Initial commit: Craft2Prints with Stages 1-3

This commit is contained in:
Andymick
2026-07-15 18:09:59 +01:00
commit 41937ffc15
158 changed files with 16054 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
'use server';
import { randomBytes } from 'crypto';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { createSession, clearSession, hashPassword, verifyPassword } from '@/lib/auth';
import { sendPasswordResetEmail } from '@/lib/mail';
import { upsertMailingListEntry } from '@/lib/mailingList';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
import { verifyTurnstileToken } from '@/lib/turnstile';
const RESET_TOKEN_DURATION_MS = 60 * 60 * 1000; // 1 hour
// Pulls in any past guest-checkout orders placed under this email so they show
// up in the customer's order history once they have an account.
async function linkGuestOrders(customerId: string, email: string) {
await prisma.order.updateMany({
where: { email, customerId: null },
data: { customerId },
});
}
function safeNext(next: FormDataEntryValue | null) {
const path = String(next ?? '');
return path.startsWith('/') && !path.startsWith('//') ? path : '/account';
}
export async function registerCustomer(formData: FormData) {
const name = String(formData.get('name') ?? '').trim();
const email = String(formData.get('email') ?? '').trim().toLowerCase();
const password = String(formData.get('password') ?? '');
const confirmPassword = String(formData.get('confirmPassword') ?? '');
const next = safeNext(formData.get('next'));
if (!name || !email || !password) redirect('/account/register?error=missing');
if (password !== confirmPassword) redirect('/account/register?error=mismatch');
if (password.length < 8) redirect('/account/register?error=weak');
const ip = getClientIp(headers());
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) redirect('/account/register?error=captcha');
const existing = await prisma.customer.findUnique({ where: { email } });
if (existing) redirect('/account/register?error=taken');
const passwordHash = await hashPassword(password);
const customer = await prisma.customer.create({
data: { email, passwordHash, name },
});
await upsertMailingListEntry({ email: customer.email, name: customer.name, source: 'CUSTOMER' });
await linkGuestOrders(customer.id, customer.email);
await createSession(customer.id);
redirect(next);
}
export async function loginCustomer(formData: FormData) {
const email = String(formData.get('email') ?? '').trim().toLowerCase();
const password = String(formData.get('password') ?? '');
const next = safeNext(formData.get('next'));
const ip = getClientIp(headers());
const { allowed } = await checkRateLimit(`login:customer:${ip}`, 5, 15 * 60 * 1000);
if (!allowed) {
redirect(`/account/login?error=rate_limited&next=${encodeURIComponent(next)}`);
}
const customer = email ? await prisma.customer.findUnique({ where: { email } }) : null;
const valid = customer ? await verifyPassword(password, customer.passwordHash) : false;
if (!customer || !valid) {
redirect(`/account/login?error=invalid&next=${encodeURIComponent(next)}`);
}
await linkGuestOrders(customer.id, customer.email);
await createSession(customer.id);
redirect(next);
}
export async function logoutCustomer() {
clearSession();
redirect('/');
}
export async function requestPasswordReset(formData: FormData) {
const email = String(formData.get('email') ?? '').trim().toLowerCase();
const customer = email ? await prisma.customer.findUnique({ where: { email } }) : null;
if (customer) {
const token = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + RESET_TOKEN_DURATION_MS);
await prisma.passwordResetToken.deleteMany({ where: { customerId: customer.id } });
await prisma.passwordResetToken.create({
data: { customerId: customer.id, token, expiresAt },
});
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
await sendPasswordResetEmail({
to: customer.email,
name: customer.name,
link: `${siteUrl}/account/reset-password?token=${token}`,
});
}
// Same outcome whether or not the email matched an account, so we don't
// reveal which emails are registered.
redirect('/account/forgot-password?sent=1');
}
export async function resetPassword(formData: FormData) {
const token = String(formData.get('token') ?? '');
const password = String(formData.get('password') ?? '');
const confirmPassword = String(formData.get('confirmPassword') ?? '');
if (!token) redirect('/account/reset-password?error=invalid');
if (password !== confirmPassword) redirect(`/account/reset-password?token=${token}&error=mismatch`);
if (password.length < 8) redirect(`/account/reset-password?token=${token}&error=weak`);
const resetToken = await prisma.passwordResetToken.findUnique({ where: { token } });
if (!resetToken || resetToken.expiresAt < new Date()) {
redirect('/account/reset-password?error=expired');
}
const passwordHash = await hashPassword(password);
await prisma.customer.update({
where: { id: resetToken.customerId },
data: { passwordHash },
});
await prisma.passwordResetToken.delete({ where: { token } });
await createSession(resetToken.customerId);
redirect('/account');
}
+47
View File
@@ -0,0 +1,47 @@
import Link from 'next/link';
import { requestPasswordReset } from '../actions';
export default function ForgotPasswordPage({ searchParams }: { searchParams: { sent?: string } }) {
const sent = searchParams.sent === '1';
return (
<div className="mx-auto max-w-md px-6 py-16">
<h1 className="font-display text-3xl">Reset your password</h1>
{sent ? (
<p className="mt-6 border border-line bg-surface px-4 py-3 text-sm">
If that email has an account, we&apos;ve sent a link to reset the password. It expires in 1 hour.
</p>
) : (
<>
<p className="mt-2 text-sm text-muted">
Enter your email and we&apos;ll send you a link to reset your password.
</p>
<form action={requestPasswordReset} className="mt-8 space-y-6">
<div>
<label htmlFor="email" className="tag-label mb-2 block">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full border border-line px-3 py-2 text-sm"
/>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Send reset link
</button>
</form>
</>
)}
<p className="mt-6 text-sm">
<Link href="/account/login" className="text-clay hover:text-clay-dark">
Back to log in
</Link>
</p>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import Link from 'next/link';
import { loginCustomer } from '../actions';
const ERROR_MESSAGES: Record<string, string> = {
invalid: 'Incorrect email or password.',
rate_limited: 'Too many attempts — please wait a few minutes and try again.',
};
export default function LoginPage({ searchParams }: { searchParams: { error?: string; next?: string } }) {
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
const next = searchParams.next ?? '/account';
return (
<div className="mx-auto max-w-md px-6 py-16">
<h1 className="font-display text-3xl">Log in</h1>
<p className="mt-2 text-sm text-muted">
New here?{' '}
<Link href={`/account/register?next=${encodeURIComponent(next)}`} className="text-clay hover:text-clay-dark">
Create an account
</Link>
</p>
{error && (
<p className="mt-6 border border-splash-pink/40 bg-splash-pink/10 px-4 py-3 text-sm text-splash-pink">
{error}
</p>
)}
<form action={loginCustomer} className="mt-8 space-y-6">
<input type="hidden" name="next" value={next} />
<div>
<label htmlFor="email" className="tag-label mb-2 block">
Email
</label>
<input id="email" name="email" type="email" required className="w-full border border-line px-3 py-2 text-sm" />
</div>
<div>
<label htmlFor="password" className="tag-label mb-2 block">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full border border-line px-3 py-2 text-sm"
/>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Log in
</button>
</form>
<p className="mt-6 text-sm">
<Link href="/account/forgot-password" className="text-clay hover:text-clay-dark">
Forgot your password?
</Link>
</p>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import { getCurrentCustomer } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
function formatPrice(cents: number) {
return `£${(cents / 100).toFixed(2)}`;
}
function statusLabel(status: string) {
if (status === 'PAID') return 'Confirmed';
if (status === 'FAILED') return 'Failed';
return 'Processing';
}
function statusClasses(status: string) {
if (status === 'PAID') return 'bg-splash-blue/10 text-splash-blue';
if (status === 'FAILED') return 'bg-splash-pink/10 text-splash-pink';
return 'bg-splash-orange/10 text-splash-orange';
}
export default async function AccountOrderDetailPage({ params }: { params: { id: string } }) {
const customer = await getCurrentCustomer();
if (!customer) redirect('/account/login');
const order = await prisma.order.findUnique({
where: { id: params.id },
include: { items: true },
});
if (!order || order.customerId !== customer.id) notFound();
const shipping = order.shippingAddress ? JSON.parse(order.shippingAddress) : null;
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<Link href="/account" className="tag-label text-muted hover:text-clay">
My account
</Link>
<div className="mt-4 flex items-baseline justify-between gap-3">
<h1 className="font-display text-3xl">Order</h1>
<span className={`tag-label rounded-full px-3 py-1 ${statusClasses(order.status)}`}>{statusLabel(order.status)}</span>
</div>
<div className="mt-6 grid gap-6 border border-line bg-surface p-6 sm:grid-cols-2">
<div>
<p className="tag-label mb-1">Placed</p>
<p className="text-sm">
{new Date(order.createdAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
</p>
</div>
{shipping && (
<div className="sm:col-span-2">
<p className="tag-label mb-1">Shipping address</p>
<p className="text-sm">
{shipping.name}
<br />
{[shipping.address?.line1, shipping.address?.line2].filter(Boolean).join(', ')}
<br />
{[shipping.address?.city, shipping.address?.postal_code].filter(Boolean).join(', ')}
<br />
{shipping.address?.country}
</p>
</div>
)}
</div>
<div className="mt-10 divide-y divide-line border-t border-line">
{order.items.map((item) => (
<div key={item.id} className="flex flex-wrap items-center justify-between gap-4 py-6">
<img
src={item.placementPreviewUrl || item.designPreviewUrl}
alt={item.productName}
className="h-24 w-24 border border-line bg-paper object-contain"
/>
<div className="flex-1">
<p className="font-display text-lg">{item.productName}</p>
<p className="text-sm text-muted">
{item.color}
{item.size ? `, ${item.size}` : ''} · qty {item.quantity}
</p>
</div>
<p className="font-mono text-sm">{formatPrice(item.unitPrice * item.quantity)}</p>
</div>
))}
</div>
<div className="mt-6 flex justify-end">
<div className="flex items-baseline gap-3">
<span className="tag-label">Total</span>
<span className="font-mono text-xl">{formatPrice(order.total)}</span>
</div>
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { getCurrentCustomer } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { logoutCustomer } from './actions';
function formatPrice(cents: number) {
return `£${(cents / 100).toFixed(2)}`;
}
function statusLabel(status: string) {
if (status === 'PAID') return 'Confirmed';
if (status === 'FAILED') return 'Failed';
return 'Processing';
}
function statusClasses(status: string) {
if (status === 'PAID') return 'bg-splash-blue/10 text-splash-blue';
if (status === 'FAILED') return 'bg-splash-pink/10 text-splash-pink';
return 'bg-splash-orange/10 text-splash-orange';
}
export default async function AccountPage() {
const customer = await getCurrentCustomer();
if (!customer) redirect('/account/login');
const orders = await prisma.order.findMany({
where: { customerId: customer.id },
orderBy: { createdAt: 'desc' },
include: { items: true },
});
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<div className="flex items-baseline justify-between gap-3">
<div>
<h1 className="font-display text-3xl">My account</h1>
<p className="mt-1 text-sm text-muted">
{customer.name ? `${customer.name} · ` : ''}
{customer.email}
</p>
</div>
<form action={logoutCustomer}>
<button
type="submit"
className="tag-label border border-line px-3 py-1.5 hover:border-clay hover:text-clay"
>
Log out
</button>
</form>
</div>
<h2 className="mt-10 font-display text-xl">Order history</h2>
{orders.length === 0 ? (
<p className="mt-3 text-sm text-muted">No orders yet.</p>
) : (
<div className="mt-4 divide-y divide-line border-t border-line">
{orders.map((order) => (
<Link
key={order.id}
href={`/account/orders/${order.id}`}
className="flex flex-wrap items-center justify-between gap-2 py-4 hover:bg-surface"
>
<div>
<p className="text-sm">
{new Date(order.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })} ·{' '}
{order.items.length} item{order.items.length === 1 ? '' : 's'}
</p>
<span className={`tag-label mt-1 inline-block rounded-full px-3 py-1 ${statusClasses(order.status)}`}>
{statusLabel(order.status)}
</span>
</div>
<span className="font-mono text-sm">{formatPrice(order.total)}</span>
</Link>
))}
</div>
)}
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import Link from 'next/link';
import { registerCustomer } from '../actions';
import Turnstile from '@/components/Turnstile';
const ERROR_MESSAGES: Record<string, string> = {
missing: 'Name, email, and password are required.',
mismatch: "Passwords don't match.",
weak: 'Password must be at least 8 characters.',
taken: 'An account with that email already exists.',
captcha: 'CAPTCHA verification failed — please try again.',
};
export default function RegisterPage({ searchParams }: { searchParams: { error?: string; next?: string } }) {
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
const next = searchParams.next ?? '/account';
return (
<div className="mx-auto max-w-md px-6 py-16">
<h1 className="font-display text-3xl">Create an account</h1>
<p className="mt-2 text-sm text-muted">
Already have one?{' '}
<Link href={`/account/login?next=${encodeURIComponent(next)}`} className="text-clay hover:text-clay-dark">
Log in
</Link>
</p>
{error && (
<p className="mt-6 border border-splash-pink/40 bg-splash-pink/10 px-4 py-3 text-sm text-splash-pink">
{error}
</p>
)}
<form action={registerCustomer} className="mt-8 space-y-6">
<input type="hidden" name="next" value={next} />
<div>
<label htmlFor="name" className="tag-label mb-2 block">
Name
</label>
<input id="name" name="name" required className="w-full border border-line px-3 py-2 text-sm" />
</div>
<div>
<label htmlFor="email" className="tag-label mb-2 block">
Email
</label>
<input id="email" name="email" type="email" required className="w-full border border-line px-3 py-2 text-sm" />
</div>
<div>
<label htmlFor="password" className="tag-label mb-2 block">
Password
</label>
<input
id="password"
name="password"
type="password"
required
minLength={8}
className="w-full border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="confirmPassword" className="tag-label mb-2 block">
Confirm password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minLength={8}
className="w-full border border-line px-3 py-2 text-sm"
/>
</div>
<Turnstile />
<button
type="submit"
className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark disabled:cursor-not-allowed disabled:opacity-50"
>
Create account
</button>
<p className="text-xs text-muted">
By creating an account you agree to our{' '}
<Link href="/privacy" className="text-clay hover:text-clay-dark">
Privacy Policy
</Link>
.
</p>
</form>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import Link from 'next/link';
import { resetPassword } from '../actions';
const ERROR_MESSAGES: Record<string, string> = {
invalid: 'This reset link is invalid.',
expired: 'This reset link has expired — request a new one.',
mismatch: "Passwords don't match.",
weak: 'Password must be at least 8 characters.',
};
export default function ResetPasswordPage({ searchParams }: { searchParams: { token?: string; error?: string } }) {
const token = searchParams.token ?? '';
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
if (!token) {
return (
<div className="mx-auto max-w-md px-6 py-16">
<h1 className="font-display text-3xl">Reset your password</h1>
<p className="mt-6 border border-splash-pink/40 bg-splash-pink/10 px-4 py-3 text-sm text-splash-pink">
This reset link is invalid or incomplete.
</p>
<p className="mt-6 text-sm">
<Link href="/account/forgot-password" className="text-clay hover:text-clay-dark">
Request a new link
</Link>
</p>
</div>
);
}
return (
<div className="mx-auto max-w-md px-6 py-16">
<h1 className="font-display text-3xl">Choose a new password</h1>
{error && (
<p className="mt-6 border border-splash-pink/40 bg-splash-pink/10 px-4 py-3 text-sm text-splash-pink">
{error}
</p>
)}
<form action={resetPassword} className="mt-8 space-y-6">
<input type="hidden" name="token" value={token} />
<div>
<label htmlFor="password" className="tag-label mb-2 block">
New password
</label>
<input
id="password"
name="password"
type="password"
required
minLength={8}
className="w-full border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="confirmPassword" className="tag-label mb-2 block">
Confirm new password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minLength={8}
className="w-full border border-line px-3 py-2 text-sm"
/>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Set new password
</button>
</form>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
'use server';
import { headers } from 'next/headers';
import { prisma } from '@/lib/prisma';
import { upsertMailingListEntry } from '@/lib/mailingList';
import { verifyTurnstileToken } from '@/lib/turnstile';
import { getClientIp } from '@/lib/rateLimit';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export async function subscribeToNewsletter(email: string, turnstileToken: string) {
const trimmed = email.trim().toLowerCase();
if (!EMAIL_RE.test(trimmed)) {
return { ok: false, message: 'Enter a valid email address.' };
}
const ip = getClientIp(headers());
const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) {
return { ok: false, message: 'CAPTCHA verification failed — please try again.' };
}
await prisma.newsletterSubscriber.upsert({
where: { email: trimmed },
create: { email: trimmed },
update: {},
});
await upsertMailingListEntry({ email: trimmed, source: 'NEWSLETTER' });
return { ok: true, message: "Thanks — you're on the list." };
}
+91
View File
@@ -0,0 +1,91 @@
'use server';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { applyStockMovement } from '@/lib/stock';
export type ManualSaleItemInput = {
productId: string | null; // null = free-text one-off
description: string;
color: string | null;
size: string | null;
quantity: number;
unitPrice: number; // pence
};
export async function createManualSale(formData: FormData) {
const soldAtInput = String(formData.get('soldAt') ?? '').trim();
const buyerName = String(formData.get('buyerName') ?? '').trim();
const buyerEmail = String(formData.get('buyerEmail') ?? '').trim().toLowerCase();
const paymentMethod = String(formData.get('paymentMethod') ?? 'CASH');
const notes = String(formData.get('notes') ?? '').trim();
const itemsJson = String(formData.get('items') ?? '[]');
if (!['CASH', 'BANK_TRANSFER'].includes(paymentMethod)) {
throw new Error('Unknown payment method.');
}
let items: ManualSaleItemInput[];
try {
items = JSON.parse(itemsJson);
} catch {
throw new Error('Could not read the sale items.');
}
items = items.filter((i) => i.description.trim() && i.quantity > 0);
if (items.length === 0) {
throw new Error('A sale needs at least one item.');
}
for (const item of items) {
if (!Number.isInteger(item.quantity) || item.quantity < 1 || item.quantity > 10000) {
throw new Error('Quantities must be whole numbers.');
}
if (!Number.isInteger(item.unitPrice) || item.unitPrice < 0) {
throw new Error('Prices cannot be negative.');
}
}
// Total always recomputed here — never trusted from the client.
const total = items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0);
// Auto-link to an existing customer account when the email matches, so this
// sale shows up in their combined purchase history later.
const customer = buyerEmail ? await prisma.customer.findUnique({ where: { email: buyerEmail } }) : null;
await prisma.manualSale.create({
data: {
soldAt: soldAtInput ? new Date(soldAtInput) : new Date(),
buyerName: buyerName || null,
buyerEmail: buyerEmail || null,
customerId: customer?.id ?? null,
paymentMethod,
total,
notes: notes || null,
items: {
create: items.map((i) => ({
productId: i.productId,
description: i.description.trim(),
color: i.color,
size: i.size,
quantity: i.quantity,
unitPrice: i.unitPrice,
})),
},
},
});
// Product lines reduce stock; free-text lines carry none.
await applyStockMovement(items, -1);
redirect('/admin/accounts');
}
export async function deleteManualSale(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
const sale = await prisma.manualSale.findUnique({ where: { id }, include: { items: true } });
if (!sale) return;
await prisma.manualSale.delete({ where: { id } });
// Deleting a sale puts its stock back — the sale never happened.
await applyStockMovement(sale.items, 1);
redirect('/admin/accounts');
}
+28
View File
@@ -0,0 +1,28 @@
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import ManualSaleForm from '@/components/ManualSaleForm';
export default async function NewManualSalePage() {
const rows = await prisma.product.findMany({ orderBy: { name: 'asc' } });
const products = rows.map((p) => ({
id: p.id,
name: p.name,
basePrice: p.basePrice,
colors: JSON.parse(p.colors) as string[],
sizes: p.sizes ? (JSON.parse(p.sizes) as string[]) : [],
}));
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Record a sale</h1>
<p className="mt-2 text-sm text-muted">
For anything sold outside the website cash at a fair, a bank transfer arranged over
Facebook. It lands in the same ledger as website orders. If the buyer&apos;s email matches a
customer account, the sale is linked to them automatically.
</p>
<ManualSaleForm products={products} />
</div>
);
}
+223
View File
@@ -0,0 +1,223 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import AccountsNav from '@/components/AccountsNav';
import Price from '@/components/Price';
import { deleteManualSale } from './actions';
const PAYMENT_LABELS: Record<string, string> = {
CASH: 'Cash',
BANK_TRANSFER: 'Bank transfer',
};
// One row of the merged ledger — a website order and a manual sale flattened
// to the same shape so they can be sorted and totalled together.
type LedgerEntry = {
id: string;
kind: 'WEB' | 'MANUAL';
date: Date;
who: string;
what: string;
method: string;
total: number;
href: string | null; // detail link (web orders only for now)
};
export default async function AccountsPage({
searchParams,
}: {
searchParams: { type?: string; from?: string; to?: string };
}) {
const type = searchParams.type === 'web' || searchParams.type === 'manual' ? searchParams.type : 'all';
const from = searchParams.from ? new Date(searchParams.from) : null;
// Include the whole "to" day, not just its midnight.
const to = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null;
const [orders, manualSales] = await Promise.all([
type === 'manual'
? []
: prisma.order.findMany({
where: {
status: 'PAID',
...(from || to ? { createdAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } : {}),
},
include: { items: true, customer: true },
}),
type === 'web'
? []
: prisma.manualSale.findMany({
where: from || to ? { soldAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } : {},
include: { items: true },
}),
]);
const entries: LedgerEntry[] = [
...orders.map((o) => ({
id: o.id,
kind: 'WEB' as const,
date: o.createdAt,
who: o.customer?.name ?? o.email ?? 'Guest',
what: o.items.map((i) => `${i.quantity}× ${i.productName}`).join(', '),
method: 'Card (website)',
total: o.total,
href: `/admin/orders`,
})),
...manualSales.map((s) => ({
id: s.id,
kind: 'MANUAL' as const,
date: s.soldAt,
who: s.buyerName || s.buyerEmail || 'Unnamed buyer',
what: s.items.map((i) => `${i.quantity}× ${i.description}`).join(', '),
method: PAYMENT_LABELS[s.paymentMethod] ?? s.paymentMethod,
total: s.total,
href: null,
})),
].sort((a, b) => b.date.getTime() - a.date.getTime());
const totalAll = entries.reduce((sum, e) => sum + e.total, 0);
const totalWeb = entries.filter((e) => e.kind === 'WEB').reduce((sum, e) => sum + e.total, 0);
const totalManual = totalAll - totalWeb;
const filterHref = (t: string) => {
const params = new URLSearchParams();
if (t !== 'all') params.set('type', t);
if (searchParams.from) params.set('from', searchParams.from);
if (searchParams.to) params.set('to', searchParams.to);
const qs = params.toString();
return `/admin/accounts${qs ? `?${qs}` : ''}`;
};
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<AdminNav />
<div className="flex flex-wrap items-baseline justify-between gap-3">
<h1 className="font-display text-3xl">Accounts</h1>
<Link
href="/admin/accounts/new"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
+ Record a sale
</Link>
</div>
<AccountsNav active="/admin/accounts" />
<p className="mt-4 text-sm text-muted">
Every sale in one place paid website orders plus anything you record by hand (cash, bank
transfer). Website orders appear here automatically once paid.
</p>
{/* Totals for whatever the current filter shows */}
<div className="mt-6 grid gap-3 sm:grid-cols-3">
<div className="border border-line bg-surface p-4">
<p className="tag-label">Total</p>
<p className="mt-1 font-mono text-xl">
<Price cents={totalAll} />
</p>
</div>
<div className="border border-line bg-surface p-4">
<p className="tag-label">Website</p>
<p className="mt-1 font-mono text-xl">
<Price cents={totalWeb} />
</p>
</div>
<div className="border border-line bg-surface p-4">
<p className="tag-label">Manual</p>
<p className="mt-1 font-mono text-xl">
<Price cents={totalManual} />
</p>
</div>
</div>
{/* Filters */}
<div className="mt-6 flex flex-wrap items-center gap-4">
<div className="inline-flex border border-line">
{(
[
['all', 'All'],
['web', 'Website'],
['manual', 'Manual'],
] as const
).map(([value, label]) => (
<Link
key={value}
href={filterHref(value)}
className={`px-4 py-1.5 text-sm ${type === value ? 'bg-ink text-paper' : 'hover:text-clay'}`}
>
{label}
</Link>
))}
</div>
<form method="get" className="flex flex-wrap items-center gap-2 text-sm">
{type !== 'all' && <input type="hidden" name="type" value={type} />}
<label className="flex items-center gap-1 text-muted">
From
<input
type="date"
name="from"
defaultValue={searchParams.from ?? ''}
className="border border-line bg-paper px-2 py-1.5"
/>
</label>
<label className="flex items-center gap-1 text-muted">
To
<input
type="date"
name="to"
defaultValue={searchParams.to ?? ''}
className="border border-line bg-paper px-2 py-1.5"
/>
</label>
<button type="submit" className="border border-line px-3 py-1.5 hover:border-clay hover:text-clay">
Apply
</button>
{(searchParams.from || searchParams.to) && (
<Link href={filterHref(type)} className="tag-label text-muted hover:text-ink">
Clear dates
</Link>
)}
</form>
</div>
{/* Ledger */}
{entries.length === 0 ? (
<p className="mt-8 text-sm text-muted">No sales in this range yet.</p>
) : (
<div className="mt-8 divide-y divide-line border-t border-line">
{entries.map((e) => (
<div key={`${e.kind}-${e.id}`} className="flex flex-wrap items-center gap-x-4 gap-y-1 py-4">
<span
className={`tag-label shrink-0 rounded-full px-3 py-1 ${
e.kind === 'WEB' ? 'bg-splash-blue/10 text-splash-blue' : 'bg-splash-orange/10 text-splash-orange'
}`}
>
{e.kind === 'WEB' ? 'Website' : 'Manual'}
</span>
<span className="w-24 shrink-0 font-mono text-xs text-muted">
{e.date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{e.who}</p>
<p className="truncate text-xs text-muted">{e.what}</p>
</div>
<span className="tag-label hidden text-muted sm:inline">{e.method}</span>
<span className="w-20 shrink-0 text-right font-mono text-sm">
<Price cents={e.total} />
</span>
{e.kind === 'MANUAL' ? (
<form action={deleteManualSale}>
<input type="hidden" name="id" value={e.id} />
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
Delete
</button>
</form>
) : (
<Link href={e.href!} className="tag-label text-muted hover:text-ink">
View
</Link>
)}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,80 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import Price from '@/components/Price';
export default async function PurchaseDetailPage({ params }: { params: { id: string } }) {
const purchase = await prisma.purchase.findUnique({
where: { id: params.id },
include: { items: { include: { product: true } } },
});
if (!purchase) notFound();
const isPdf = purchase.invoiceDataUrl?.startsWith('data:application/pdf');
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between gap-3">
<h1 className="font-display text-3xl">{purchase.supplierName}</h1>
<span className="font-mono text-sm text-muted">
{purchase.purchasedAt.toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' })}
</span>
</div>
<div className="mt-8 divide-y divide-line border-t border-line">
{purchase.items.map((item) => (
<div key={item.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 py-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{item.description}</p>
<p className="text-xs text-muted">
{item.productId
? [item.color, item.size].filter(Boolean).join(' · ') || 'Stock item'
: 'Material / other — not stock-counted'}
</p>
</div>
<span className="font-mono text-xs text-muted">
{item.quantity} × <Price cents={item.unitCost} />
</span>
<span className="w-20 shrink-0 text-right font-mono text-sm">
<Price cents={item.quantity * item.unitCost} />
</span>
</div>
))}
</div>
<p className="mt-4 text-right font-mono text-lg">
Total <Price cents={purchase.total} />
</p>
{purchase.notes && <div className="mt-6 border-l-2 border-splash-orange pl-4 text-sm italic">{purchase.notes}</div>}
{purchase.invoiceDataUrl && (
<div className="mt-8">
<p className="tag-label mb-3">Invoice{purchase.invoiceFileName ? `${purchase.invoiceFileName}` : ''}</p>
{isPdf ? (
<iframe src={purchase.invoiceDataUrl} title="Invoice" className="h-[600px] w-full border border-line" />
) : (
<img
src={purchase.invoiceDataUrl}
alt="Invoice"
className="max-h-[600px] w-full border border-line object-contain"
/>
)}
<a
href={purchase.invoiceDataUrl}
download={purchase.invoiceFileName ?? 'invoice'}
className="mt-2 inline-block tag-label text-clay hover:text-clay-dark"
>
Download invoice
</a>
</div>
)}
<Link href="/admin/accounts/purchases" className="mt-8 inline-block tag-label hover:text-ink">
All purchases
</Link>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
'use server';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { applyStockMovement } from '@/lib/stock';
export type PurchaseItemInput = {
productId: string | null; // null = loose material line (ink, packaging…)
description: string;
color: string | null;
size: string | null;
quantity: number;
unitCost: number; // pence
};
const MAX_INVOICE_BYTES = 8 * 1024 * 1024; // keep data-URL rows sane
export async function createPurchase(formData: FormData) {
const supplierName = String(formData.get('supplierName') ?? '').trim();
const purchasedAtInput = String(formData.get('purchasedAt') ?? '').trim();
const notes = String(formData.get('notes') ?? '').trim();
const itemsJson = String(formData.get('items') ?? '[]');
const invoice = formData.get('invoice') as File | null;
if (!supplierName) throw new Error('Supplier name is required.');
let items: PurchaseItemInput[];
try {
items = JSON.parse(itemsJson);
} catch {
throw new Error('Could not read the purchase items.');
}
items = items.filter((i) => i.description.trim() && i.quantity > 0);
if (items.length === 0) throw new Error('A purchase needs at least one item.');
for (const item of items) {
if (!Number.isInteger(item.quantity) || item.quantity < 1 || item.quantity > 100000) {
throw new Error('Quantities must be whole numbers.');
}
if (!Number.isInteger(item.unitCost) || item.unitCost < 0) {
throw new Error('Costs cannot be negative.');
}
}
let invoiceDataUrl: string | null = null;
let invoiceFileName: string | null = null;
if (invoice && invoice.size > 0) {
if (invoice.size > MAX_INVOICE_BYTES) {
throw new Error('Invoice file is too large — keep it under 8 MB.');
}
const bytes = Buffer.from(await invoice.arrayBuffer());
const mimeType = invoice.type || 'application/octet-stream';
invoiceDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
invoiceFileName = invoice.name || 'invoice';
}
const total = items.reduce((sum, i) => sum + i.quantity * i.unitCost, 0);
const purchaseDate = purchasedAtInput ? new Date(purchasedAtInput) : new Date();
await prisma.purchase.create({
data: {
supplierName,
purchasedAt: purchaseDate,
invoiceDataUrl,
invoiceFileName,
total,
notes: notes || null,
items: {
create: items.map((i) => ({
productId: i.productId,
description: i.description.trim(),
color: i.color,
size: i.size,
quantity: i.quantity,
unitCost: i.unitCost,
})),
},
// Auto-create an Expense entry for accounting — every purchase is an expense.
expense: {
create: {
date: purchaseDate,
description: `Stock purchase: ${supplierName}`,
category: 'Stock purchases',
amount: total,
notes: notes || null,
},
},
},
});
// Product lines add to stock; material lines are just logged.
await applyStockMovement(items, 1);
redirect('/admin/accounts/purchases');
}
export async function deletePurchase(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
const purchase = await prisma.purchase.findUnique({ where: { id }, include: { items: true } });
if (!purchase) return;
await prisma.purchase.delete({ where: { id } });
// Removing a purchase takes its stock back out.
await applyStockMovement(purchase.items, -1);
redirect('/admin/accounts/purchases');
}
// Direct correction from the stock page — "I counted the shelf and there are
// actually N". Sets the absolute value rather than nudging by a delta.
export async function setStockQuantity(formData: FormData) {
const productId = String(formData.get('productId') ?? '');
const color = String(formData.get('color') ?? '');
const size = String(formData.get('size') ?? '');
const quantity = Math.trunc(Number(formData.get('quantity')));
if (!productId || !color || !Number.isFinite(quantity)) return;
await prisma.stockLevel.upsert({
where: { productId_color_size: { productId, color, size } },
create: { productId, color, size, quantity },
update: { quantity },
});
redirect('/admin/accounts/stock');
}
@@ -0,0 +1,27 @@
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import PurchaseForm from '@/components/PurchaseForm';
export default async function NewPurchasePage() {
const rows = await prisma.product.findMany({ orderBy: { name: 'asc' } });
const products = rows.map((p) => ({
id: p.id,
name: p.name,
colors: JSON.parse(p.colors) as string[],
sizes: p.sizes ? (JSON.parse(p.sizes) as string[]) : [],
}));
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Record a purchase</h1>
<p className="mt-2 text-sm text-muted">
A supplier invoice blanks, materials, anything you bought for the business. Attach the
invoice file and it&apos;s stored with the purchase. Lines matched to a product add straight to
its stock count.
</p>
<PurchaseForm products={products} />
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import AccountsNav from '@/components/AccountsNav';
import Price from '@/components/Price';
import { deletePurchase } from './actions';
export default async function PurchasesPage() {
const purchases = await prisma.purchase.findMany({
include: { items: true },
orderBy: { purchasedAt: 'desc' },
});
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<AdminNav />
<div className="flex flex-wrap items-baseline justify-between gap-3">
<h1 className="font-display text-3xl">Purchases</h1>
<Link
href="/admin/accounts/purchases/new"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
+ Record a purchase
</Link>
</div>
<AccountsNav active="/admin/accounts/purchases" />
<p className="mt-4 text-sm text-muted">
Stock coming in from suppliers, with the invoice attached. Product lines add to stock
counts automatically; deleting a purchase takes them back out.
</p>
{purchases.length === 0 ? (
<p className="mt-8 text-sm text-muted">No purchases recorded yet.</p>
) : (
<div className="mt-8 divide-y divide-line border-t border-line">
{purchases.map((p) => (
<div key={p.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 py-4">
<span className="w-24 shrink-0 font-mono text-xs text-muted">
{p.purchasedAt.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{p.supplierName}</p>
<p className="truncate text-xs text-muted">
{p.items.map((i) => `${i.quantity}× ${i.description}`).join(', ')}
</p>
</div>
{p.invoiceDataUrl && <span className="tag-label hidden text-muted sm:inline">Invoice attached</span>}
<span className="w-20 shrink-0 text-right font-mono text-sm">
<Price cents={p.total} />
</span>
<Link href={`/admin/accounts/purchases/${p.id}`} className="tag-label text-muted hover:text-ink">
View
</Link>
<form action={deletePurchase}>
<input type="hidden" name="id" value={p.id} />
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
Delete
</button>
</form>
</div>
))}
</div>
)}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import AccountsNav from '@/components/AccountsNav';
import StockAdjustForm from '@/components/StockAdjustForm';
export default async function StockPage() {
const [levels, productRows] = await Promise.all([
prisma.stockLevel.findMany({
include: { product: true },
orderBy: [{ product: { name: 'asc' } }, { color: 'asc' }, { size: 'asc' }],
}),
prisma.product.findMany({ orderBy: { name: 'asc' } }),
]);
const products = productRows.map((p) => ({
id: p.id,
name: p.name,
colors: JSON.parse(p.colors) as string[],
sizes: p.sizes ? (JSON.parse(p.sizes) as string[]) : [],
}));
// Group rows under their product for a readable table.
const grouped = new Map<string, typeof levels>();
for (const level of levels) {
const list = grouped.get(level.productId) ?? [];
list.push(level);
grouped.set(level.productId, list);
}
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Stock</h1>
<AccountsNav active="/admin/accounts/stock" />
<p className="mt-4 text-sm text-muted">
Counts go up when you record a purchase and down when something sells (website or manual).
A <span className="text-splash-pink">negative count</span> means a sale happened that your
purchases don&apos;t cover enter the missing invoice or set the real count below. Nothing
here ever blocks an order.
</p>
<StockAdjustForm products={products} />
{levels.length === 0 ? (
<p className="mt-8 text-sm text-muted">
No stock recorded yet record a purchase, or set a count above to do an initial stock take.
</p>
) : (
<div className="mt-8 space-y-8">
{[...grouped.entries()].map(([productId, rows]) => (
<div key={productId}>
<h2 className="font-display text-xl">{rows[0].product.name}</h2>
<div className="mt-2 divide-y divide-line border-t border-line">
{rows.map((row) => (
<div key={row.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 py-2.5">
<span
className="h-5 w-5 shrink-0 rounded-full border border-line"
style={{ backgroundColor: row.color }}
title={row.color}
/>
<span className="w-20 font-mono text-xs text-muted">{row.color}</span>
<span className="w-12 text-sm">{row.size || '—'}</span>
<span
className={`ml-auto font-mono text-sm ${row.quantity < 0 ? 'font-medium text-splash-pink' : ''}`}
>
{row.quantity} in stock
</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
'use server';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
function slugify(input: string) {
return input
.toUpperCase()
.trim()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
async function uniqueSlug(base: string) {
let slug = base || 'CATEGORY';
let n = 2;
while (await prisma.category.findUnique({ where: { slug } })) {
slug = `${base}_${n}`;
n += 1;
}
return slug;
}
export async function createCategory(formData: FormData) {
const name = String(formData.get('name') ?? '').trim();
if (!name) throw new Error('Category name is required.');
const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1';
const showOnProducts = String(formData.get('showOnProducts') ?? '') === '1';
const slug = await uniqueSlug(slugify(name));
await prisma.category.create({ data: { name, slug, showOnPersonalised, showOnProducts } });
redirect('/admin/categories');
}
export async function updateCategoryVisibility(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1';
const showOnProducts = String(formData.get('showOnProducts') ?? '') === '1';
await prisma.category.update({ where: { id }, data: { showOnPersonalised, showOnProducts } });
redirect('/admin/categories');
}
export async function deleteCategory(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
const category = await prisma.category.findUnique({ where: { id } });
if (!category) return;
const inUse = await prisma.product.count({ where: { category: category.slug } });
if (inUse > 0) {
redirect('/admin/categories?error=in_use');
}
await prisma.category.delete({ where: { id } });
redirect('/admin/categories');
}
+59
View File
@@ -0,0 +1,59 @@
import { createCategory } from '../actions';
import AdminNav from '@/components/AdminNav';
export default function NewCategoryPage() {
return (
<div className="mx-auto max-w-md px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Add a category</h1>
<p className="mt-2 text-sm text-muted">
New categories show up right away in the product form, the shop filters, and the main nav.
</p>
<form action={createCategory} className="mt-8 space-y-6">
<div>
<label htmlFor="name" className="tag-label mb-2 block">
Category name
</label>
<input
id="name"
name="name"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Tote bags"
/>
</div>
<div>
<label className="tag-label mb-2 block">Show as a filter on</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="showOnPersonalised"
value="1"
defaultChecked
className="h-4 w-4 accent-clay"
/>
Personalised catalog (/made-to-order)
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="showOnProducts"
value="1"
defaultChecked
className="h-4 w-4 accent-clay"
/>
Products catalog (/products)
</label>
</div>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Add category
</button>
</form>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { deleteCategory, updateCategoryVisibility } from './actions';
export default async function CategoriesPage({ searchParams }: { searchParams: { error?: string } }) {
const categories = await prisma.category.findMany({ orderBy: { createdAt: 'asc' } });
const counts = await prisma.product.groupBy({ by: ['category'], _count: true });
const countFor = (slug: string) => counts.find((c) => c.category === slug)?._count ?? 0;
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between">
<h1 className="font-display text-3xl">Categories</h1>
<Link
href="/admin/categories/new"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
+ Add category
</Link>
</div>
{searchParams.error === 'in_use' && (
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
Can&apos;t delete that category there are still products using it. Move or delete those
products first.
</div>
)}
<div className="mt-8 divide-y divide-line border-t border-line">
{categories.map((c) => (
<div key={c.id} className="flex items-center gap-4 py-4">
<div className="flex-1">
<p className="font-medium">{c.name}</p>
<p className="tag-label mt-0.5">
{countFor(c.slug)} product{countFor(c.slug) === 1 ? '' : 's'}
</p>
</div>
<form action={updateCategoryVisibility} className="flex items-center gap-3">
<input type="hidden" name="id" value={c.id} />
<label className="flex items-center gap-1.5 text-xs text-muted">
<input
type="checkbox"
name="showOnPersonalised"
value="1"
defaultChecked={c.showOnPersonalised}
className="h-3.5 w-3.5 accent-clay"
/>
Personalised
</label>
<label className="flex items-center gap-1.5 text-xs text-muted">
<input
type="checkbox"
name="showOnProducts"
value="1"
defaultChecked={c.showOnProducts}
className="h-3.5 w-3.5 accent-clay"
/>
Products
</label>
<button type="submit" className="tag-label hover:text-clay">
Save
</button>
</form>
<form action={deleteCategory}>
<input type="hidden" name="id" value={c.id} />
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
Delete
</button>
</form>
</div>
))}
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
'use server';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
function slugify(input: string) {
return input
.toUpperCase()
.trim()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
async function uniqueSlug(base: string) {
let slug = base || 'COLLECTION';
let n = 2;
while (await prisma.collection.findUnique({ where: { slug } })) {
slug = `${base}_${n}`;
n += 1;
}
return slug;
}
export async function createCollection(formData: FormData) {
const name = String(formData.get('name') ?? '').trim();
if (!name) throw new Error('Name is required.');
const group = String(formData.get('group') ?? '');
if (group !== 'OCCASION' && group !== 'RECIPIENT') {
throw new Error('Choose Occasion or Recipient.');
}
const slug = await uniqueSlug(slugify(name));
await prisma.collection.create({ data: { name, slug, group } });
redirect('/admin/collections');
}
export async function deleteCollection(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
const inUse = await prisma.product.count({ where: { collections: { some: { id } } } });
if (inUse > 0) {
redirect('/admin/collections?error=in_use');
}
await prisma.collection.delete({ where: { id } });
redirect('/admin/collections');
}
+53
View File
@@ -0,0 +1,53 @@
import { createCollection } from '../actions';
import AdminNav from '@/components/AdminNav';
export default function NewCollectionPage() {
return (
<div className="mx-auto max-w-md px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Add a collection</h1>
<p className="mt-2 text-sm text-muted">
New collections show up right away in the product form and the Occasions/Gifts For menus.
</p>
<form action={createCollection} className="mt-8 space-y-6">
<div>
<label htmlFor="name" className="tag-label mb-2 block">
Name
</label>
<input
id="name"
name="name"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Christmas"
/>
</div>
<div>
<label className="tag-label mb-2 block">Type</label>
<div className="space-y-2">
<label className="flex items-start gap-2 text-sm">
<input type="radio" name="group" value="OCCASION" required className="mt-1 h-4 w-4 accent-clay" />
<span>
Occasion
<span className="mt-0.5 block text-xs text-muted">e.g. Christmas, birthdays, holidays</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm">
<input type="radio" name="group" value="RECIPIENT" required className="mt-1 h-4 w-4 accent-clay" />
<span>
Recipient
<span className="mt-0.5 block text-xs text-muted">e.g. teachers, school leavers</span>
</span>
</label>
</div>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Add collection
</button>
</form>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { deleteCollection } from './actions';
export default async function CollectionsPage({ searchParams }: { searchParams: { error?: string } }) {
const collections = await prisma.collection.findMany({ orderBy: { createdAt: 'asc' } });
const occasions = collections.filter((c) => c.group === 'OCCASION');
const recipients = collections.filter((c) => c.group === 'RECIPIENT');
const counts = await Promise.all(
collections.map((c) => prisma.product.count({ where: { collections: { some: { id: c.id } } } })),
);
const countFor = (id: string) => counts[collections.findIndex((c) => c.id === id)] ?? 0;
const Section = ({ title, items }: { title: string; items: typeof collections }) => (
<div>
<h2 className="tag-label mt-10 mb-3 text-ink">{title}</h2>
{items.length === 0 ? (
<p className="text-sm text-muted">None yet.</p>
) : (
<div className="divide-y divide-line border-t border-line">
{items.map((c) => (
<div key={c.id} className="flex items-center gap-4 py-4">
<div className="flex-1">
<p className="font-medium">{c.name}</p>
<p className="tag-label mt-0.5">
{countFor(c.id)} product{countFor(c.id) === 1 ? '' : 's'}
</p>
</div>
<form action={deleteCollection}>
<input type="hidden" name="id" value={c.id} />
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
Delete
</button>
</form>
</div>
))}
</div>
)}
</div>
);
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between">
<h1 className="font-display text-3xl">Collections</h1>
<Link
href="/admin/collections/new"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
+ Add collection
</Link>
</div>
<p className="mt-2 text-sm text-muted">
These power the &quot;Occasions&quot; and &quot;Gifts For&quot; menus, and are tagged onto
products from each product&apos;s edit page.
</p>
{searchParams.error === 'in_use' && (
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
Can&apos;t delete that there are still products tagged with it. Untag those products
first.
</div>
)}
<Section title="Occasions" items={occasions} />
<Section title="Gifts for" items={recipients} />
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import ShareButtons from '@/components/ShareButtons';
import { deleteRequest, markRequestDone, markRequestNew } from '../actions';
export default async function CustomRequestDetailPage({ params }: { params: { id: string } }) {
const request = await prisma.customRequest.findUnique({
where: { id: params.id },
include: { product: true },
});
if (!request) notFound();
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
const adminLink = `${siteUrl}/admin/custom-requests/${request.id}`;
const whatsappDigits = request.customerPhone ? request.customerPhone.replace(/\D/g, '') : null;
const whatsappMessage = `Hi ${request.customerName}, thanks for sending over your photo for a ${request.product.name} — we're working on it!`;
const newProofParams = new URLSearchParams({
productId: request.productId,
customerName: request.customerName,
customerEmail: request.customerEmail,
});
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between gap-3">
<h1 className="font-display text-3xl">Photo request</h1>
<span
className={`tag-label rounded-full px-3 py-1 ${
request.status === 'NEW' ? 'bg-splash-orange/10 text-splash-orange' : 'bg-splash-blue/10 text-splash-blue'
}`}
>
{request.status}
</span>
</div>
<div className="mt-6 border border-line bg-surface p-6">
<img src={request.imageDataUrl} alt="Customer's photo" className="mx-auto max-h-96 object-contain" />
</div>
{request.note && (
<div className="mt-6 border-l-2 border-splash-pink pl-4 text-sm italic">"{request.note}"</div>
)}
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<div>
<p className="tag-label mb-1">Customer</p>
<p className="text-sm">{request.customerName}</p>
<p className="text-sm text-muted">{request.customerEmail}</p>
{request.customerPhone && <p className="text-sm text-muted">{request.customerPhone}</p>}
</div>
<div>
<p className="tag-label mb-1">For</p>
<p className="text-sm">{request.product.name}</p>
</div>
</div>
<div className="mt-8 flex flex-wrap gap-3">
<Link
href={`/admin/proofs/new?${newProofParams.toString()}`}
className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark"
>
Start a design for this
</Link>
{whatsappDigits && (
<a
href={`https://wa.me/${whatsappDigits}?text=${encodeURIComponent(whatsappMessage)}`}
target="_blank"
rel="noopener noreferrer"
className="border border-ink px-6 py-3 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Message {request.customerName} on WhatsApp
</a>
)}
<form action={request.status === 'NEW' ? markRequestDone : markRequestNew}>
<input type="hidden" name="id" value={request.id} />
<button
type="submit"
className="tag-label border border-line px-4 py-3 hover:border-clay hover:text-clay"
>
{request.status === 'NEW' ? 'Mark as done' : 'Reopen'}
</button>
</form>
<form action={deleteRequest}>
<input type="hidden" name="id" value={request.id} />
<button
type="submit"
className="tag-label border border-line px-4 py-3 text-muted hover:border-splash-pink hover:text-splash-pink"
>
Delete
</button>
</form>
</div>
<div className="mt-10 border-t border-line pt-6">
<p className="tag-label mb-3">Notify yourself / a colleague</p>
<ShareButtons
message={`New photo request from ${request.customerName} for a ${request.product.name}: ${adminLink}`}
link={adminLink}
redirectUri={adminLink}
/>
</div>
<Link href="/admin/custom-requests" className="mt-8 inline-block tag-label hover:text-ink">
All photo requests
</Link>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
'use server';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
export async function markRequestDone(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
await prisma.customRequest.update({ where: { id }, data: { status: 'DONE' } });
redirect('/admin/custom-requests');
}
export async function markRequestNew(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
await prisma.customRequest.update({ where: { id }, data: { status: 'NEW' } });
redirect('/admin/custom-requests');
}
export async function deleteRequest(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
await prisma.customRequest.delete({ where: { id } });
redirect('/admin/custom-requests');
}
+53
View File
@@ -0,0 +1,53 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { deleteRequest } from './actions';
export default async function CustomRequestsPage() {
const rows = await prisma.customRequest.findMany({
include: { product: true },
orderBy: { createdAt: 'desc' },
});
// NEW requests first, most recent within each group (Array.sort is stable).
const requests = [...rows].sort((a, b) => (a.status === b.status ? 0 : a.status === 'NEW' ? -1 : 1));
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Photo requests</h1>
{requests.length === 0 ? (
<p className="mt-8 text-sm text-muted">No photo requests yet.</p>
) : (
<div className="mt-8 divide-y divide-line border-t border-line">
{requests.map((r) => (
<div key={r.id} className="flex items-center gap-4 py-4 hover:bg-surface">
<Link href={`/admin/custom-requests/${r.id}`} className="flex flex-1 items-center gap-4">
<img src={r.imageDataUrl} alt="" className="h-14 w-14 border border-line object-cover" />
<div className="flex-1">
<p className="font-medium">{r.customerName}</p>
<p className="truncate text-sm text-muted">{r.customerEmail}</p>
</div>
<span className="tag-label">{r.product.name}</span>
<span
className={`tag-label rounded-full px-3 py-1 ${
r.status === 'NEW' ? 'bg-splash-orange/10 text-splash-orange' : 'bg-splash-blue/10 text-splash-blue'
}`}
>
{r.status}
</span>
</Link>
<form action={deleteRequest}>
<input type="hidden" name="id" value={r.id} />
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
Delete
</button>
</form>
</div>
))}
</div>
)}
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
'use server';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { sendCustomerBroadcast } from '@/lib/mail';
export async function updateSubscribed(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
const subscribed = String(formData.get('subscribed') ?? '') === '1';
await prisma.mailingListEntry.update({ where: { id }, data: { subscribed } });
redirect('/admin/customers');
}
export async function sendBulkEmail(formData: FormData) {
const subject = String(formData.get('subject') ?? '').trim();
const message = String(formData.get('message') ?? '').trim();
if (!subject || !message) {
throw new Error('Subject and message are required.');
}
const attachmentFile = formData.get('attachment') as File | null;
const attachment =
attachmentFile && attachmentFile.size > 0
? {
filename: attachmentFile.name,
content: Buffer.from(await attachmentFile.arrayBuffer()),
contentType: attachmentFile.type || undefined,
}
: null;
const recipients = await prisma.mailingListEntry.findMany({ where: { subscribed: true } });
let sent = 0;
let failed = 0;
for (const recipient of recipients) {
const result = await sendCustomerBroadcast({
to: recipient.email,
name: recipient.name,
subject,
message,
attachment,
});
if (result.sent) sent += 1;
else failed += 1;
}
redirect(`/admin/customers?sent=${sent}&failed=${failed}`);
}
+109
View File
@@ -0,0 +1,109 @@
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { updateSubscribed, sendBulkEmail } from './actions';
const SOURCE_LABELS: Record<string, string> = {
CUSTOMER: 'Account',
NEWSLETTER: 'Newsletter',
GUEST: 'Guest order',
};
export default async function CustomersPage({
searchParams,
}: {
searchParams: { sent?: string; failed?: string };
}) {
const entries = await prisma.mailingListEntry.findMany({ orderBy: { createdAt: 'desc' } });
const subscribedCount = entries.filter((e) => e.subscribed).length;
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Email customers</h1>
<p className="mt-2 text-sm text-muted">
Everyone who&apos;s made an account, signed up for the newsletter, or checked out as a
guest, in one list. Untick Subscribed to leave someone out of future mailings.
</p>
{searchParams.sent !== undefined && (
<div className="mt-6 border border-splash-blue bg-splash-blue/5 p-4 text-sm">
Sent to {searchParams.sent} recipient{searchParams.sent === '1' ? '' : 's'}.
{Number(searchParams.failed) > 0 && ` ${searchParams.failed} failed to send.`}
</div>
)}
<h2 className="tag-label mt-10 mb-3 text-ink">
Mailing list ({subscribedCount} of {entries.length} subscribed)
</h2>
{entries.length === 0 ? (
<p className="text-sm text-muted">No one on the list yet.</p>
) : (
<div className="divide-y divide-line border-t border-line">
{entries.map((e) => (
<div key={e.id} className="flex items-center gap-4 py-3">
<div className="flex-1">
<p className="font-medium">{e.name || e.email}</p>
{e.name && <p className="text-xs text-muted">{e.email}</p>}
</div>
<span className="tag-label text-muted">{SOURCE_LABELS[e.source] ?? e.source}</span>
<form action={updateSubscribed} className="flex items-center gap-2">
<input type="hidden" name="id" value={e.id} />
<label className="flex items-center gap-1.5 text-xs text-muted">
<input
type="checkbox"
name="subscribed"
value="1"
defaultChecked={e.subscribed}
className="h-3.5 w-3.5 accent-clay"
/>
Subscribed
</label>
<button type="submit" className="tag-label hover:text-clay">
Save
</button>
</form>
</div>
))}
</div>
)}
<h2 className="tag-label mt-10 mb-3 text-ink">Send an email</h2>
<form action={sendBulkEmail} className="space-y-6">
<div>
<label htmlFor="subject" className="tag-label mb-2 block">
Subject
</label>
<input
id="subject"
name="subject"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="What's new at Craft2Prints"
/>
</div>
<div>
<label htmlFor="message" className="tag-label mb-2 block">
Message
</label>
<textarea
id="message"
name="message"
required
rows={8}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Write your message here…"
/>
</div>
<div>
<label htmlFor="attachment" className="tag-label mb-2 block">
Attachment (optional)
</label>
<input id="attachment" name="attachment" type="file" className="w-full text-sm" />
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Send to {subscribedCount} subscribed recipient{subscribedCount === 1 ? '' : 's'}
</button>
</form>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
'use server';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
function slugify(input: string) {
return input
.toUpperCase()
.trim()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
async function uniqueSlug(base: string) {
let slug = base || 'GALLERY';
let n = 2;
while (await prisma.galleryCategory.findUnique({ where: { slug } })) {
slug = `${base}_${n}`;
n += 1;
}
return slug;
}
async function fileToDataUrl(file: File): Promise<string> {
const bytes = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/png';
return `data:${mimeType};base64,${bytes.toString('base64')}`;
}
export async function uploadGalleryPhoto(formData: FormData) {
const caption = String(formData.get('caption') ?? '').trim();
const categoryId = String(formData.get('categoryId') ?? '').trim() || null;
const file = formData.get('image') as File | null;
if (!file || file.size === 0) {
throw new Error('A photo is required.');
}
const imageDataUrl = await fileToDataUrl(file);
await prisma.galleryPhoto.create({
data: {
imageDataUrl,
caption: caption || null,
categoryId,
},
});
redirect('/admin/gallery');
}
export async function deleteGalleryPhoto(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
await prisma.galleryPhoto.delete({ where: { id } });
redirect('/admin/gallery');
}
export async function createGalleryCategory(formData: FormData) {
const name = String(formData.get('name') ?? '').trim();
if (!name) throw new Error('Category name is required.');
const slug = await uniqueSlug(slugify(name));
await prisma.galleryCategory.create({ data: { name, slug } });
redirect('/admin/gallery/categories');
}
export async function deleteGalleryCategory(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
const inUse = await prisma.galleryPhoto.count({ where: { categoryId: id } });
if (inUse > 0) {
redirect('/admin/gallery/categories?error=in_use');
}
await prisma.galleryCategory.delete({ where: { id } });
redirect('/admin/gallery/categories');
}
@@ -0,0 +1,33 @@
import AdminNav from '@/components/AdminNav';
import { createGalleryCategory } from '../../actions';
export default function NewGalleryCategoryPage() {
return (
<div className="mx-auto max-w-md px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Add a gallery category</h1>
<p className="mt-2 text-sm text-muted">
Show up right away as a filter on the public gallery and in the photo upload form.
</p>
<form action={createGalleryCategory} className="mt-8 space-y-6">
<div>
<label htmlFor="name" className="tag-label mb-2 block">
Category name
</label>
<input
id="name"
name="name"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Weddings"
/>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Add category
</button>
</form>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { deleteGalleryCategory } from '../actions';
export default async function GalleryCategoriesPage({ searchParams }: { searchParams: { error?: string } }) {
const categories = await prisma.galleryCategory.findMany({ orderBy: { createdAt: 'asc' } });
const counts = await prisma.galleryPhoto.groupBy({ by: ['categoryId'], _count: true });
const countFor = (id: string) => counts.find((c) => c.categoryId === id)?._count ?? 0;
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between">
<h1 className="font-display text-3xl">Gallery categories</h1>
<Link
href="/admin/gallery/categories/new"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
+ Add category
</Link>
</div>
<p className="mt-2 text-sm text-muted">
Used to group photos on the public gallery separate from your product categories.
</p>
{searchParams.error === 'in_use' && (
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
Can&apos;t delete that category there are still photos using it. Move or delete those
photos first.
</div>
)}
{categories.length === 0 ? (
<p className="mt-8 text-sm text-muted">No gallery categories yet.</p>
) : (
<div className="mt-8 divide-y divide-line border-t border-line">
{categories.map((c) => (
<div key={c.id} className="flex items-center gap-4 py-4">
<div className="flex-1">
<p className="font-medium">{c.name}</p>
<p className="tag-label mt-0.5">
{countFor(c.id)} photo{countFor(c.id) === 1 ? '' : 's'}
</p>
</div>
<form action={deleteGalleryCategory}>
<input type="hidden" name="id" value={c.id} />
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
Delete
</button>
</form>
</div>
))}
</div>
)}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { uploadGalleryPhoto } from '../actions';
export default async function NewGalleryPhotoPage() {
const categories = await prisma.galleryCategory.findMany({ orderBy: { name: 'asc' } });
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Add a photo</h1>
<p className="mt-2 text-sm text-muted">
Upload a photo of completed work to show on the public gallery.
</p>
<form action={uploadGalleryPhoto} className="mt-10 space-y-6">
<div>
<label htmlFor="image" className="tag-label mb-2 block">
Photo
</label>
<input
id="image"
name="image"
type="file"
accept="image/*"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="caption" className="tag-label mb-2 block">
Caption (optional)
</label>
<input
id="caption"
name="caption"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Personalised wedding mugs"
/>
</div>
<div>
<label htmlFor="categoryId" className="tag-label mb-2 block">
Category (optional)
</label>
<select
id="categoryId"
name="categoryId"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
>
<option value=""> Uncategorized </option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<a href="/admin/gallery/categories/new" className="mt-1 inline-block text-xs text-clay hover:text-clay-dark">
+ Add a new category
</a>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Add photo
</button>
</form>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { deleteGalleryPhoto } from './actions';
export default async function AdminGalleryPage() {
const photos = await prisma.galleryPhoto.findMany({
include: { category: true },
orderBy: { createdAt: 'desc' },
});
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between">
<h1 className="font-display text-3xl">Gallery</h1>
<Link
href="/admin/gallery/new"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
+ Add photo
</Link>
</div>
<p className="mt-2 text-sm text-muted">
Photos of completed work, shown on the public <Link href="/gallery" className="text-clay hover:text-clay-dark">gallery page</Link>.
Manage the categories under{' '}
<Link href="/admin/gallery/categories" className="text-clay hover:text-clay-dark">Gallery categories</Link>.
</p>
{photos.length === 0 ? (
<p className="mt-8 text-sm text-muted">No photos yet.</p>
) : (
<div className="mt-8 divide-y divide-line border-t border-line">
{photos.map((p) => (
<div key={p.id} className="flex items-center gap-4 py-4">
<img src={p.imageDataUrl} alt="" className="h-16 w-16 border border-line object-cover" />
<div className="flex-1">
<p className="font-medium">{p.caption || <span className="text-muted">No caption</span>}</p>
<p className="tag-label mt-0.5">
{new Date(p.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })}
</p>
</div>
{p.category ? (
<span className="tag-label rounded-full bg-surface px-3 py-1">{p.category.name}</span>
) : (
<span className="tag-label text-muted">Uncategorized</span>
)}
<form action={deleteGalleryPhoto}>
<input type="hidden" name="id" value={p.id} />
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
Delete
</button>
</form>
</div>
))}
</div>
)}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import Chat from '@/components/Chat';
function formatPrice(cents: number) {
return `£${(cents / 100).toFixed(2)}`;
}
export default async function AdminThreadPage({ params }: { params: { id: string } }) {
const proof = await prisma.designProof.findUnique({
where: { id: params.id },
include: { product: true },
});
if (!proof) notFound();
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<div className="flex items-center gap-4 border border-line bg-surface p-4">
<img src={proof.imageDataUrl} alt="" className="h-16 w-16 border border-line object-cover" />
<div className="flex-1">
<p className="font-medium">{proof.customerName || proof.customerEmail}</p>
<p className="text-sm text-muted">
{proof.product.name} · qty {proof.quantity} · {formatPrice(proof.unitPrice * proof.quantity)}
</p>
</div>
<span
className={`tag-label rounded-full px-3 py-1 ${
proof.status === 'APPROVED' ? 'bg-splash-blue/10 text-splash-blue' : 'bg-splash-orange/10 text-splash-orange'
}`}
>
{proof.status}
</span>
</div>
<Link href={`/proofs/${proof.id}`} className="mt-3 inline-block tag-label hover:text-ink">
View customer page
</Link>
<div className="mt-6">
<Chat proofId={proof.id} role="ADMIN" otherPartyLabel={proof.customerName || proof.customerEmail} />
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
export default async function InboxPage() {
const proofs = await prisma.designProof.findMany({
include: {
product: true,
messages: { orderBy: { createdAt: 'desc' }, take: 1 },
},
});
// Only show threads that actually have at least one message, most recent first.
const withMessages = proofs
.filter((p) => p.messages.length > 0)
.sort((a, b) => b.messages[0].createdAt.getTime() - a.messages[0].createdAt.getTime());
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Inbox</h1>
{withMessages.length === 0 ? (
<p className="mt-8 text-sm text-muted">No conversations yet.</p>
) : (
<div className="mt-8 divide-y divide-line border-t border-line">
{withMessages.map((p) => {
const last = p.messages[0];
return (
<Link
key={p.id}
href={`/admin/inbox/${p.id}`}
className="flex items-center gap-4 py-4 hover:bg-surface"
>
<img src={p.imageDataUrl} alt="" className="h-14 w-14 border border-line object-cover" />
<div className="flex-1">
<p className="font-medium">{p.customerName || p.customerEmail}</p>
<p className="truncate text-sm text-muted">
{last.sender === 'ADMIN' ? 'You: ' : ''}
{last.body}
</p>
</div>
<span className="tag-label">{p.product.name}</span>
</Link>
);
})}
</div>
)}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
'use server';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { createAdminSession, clearAdminSession } from '@/lib/adminAuth';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
function safeNext(next: FormDataEntryValue | null) {
const path = String(next ?? '');
return path.startsWith('/') && !path.startsWith('//') ? path : '/admin/orders';
}
export async function loginAdmin(formData: FormData) {
const username = String(formData.get('username') ?? '');
const password = String(formData.get('password') ?? '');
const next = safeNext(formData.get('next'));
const ip = getClientIp(headers());
const { allowed } = await checkRateLimit(`login:admin:${ip}`, 5, 15 * 60 * 1000);
if (!allowed) {
redirect(`/admin/login?error=rate_limited&next=${encodeURIComponent(next)}`);
}
if (username !== process.env.ADMIN_USER || password !== process.env.ADMIN_PASSWORD) {
redirect(`/admin/login?error=invalid&next=${encodeURIComponent(next)}`);
}
await createAdminSession();
redirect(next);
}
export async function logoutAdmin() {
clearAdminSession();
redirect('/admin/login');
}
+54
View File
@@ -0,0 +1,54 @@
import { loginAdmin } from './actions';
const ERROR_MESSAGES: Record<string, string> = {
invalid: 'Incorrect username or password.',
rate_limited: 'Too many attempts — please wait a few minutes and try again.',
};
export default function AdminLoginPage({ searchParams }: { searchParams: { error?: string; next?: string } }) {
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
const next = searchParams.next ?? '/admin/orders';
return (
<div className="mx-auto max-w-md px-6 py-16">
<h1 className="font-display text-3xl">Admin login</h1>
{error && (
<p className="mt-6 border border-splash-pink/40 bg-splash-pink/10 px-4 py-3 text-sm text-splash-pink">
{error}
</p>
)}
<form action={loginAdmin} className="mt-8 space-y-6">
<input type="hidden" name="next" value={next} />
<div>
<label htmlFor="username" className="tag-label mb-2 block">
Username
</label>
<input
id="username"
name="username"
required
autoFocus
className="w-full border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="password" className="tag-label mb-2 block">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full border border-line px-3 py-2 text-sm"
/>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Log in
</button>
</form>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
'use server';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { SITE_SETTINGS_ID } from '@/lib/settings';
export async function setMaintenance(formData: FormData) {
const enable = String(formData.get('enable') ?? '') === '1';
const message = String(formData.get('message') ?? '').trim();
await prisma.siteSetting.upsert({
where: { id: SITE_SETTINGS_ID },
create: {
id: SITE_SETTINGS_ID,
maintenanceMode: enable,
maintenanceMessage: message || null,
},
update: {
maintenanceMode: enable,
maintenanceMessage: message || null,
},
});
redirect('/admin/maintenance');
}
+76
View File
@@ -0,0 +1,76 @@
import AdminNav from '@/components/AdminNav';
import { getSiteSettings, DEFAULT_MAINTENANCE_MESSAGE } from '@/lib/settings';
import { setMaintenance } from './actions';
export default async function MaintenancePage() {
const { maintenanceMode, maintenanceMessage } = await getSiteSettings();
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between gap-3">
<h1 className="font-display text-3xl">Maintenance mode</h1>
<span
className={`tag-label rounded-full px-3 py-1 ${
maintenanceMode ? 'bg-splash-pink/10 text-splash-pink' : 'bg-splash-blue/10 text-splash-blue'
}`}
>
{maintenanceMode ? 'ON — site is hidden' : 'OFF — site is live'}
</span>
</div>
<p className="mt-2 text-sm text-muted">
When on, customers see a &quot;we&apos;re updating&quot; page instead of the shop. You (while
logged in here) still see the real site, and can always get back to this page to turn it off.
</p>
<form action={setMaintenance} className="mt-10 space-y-6">
<div>
<label htmlFor="message" className="tag-label mb-2 block">
Message shown to customers
</label>
<textarea
id="message"
name="message"
rows={3}
defaultValue={maintenanceMessage ?? ''}
placeholder={DEFAULT_MAINTENANCE_MESSAGE}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">Leave blank to use the default message shown above.</p>
</div>
<div className="flex flex-wrap gap-3">
{maintenanceMode ? (
<>
<button
type="submit"
name="enable"
value="0"
className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark"
>
Turn maintenance OFF
</button>
<button
type="submit"
name="enable"
value="1"
className="border border-line px-6 py-3 text-sm hover:border-clay hover:text-clay"
>
Save message (keep ON)
</button>
</>
) : (
<button
type="submit"
name="enable"
value="1"
className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark"
>
Turn maintenance ON
</button>
)}
</div>
</form>
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { archiveOrder, unarchiveOrder } from '../actions';
function formatPrice(cents: number) {
return `£${(cents / 100).toFixed(2)}`;
}
export default async function OrderDetailPage({ params }: { params: { id: string } }) {
const order = await prisma.order.findUnique({
where: { id: params.id },
include: { items: true },
});
if (!order) notFound();
const shipping = order.shippingAddress ? JSON.parse(order.shippingAddress) : null;
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between gap-3">
<h1 className="font-display text-3xl">Order</h1>
<div className="flex items-center gap-3">
{order.archived && <span className="tag-label">Archived</span>}
<form action={order.archived ? unarchiveOrder : archiveOrder}>
<input type="hidden" name="id" value={order.id} />
<button type="submit" className="tag-label border border-line px-3 py-1.5 hover:border-clay hover:text-clay">
{order.archived ? 'Unarchive' : 'Archive'}
</button>
</form>
<span
className={`tag-label rounded-full px-3 py-1 ${
order.status === 'PAID'
? 'bg-splash-blue/10 text-splash-blue'
: order.status === 'FAILED'
? 'bg-splash-pink/10 text-splash-pink'
: 'bg-splash-orange/10 text-splash-orange'
}`}
>
{order.status}
</span>
</div>
</div>
<div className="mt-6 grid gap-6 border border-line bg-surface p-6 sm:grid-cols-2">
<div>
<p className="tag-label mb-1">Customer</p>
<p className="text-sm">{order.email ?? 'Not yet available'}</p>
</div>
<div>
<p className="tag-label mb-1">Placed</p>
<p className="text-sm">
{new Date(order.createdAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
</p>
</div>
{shipping && (
<div className="sm:col-span-2">
<p className="tag-label mb-1">Shipping address</p>
<p className="text-sm">
{shipping.name}
<br />
{[shipping.address?.line1, shipping.address?.line2].filter(Boolean).join(', ')}
<br />
{[shipping.address?.city, shipping.address?.postal_code].filter(Boolean).join(', ')}
<br />
{shipping.address?.country}
</p>
</div>
)}
</div>
<div className="mt-10 divide-y divide-line border-t border-line">
{order.items.map((item) => {
const design = JSON.parse(item.designJson) as { front: unknown[]; back: unknown[] };
const hasFrontDesign = design.front.length > 0;
const hasBackDesign = design.back.length > 0;
const frontPlacement = item.placementPreviewUrl || item.designPreviewUrl;
return (
<div key={item.id} className="py-8">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<p className="font-display text-xl">{item.productName}</p>
<p className="text-sm text-muted">
{item.color}
{item.size ? `, ${item.size}` : ''} · qty {item.quantity} ·{' '}
{formatPrice(item.unitPrice * item.quantity)}
</p>
</div>
<div className="mt-4 grid gap-6 sm:grid-cols-2">
<div>
<img
src={frontPlacement || undefined}
alt={`${item.productName} — where the design sits`}
className="w-full max-w-md border border-line bg-paper object-contain"
/>
<div className="mt-2 flex items-center justify-between">
<p className="tag-label">{item.placementPreviewUrl ? 'On product — front' : 'Front'}</p>
{hasFrontDesign && (
<a
href={item.designPreviewUrl}
download={`order-${order.id}-${item.id}-front.png`}
className="text-xs text-clay hover:text-clay-dark"
>
Download print file
</a>
)}
</div>
</div>
{item.placementPreviewUrlBack && (
<div>
<img
src={item.placementPreviewUrlBack}
alt={`${item.productName} back — where the design sits`}
className="w-full max-w-md border border-line bg-paper object-contain"
/>
<div className="mt-2 flex items-center justify-between">
<p className="tag-label">On product back</p>
{hasBackDesign && item.designPreviewUrlBack && (
<a
href={item.designPreviewUrlBack}
download={`order-${order.id}-${item.id}-back.png`}
className="text-xs text-clay hover:text-clay-dark"
>
Download print file
</a>
)}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
<div className="mt-6 flex justify-end">
<div className="flex items-baseline gap-3">
<span className="tag-label">Total</span>
<span className="font-mono text-xl">{formatPrice(order.total)}</span>
</div>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
'use server';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
const AUTO_ARCHIVE_DAYS = 30;
// Runs on every visit to the orders list — sweeps anything past the cutoff into
// the archive. No cron/scheduled task needed for a tool at this scale; this is
// "close enough" to real auto-archiving without extra infrastructure.
export async function autoArchiveOldOrders() {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - AUTO_ARCHIVE_DAYS);
await prisma.order.updateMany({
where: {
archived: false,
status: { in: ['PAID', 'FAILED'] }, // never auto-archive something still PENDING
createdAt: { lt: cutoff },
},
data: { archived: true },
});
}
export async function archiveOrder(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
await prisma.order.update({ where: { id }, data: { archived: true } });
redirect('/admin/orders');
}
export async function unarchiveOrder(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
await prisma.order.update({ where: { id }, data: { archived: false } });
redirect('/admin/orders?archived=1');
}
+80
View File
@@ -0,0 +1,80 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { autoArchiveOldOrders, archiveOrder, unarchiveOrder } from './actions';
function formatPrice(cents: number) {
return `£${(cents / 100).toFixed(2)}`;
}
const STATUS_STYLES: Record<string, string> = {
PAID: 'bg-splash-blue/10 text-splash-blue',
PENDING: 'bg-splash-orange/10 text-splash-orange',
FAILED: 'bg-splash-pink/10 text-splash-pink',
};
export default async function OrdersPage({ searchParams }: { searchParams: { archived?: string } }) {
await autoArchiveOldOrders();
const showArchived = searchParams.archived === '1';
const orders = await prisma.order.findMany({
where: {
status: { in: ['PAID', 'PENDING', 'FAILED'] },
archived: showArchived,
},
include: { items: true },
orderBy: { createdAt: 'desc' },
});
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between">
<h1 className="font-display text-3xl">{showArchived ? 'Archived orders' : 'Orders'}</h1>
<Link href={showArchived ? '/admin/orders' : '/admin/orders?archived=1'} className="tag-label hover:text-ink">
{showArchived ? '← Back to active orders' : 'View archived →'}
</Link>
</div>
{!showArchived && (
<p className="mt-2 text-xs text-muted">
Paid or failed orders older than 30 days archive automatically. Use the button on any order
to archive it sooner, or wait for it to happen on its own.
</p>
)}
{orders.length === 0 ? (
<p className="mt-10 text-sm text-muted">{showArchived ? 'Nothing archived yet.' : 'No orders yet.'}</p>
) : (
<div className="mt-8 divide-y divide-line border-t border-line">
{orders.map((order) => (
<div key={order.id} className="flex items-center gap-4 py-4">
<Link href={`/admin/orders/${order.id}`} className="flex flex-1 items-center gap-4 hover:opacity-80">
<div className="flex-1">
<p className="font-medium">{order.email ?? 'No email yet'}</p>
<p className="text-sm text-muted">
{order.items.length} item{order.items.length === 1 ? '' : 's'} ·{' '}
{new Date(order.createdAt).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
})}
</p>
</div>
<span className="font-mono text-sm">{formatPrice(order.total)}</span>
<span className={`tag-label rounded-full px-3 py-1 ${STATUS_STYLES[order.status] ?? ''}`}>
{order.status}
</span>
</Link>
<form action={showArchived ? unarchiveOrder : archiveOrder}>
<input type="hidden" name="id" value={order.id} />
<button type="submit" className="tag-label text-muted hover:text-clay">
{showArchived ? 'Unarchive' : 'Archive'}
</button>
</form>
</div>
))}
</div>
)}
</div>
);
}
+322
View File
@@ -0,0 +1,322 @@
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import { updateProduct } from '../../actions';
import AdminNav from '@/components/AdminNav';
import ColorPicker from '@/components/ColorPicker';
import SizePicker from '@/components/SizePicker';
import PhotoPrintAreaField from '@/components/PhotoPrintAreaField';
import { MOCKUP_OPTIONS } from '@/lib/mockupDefaults';
export default async function EditProductPage({ params }: { params: { id: string } }) {
const activePromotions = await fetchActivePromotions();
const row = await prisma.product.findUnique({ where: { id: params.id }, include: { collections: true } });
if (!row) notFound();
const product = toProductDTO(row, activePromotions);
const categories = await prisma.category.findMany({ orderBy: { name: 'asc' } });
const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
const occasions = collections.filter((c) => c.group === 'OCCASION');
const recipients = collections.filter((c) => c.group === 'RECIPIENT');
const selectedCollectionIds = new Set(row.collections.map((c) => c.id));
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Edit {product.name}</h1>
<p className="mt-2 text-sm text-muted">
Changes apply immediately to the live product at /made-to-order/{product.slug}.
</p>
<form action={updateProduct} className="mt-10 space-y-6" encType="multipart/form-data">
<input type="hidden" name="id" value={product.id} />
<div>
<label className="tag-label mb-2 block">Show on</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="showOnPersonalised"
value="1"
defaultChecked={product.showOnPersonalised}
className="h-4 w-4 accent-clay"
/>
Personalised catalog (/made-to-order) customers design their own
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="showOnProducts"
value="1"
defaultChecked={product.showOnProducts}
className="h-4 w-4 accent-clay"
/>
Products catalog (/products) same photos, browse and buy as-is
</label>
</div>
<p className="mt-1 text-xs text-muted">Check both to list the same product on both pages.</p>
</div>
<div>
<label htmlFor="name" className="tag-label mb-2 block">
Product name
</label>
<input
id="name"
name="name"
required
defaultValue={product.name}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="category" className="tag-label mb-2 block">
Category
</label>
<select
id="category"
name="category"
required
defaultValue={product.category}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
>
{categories.map((c) => (
<option key={c.id} value={c.slug}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label htmlFor="mockup" className="tag-label mb-2 block">
Shape (used if no photo)
</label>
<select
id="mockup"
name="mockup"
required
defaultValue={product.mockup}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
>
{MOCKUP_OPTIONS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
</div>
{(occasions.length > 0 || recipients.length > 0) && (
<div>
<label className="tag-label mb-2 block">Collections (optional)</label>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="mb-2 text-xs text-muted">Occasions</p>
<div className="space-y-1.5">
{occasions.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collections"
value={c.id}
defaultChecked={selectedCollectionIds.has(c.id)}
className="h-4 w-4 accent-clay"
/>
{c.name}
</label>
))}
{occasions.length === 0 && <p className="text-xs text-muted">None yet.</p>}
</div>
</div>
<div>
<p className="mb-2 text-xs text-muted">Gifts for</p>
<div className="space-y-1.5">
{recipients.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collections"
value={c.id}
defaultChecked={selectedCollectionIds.has(c.id)}
className="h-4 w-4 accent-clay"
/>
{c.name}
</label>
))}
{recipients.length === 0 && <p className="text-xs text-muted">None yet.</p>}
</div>
</div>
</div>
<a href="/admin/collections/new" className="mt-1 inline-block text-xs text-clay hover:text-clay-dark">
+ Add a new collection
</a>
</div>
)}
<div>
<label htmlFor="price" className="tag-label mb-2 block">
Price (£)
</label>
<input
id="price"
name="price"
type="number"
step="0.01"
min={0}
required
defaultValue={(product.basePrice / 100).toFixed(2)}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="salePrice" className="tag-label mb-2 block">
Sale price (£, optional)
</label>
<input
id="salePrice"
name="salePrice"
type="number"
step="0.01"
min={0}
defaultValue={row.salePrice != null ? (row.salePrice / 100).toFixed(2) : ''}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Leave blank for no sale"
/>
<p className="mt-1 text-xs text-muted">
Set a start/end date below too outside that window the normal price shows instead.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="saleStartsAt" className="tag-label mb-2 block">
Sale starts (optional)
</label>
<input
id="saleStartsAt"
name="saleStartsAt"
type="date"
defaultValue={row.saleStartsAt ? row.saleStartsAt.toISOString().slice(0, 10) : ''}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="saleEndsAt" className="tag-label mb-2 block">
Sale ends (optional)
</label>
<input
id="saleEndsAt"
name="saleEndsAt"
type="date"
defaultValue={row.saleEndsAt ? row.saleEndsAt.toISOString().slice(0, 10) : ''}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
</div>
<div>
<label htmlFor="personalisedPrice" className="tag-label mb-2 block">
Personalised price (£, optional)
</label>
<input
id="personalisedPrice"
name="personalisedPrice"
type="number"
step="0.01"
min={0}
defaultValue={row.personalisedPrice != null ? (row.personalisedPrice / 100).toFixed(2) : ''}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Leave blank to use the regular price"
/>
<p className="mt-1 text-xs text-muted">
Higher price for made-to-order items. Leave blank to use the same price as ready-made.
</p>
</div>
<div>
<label className="tag-label mb-2 block">Front photo</label>
{product.imageUrl && (
<div className="mb-3 flex items-center gap-3">
<img src={product.imageUrl} alt={product.name} className="h-20 w-20 border border-line object-contain" />
<p className="text-xs text-muted">Current photo. Upload a new one below to replace it.</p>
</div>
)}
<PhotoPrintAreaField />
</div>
<div>
<label className="tag-label mb-2 block">Back photo</label>
{product.imageUrlBack && (
<div className="mb-3 flex items-center gap-3">
<img
src={product.imageUrlBack}
alt={`${product.name} back`}
className="h-20 w-20 border border-line object-contain"
/>
<p className="text-xs text-muted">Current back photo and print area. Upload a new one below to replace both.</p>
</div>
)}
<PhotoPrintAreaField variant="back" />
</div>
<div>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="photoRecolorable"
value="1"
defaultChecked={product.photoRecolorable}
className="mt-1 h-4 w-4 accent-clay"
/>
<span>
Tint this photo per color (fallback)
<span className="mt-1 block text-xs text-muted">
Only works well for black, white, or gray photos the color is applied as a live
tint. If you have real photos for specific colors, add them below instead this
only kicks in for colors that don&apos;t have their own photo.
</span>
</span>
</label>
</div>
<div>
<label className="tag-label mb-2 block">Colors</label>
<ColorPicker
name="colors"
defaultColors={product.colors}
defaultColorPhotos={product.colorPhotos}
defaultColorPhotosBack={product.colorPhotosBack}
/>
</div>
<div>
<label className="tag-label mb-2 block">Sizes (optional)</label>
<SizePicker name="sizes" defaultSizes={product.sizes} />
</div>
<div>
<label htmlFor="description" className="tag-label mb-2 block">
Description
</label>
<textarea
id="description"
name="description"
rows={3}
defaultValue={product.description}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Save changes
</button>
</form>
</div>
);
}
+305
View File
@@ -0,0 +1,305 @@
'use server';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { DEFAULT_PRINT_AREAS } from '@/lib/mockupDefaults';
function slugify(input: string) {
return input
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
async function uniqueSlug(base: string) {
let slug = base || 'product';
let n = 2;
while (await prisma.product.findUnique({ where: { slug } })) {
slug = `${base}-${n}`;
n += 1;
}
return slug;
}
async function fileToDataUrl(file: File): Promise<string> {
const bytes = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/png';
return `data:${mimeType};base64,${bytes.toString('base64')}`;
}
// Reads the per-color photo file inputs the ColorPicker renders (named
// "colorPhoto__<hexWithoutHash>" for front, "colorPhotoBack__<hexWithoutHash>" for
// back) and builds { hex: dataUrl } for whichever colors actually got a file attached.
async function extractColorPhotos(
formData: FormData,
colors: string[],
variant: 'front' | 'back' = 'front',
): Promise<Record<string, string>> {
const prefix = variant === 'front' ? 'colorPhoto__' : 'colorPhotoBack__';
const result: Record<string, string> = {};
for (const hex of colors) {
const file = formData.get(`${prefix}${hex.replace('#', '')}`) as File | null;
if (file && file.size > 0) {
result[hex] = await fileToDataUrl(file);
}
}
return result;
}
// Shared by createProduct/updateProduct — empty inputs mean "no sale". The end
// date is pushed to the end of that day so the sale stays active through it.
function parseSaleFields(formData: FormData) {
const salePriceInput = String(formData.get('salePrice') ?? '').trim();
const startInput = String(formData.get('saleStartsAt') ?? '').trim();
const endInput = String(formData.get('saleEndsAt') ?? '').trim();
return {
salePrice: salePriceInput ? Math.round(Number(salePriceInput) * 100) : null,
saleStartsAt: startInput ? new Date(startInput) : null,
saleEndsAt: endInput ? new Date(`${endInput}T23:59:59`) : null,
};
}
export async function createProduct(formData: FormData) {
const name = String(formData.get('name') ?? '').trim();
const slugInput = String(formData.get('slug') ?? '').trim();
const category = String(formData.get('category') ?? '');
const description = String(formData.get('description') ?? '').trim();
const priceDollars = Number(formData.get('price') ?? 0);
const personalisedPriceDollars = String(formData.get('personalisedPrice') ?? '').trim();
const mockup = String(formData.get('mockup') ?? 'tshirt');
const colorsInput = String(formData.get('colors') ?? '');
const sizesInput = String(formData.get('sizes') ?? '');
const imageFile = formData.get('image') as File | null;
const imageBackFile = formData.get('imageBack') as File | null;
const photoRecolorable = String(formData.get('photoRecolorable') ?? '') === '1';
const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1';
const showOnProducts = String(formData.get('showOnProducts') ?? '') === '1';
const collectionIds = formData.getAll('collections').map((v) => String(v));
if (!name || !category || !priceDollars) {
throw new Error('Name, category, and price are required.');
}
const colors = colorsInput
.split(',')
.map((c) => c.trim())
.filter(Boolean);
if (colors.length === 0) {
throw new Error('At least one color is required.');
}
const sizes = sizesInput
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const imageUrl = imageFile && imageFile.size > 0 ? await fileToDataUrl(imageFile) : null;
const imageUrlBack = imageBackFile && imageBackFile.size > 0 ? await fileToDataUrl(imageBackFile) : null;
const colorPhotos = await extractColorPhotos(formData, colors, 'front');
const colorPhotosBack = await extractColorPhotos(formData, colors, 'back');
const saleFields = parseSaleFields(formData);
const slug = await uniqueSlug(slugify(slugInput || name));
const hasCustomPrintArea = String(formData.get('hasCustomPrintArea') ?? '') === '1' && imageUrl;
const printArea = hasCustomPrintArea
? {
xPct: Number(formData.get('printX') ?? 0.3),
yPct: Number(formData.get('printY') ?? 0.25),
wPct: Number(formData.get('printW') ?? 0.4),
hPct: Number(formData.get('printH') ?? 0.35),
}
: DEFAULT_PRINT_AREAS[mockup] ?? DEFAULT_PRINT_AREAS.tshirt;
const hasCustomPrintAreaBack = String(formData.get('hasCustomPrintAreaBack') ?? '') === '1' && imageUrlBack;
const printAreaBack = hasCustomPrintAreaBack
? {
xPct: Number(formData.get('printXBack') ?? 0.3),
yPct: Number(formData.get('printYBack') ?? 0.25),
wPct: Number(formData.get('printWBack') ?? 0.4),
hPct: Number(formData.get('printHBack') ?? 0.35),
}
: null;
await prisma.product.create({
data: {
slug,
name,
category,
description: description || `${name} — personalize it your way.`,
basePrice: Math.round(priceDollars * 100),
personalisedPrice: personalisedPriceDollars ? Math.round(Number(personalisedPriceDollars) * 100) : null,
...saleFields,
colors: JSON.stringify(colors),
colorPhotos: Object.keys(colorPhotos).length > 0 ? JSON.stringify(colorPhotos) : null,
colorPhotosBack: Object.keys(colorPhotosBack).length > 0 ? JSON.stringify(colorPhotosBack) : null,
sizes: sizes.length > 0 ? JSON.stringify(sizes) : null,
mockup,
printArea: JSON.stringify(printArea),
printAreaBack: printAreaBack ? JSON.stringify(printAreaBack) : null,
imageUrl,
imageUrlBack,
photoRecolorable,
showOnPersonalised,
showOnProducts,
collections: { connect: collectionIds.map((id) => ({ id })) },
},
});
redirect('/admin/products');
}
export async function updateProduct(formData: FormData) {
const id = String(formData.get('id') ?? '');
const name = String(formData.get('name') ?? '').trim();
const category = String(formData.get('category') ?? '');
const description = String(formData.get('description') ?? '').trim();
const priceDollars = Number(formData.get('price') ?? 0);
const personalisedPriceDollars = String(formData.get('personalisedPrice') ?? '').trim();
const mockup = String(formData.get('mockup') ?? 'tshirt');
const colorsInput = String(formData.get('colors') ?? '');
const sizesInput = String(formData.get('sizes') ?? '');
const imageFile = formData.get('image') as File | null;
const imageBackFile = formData.get('imageBack') as File | null;
const photoRecolorable = String(formData.get('photoRecolorable') ?? '') === '1';
const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1';
const showOnProducts = String(formData.get('showOnProducts') ?? '') === '1';
const collectionIds = formData.getAll('collections').map((v) => String(v));
if (!id) throw new Error('Missing product id.');
if (!name || !category || !priceDollars) {
throw new Error('Name, category, and price are required.');
}
const colors = colorsInput
.split(',')
.map((c) => c.trim())
.filter(Boolean);
if (colors.length === 0) {
throw new Error('At least one color is required.');
}
const sizes = sizesInput
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const existing = await prisma.product.findUnique({ where: { id } });
if (!existing) throw new Error('Product not found.');
const existingColorPhotos: Record<string, string> = existing.colorPhotos
? JSON.parse(existing.colorPhotos)
: {};
const existingColorPhotosBack: Record<string, string> = existing.colorPhotosBack
? JSON.parse(existing.colorPhotosBack)
: {};
const newColorPhotos = await extractColorPhotos(formData, colors, 'front');
const newColorPhotosBack = await extractColorPhotos(formData, colors, 'back');
// Merge: newly uploaded photos override, everything else keeps whatever was
// already saved (and only for colors still in the list).
const mergedColorPhotos: Record<string, string> = {};
const mergedColorPhotosBack: Record<string, string> = {};
for (const hex of colors) {
if (newColorPhotos[hex]) mergedColorPhotos[hex] = newColorPhotos[hex];
else if (existingColorPhotos[hex]) mergedColorPhotos[hex] = existingColorPhotos[hex];
if (newColorPhotosBack[hex]) mergedColorPhotosBack[hex] = newColorPhotosBack[hex];
else if (existingColorPhotosBack[hex]) mergedColorPhotosBack[hex] = existingColorPhotosBack[hex];
}
const data: {
name: string;
category: string;
description: string;
basePrice: number;
personalisedPrice: number | null;
salePrice: number | null;
saleStartsAt: Date | null;
saleEndsAt: Date | null;
colors: string;
colorPhotos: string | null;
colorPhotosBack: string | null;
sizes: string | null;
mockup: string;
photoRecolorable: boolean;
showOnPersonalised: boolean;
showOnProducts: boolean;
imageUrl?: string;
imageUrlBack?: string;
printArea?: string;
printAreaBack?: string;
} = {
name,
category,
description: description || `${name} — personalize it your way.`,
basePrice: Math.round(priceDollars * 100),
personalisedPrice: personalisedPriceDollars ? Math.round(Number(personalisedPriceDollars) * 100) : null,
...parseSaleFields(formData),
colors: JSON.stringify(colors),
colorPhotos: Object.keys(mergedColorPhotos).length > 0 ? JSON.stringify(mergedColorPhotos) : null,
colorPhotosBack: Object.keys(mergedColorPhotosBack).length > 0 ? JSON.stringify(mergedColorPhotosBack) : null,
sizes: sizes.length > 0 ? JSON.stringify(sizes) : null,
mockup,
photoRecolorable,
showOnPersonalised,
showOnProducts,
};
// Only touch the front photo/print area if a new photo was actually uploaded —
// otherwise leave whatever's already there alone.
if (imageFile && imageFile.size > 0) {
data.imageUrl = await fileToDataUrl(imageFile);
const hasCustomPrintArea = String(formData.get('hasCustomPrintArea') ?? '') === '1';
data.printArea = JSON.stringify(
hasCustomPrintArea
? {
xPct: Number(formData.get('printX') ?? 0.3),
yPct: Number(formData.get('printY') ?? 0.25),
wPct: Number(formData.get('printW') ?? 0.4),
hPct: Number(formData.get('printH') ?? 0.35),
}
: DEFAULT_PRINT_AREAS[mockup] ?? DEFAULT_PRINT_AREAS.tshirt,
);
}
if (imageBackFile && imageBackFile.size > 0) {
data.imageUrlBack = await fileToDataUrl(imageBackFile);
const hasCustomPrintAreaBack = String(formData.get('hasCustomPrintAreaBack') ?? '') === '1';
if (hasCustomPrintAreaBack) {
data.printAreaBack = JSON.stringify({
xPct: Number(formData.get('printXBack') ?? 0.3),
yPct: Number(formData.get('printYBack') ?? 0.25),
wPct: Number(formData.get('printWBack') ?? 0.4),
hPct: Number(formData.get('printHBack') ?? 0.35),
});
}
}
await prisma.product.update({
where: { id },
data: { ...data, collections: { set: collectionIds.map((cid) => ({ id: cid })) } },
});
redirect('/admin/products');
}
export async function deleteProduct(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
try {
await prisma.product.delete({ where: { id } });
} catch {
// Product has existing orders/design proofs tied to it — deleting would
// orphan that history, so we block it instead of cascading.
redirect('/admin/products?error=in_use');
}
redirect('/admin/products');
}
+270
View File
@@ -0,0 +1,270 @@
import { createProduct } from '../actions';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import ColorPicker from '@/components/ColorPicker';
import SizePicker from '@/components/SizePicker';
import PhotoPrintAreaField from '@/components/PhotoPrintAreaField';
import { MOCKUP_OPTIONS } from '@/lib/mockupDefaults';
export default async function NewProductPage() {
const categories = await prisma.category.findMany({ orderBy: { name: 'asc' } });
const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
const occasions = collections.filter((c) => c.group === 'OCCASION');
const recipients = collections.filter((c) => c.group === 'RECIPIENT');
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Add a product</h1>
<p className="mt-2 text-sm text-muted">
Choose which catalog(s) this shows up in below the personalized one where customers
design their own version, the ready-made one where they just buy it as pictured, or both.
</p>
<form action={createProduct} className="mt-10 space-y-6" encType="multipart/form-data">
<div>
<label className="tag-label mb-2 block">Show on</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" name="showOnPersonalised" value="1" defaultChecked className="h-4 w-4 accent-clay" />
Personalised catalog (/made-to-order) customers design their own
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" name="showOnProducts" value="1" className="h-4 w-4 accent-clay" />
Products catalog (/products) same photos, browse and buy as-is
</label>
</div>
<p className="mt-1 text-xs text-muted">Check both to list the same product on both pages.</p>
</div>
<div>
<label htmlFor="name" className="tag-label mb-2 block">
Product name
</label>
<input
id="name"
name="name"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Classic Tee"
/>
</div>
<div>
<label htmlFor="slug" className="tag-label mb-2 block">
URL slug (optional)
</label>
<input
id="slug"
name="slug"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Leave blank to generate from the name"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="category" className="tag-label mb-2 block">
Category
</label>
<select
id="category"
name="category"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
>
{categories.map((c) => (
<option key={c.id} value={c.slug}>
{c.name}
</option>
))}
</select>
<a href="/admin/categories/new" className="mt-1 inline-block text-xs text-clay hover:text-clay-dark">
+ Add a new category
</a>
</div>
<div>
<label htmlFor="mockup" className="tag-label mb-2 block">
Shape (used if no photo)
</label>
<select
id="mockup"
name="mockup"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
>
{MOCKUP_OPTIONS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
</div>
{(occasions.length > 0 || recipients.length > 0) && (
<div>
<label className="tag-label mb-2 block">Collections (optional)</label>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="mb-2 text-xs text-muted">Occasions</p>
<div className="space-y-1.5">
{occasions.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-sm">
<input type="checkbox" name="collections" value={c.id} className="h-4 w-4 accent-clay" />
{c.name}
</label>
))}
{occasions.length === 0 && <p className="text-xs text-muted">None yet.</p>}
</div>
</div>
<div>
<p className="mb-2 text-xs text-muted">Gifts for</p>
<div className="space-y-1.5">
{recipients.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-sm">
<input type="checkbox" name="collections" value={c.id} className="h-4 w-4 accent-clay" />
{c.name}
</label>
))}
{recipients.length === 0 && <p className="text-xs text-muted">None yet.</p>}
</div>
</div>
</div>
<a href="/admin/collections/new" className="mt-1 inline-block text-xs text-clay hover:text-clay-dark">
+ Add a new collection
</a>
</div>
)}
<div>
<label htmlFor="price" className="tag-label mb-2 block">
Price (£)
</label>
<input
id="price"
name="price"
type="number"
step="0.01"
min={0}
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="29.00"
/>
</div>
<div>
<label htmlFor="salePrice" className="tag-label mb-2 block">
Sale price (£, optional)
</label>
<input
id="salePrice"
name="salePrice"
type="number"
step="0.01"
min={0}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Leave blank for no sale"
/>
<p className="mt-1 text-xs text-muted">
Set a start/end date below too outside that window the normal price shows instead.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="saleStartsAt" className="tag-label mb-2 block">
Sale starts (optional)
</label>
<input
id="saleStartsAt"
name="saleStartsAt"
type="date"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="saleEndsAt" className="tag-label mb-2 block">
Sale ends (optional)
</label>
<input
id="saleEndsAt"
name="saleEndsAt"
type="date"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
</div>
<div>
<label htmlFor="personalisedPrice" className="tag-label mb-2 block">
Personalised price (£, optional)
</label>
<input
id="personalisedPrice"
name="personalisedPrice"
type="number"
step="0.01"
min={0}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Leave blank to use the regular price"
/>
<p className="mt-1 text-xs text-muted">
Higher price for made-to-order items. Leave blank to use the same price as ready-made.
</p>
</div>
<div>
<label className="tag-label mb-2 block">Front photo (optional)</label>
<PhotoPrintAreaField />
</div>
<div>
<label className="tag-label mb-2 block">Back photo (optional)</label>
<PhotoPrintAreaField variant="back" />
</div>
<div>
<label className="flex items-start gap-2 text-sm">
<input type="checkbox" name="photoRecolorable" value="1" className="mt-1 h-4 w-4 accent-clay" />
<span>
Tint this photo per color (fallback)
<span className="mt-1 block text-xs text-muted">
Only works well for black, white, or gray photos the color is applied as a live
tint. If you have real photos for specific colors, add them below instead this
only kicks in for colors that don&apos;t have their own photo.
</span>
</span>
</label>
</div>
<div>
<label className="tag-label mb-2 block">Colors</label>
<ColorPicker name="colors" />
</div>
<div>
<label className="tag-label mb-2 block">Sizes (optional)</label>
<SizePicker name="sizes" />
</div>
<div>
<label htmlFor="description" className="tag-label mb-2 block">
Description
</label>
<textarea
id="description"
name="description"
rows={3}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Heavyweight combed cotton, printed dead-center on the chest."
/>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Add product
</button>
</form>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import AdminNav from '@/components/AdminNav';
import ProductMockup from '@/components/ProductMockup';
import { deleteProduct } from './actions';
export default async function AdminProductsPage({
searchParams,
}: {
searchParams: { error?: string };
}) {
const activePromotions = await fetchActivePromotions();
const rows = await prisma.product.findMany({ orderBy: { createdAt: 'desc' } });
const products = rows.map((p) => toProductDTO(p, activePromotions));
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between">
<h1 className="font-display text-3xl">Products</h1>
<Link
href="/admin/products/new"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
+ Add product
</Link>
</div>
{searchParams.error === 'in_use' && (
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
Can&apos;t delete that product it has existing orders or design proofs tied to it.
</div>
)}
{products.length === 0 ? (
<p className="mt-10 text-sm text-muted">No products yet.</p>
) : (
<div className="mt-8 divide-y divide-line border-t border-line">
{products.map((p) => (
<div key={p.id} className="flex items-center gap-4 py-4">
<div className="h-16 w-16 border border-line bg-surface p-1">
{(p.colorPhotos[p.colors[0]] ?? p.imageUrl) ? (
<img
src={p.colorPhotos[p.colors[0]] ?? p.imageUrl!}
alt={p.name}
className="h-full w-full object-contain"
/>
) : (
<ProductMockup mockup={p.mockup} color={p.colors[0]} />
)}
</div>
<div className="flex-1">
<p className="font-medium">{p.name}</p>
<p className="text-sm text-muted">
{p.category.replace('_', ' ')} · £${(p.basePrice / 100).toFixed(2)} · {p.colors.length}{' '}
color{p.colors.length === 1 ? '' : 's'}
{p.sizes.length > 0 && ` · ${p.sizes.join('/')}`}
</p>
</div>
<Link href={`/admin/products/${p.id}/edit`} className="tag-label hover:text-ink">
Edit
</Link>
<Link href={`/made-to-order/${p.slug}`} className="tag-label hover:text-ink">
View
</Link>
<form action={deleteProduct}>
<input type="hidden" name="id" value={p.id} />
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
Delete
</button>
</form>
</div>
))}
</div>
)}
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import PromotionScopePicker from '@/components/PromotionScopePicker';
import { updatePromotion } from '../../actions';
export default async function EditPromotionPage({ params }: { params: { id: string } }) {
const [promotion, products] = await Promise.all([
prisma.promotion.findUnique({ where: { id: params.id }, include: { products: { select: { id: true } } } }),
prisma.product.findMany({ orderBy: { name: 'asc' } }),
]);
if (!promotion) notFound();
const selectedIds = new Set(promotion.products.map((p) => p.id));
const toDateInput = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : '');
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Edit promotion</h1>
<p className="mt-2 text-sm text-muted">
Shown as a banner at the top of every page while its date window is active, and actually
discounts the price of whatever it covers every item, or specific ones you pick.
</p>
<form action={updatePromotion} className="mt-10 space-y-6">
<input type="hidden" name="id" value={promotion.id} />
<div>
<label htmlFor="message" className="tag-label mb-2 block">
Banner message
</label>
<textarea
id="message"
name="message"
required
rows={2}
defaultValue={promotion.message}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="discountPercent" className="tag-label mb-2 block">
Discount (%)
</label>
<input
id="discountPercent"
name="discountPercent"
type="number"
min={1}
max={100}
required
defaultValue={promotion.discountPercent}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<PromotionScopePicker
products={products}
defaultScope={promotion.scope as 'ALL' | 'SPECIFIC'}
defaultSelectedIds={[...selectedIds]}
/>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="startsAt" className="tag-label mb-2 block">
Starts (optional)
</label>
<input
id="startsAt"
name="startsAt"
type="date"
defaultValue={toDateInput(promotion.startsAt)}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">Leave blank to start immediately.</p>
</div>
<div>
<label htmlFor="endsAt" className="tag-label mb-2 block">
Ends (optional)
</label>
<input
id="endsAt"
name="endsAt"
type="date"
defaultValue={toDateInput(promotion.endsAt)}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">Leave blank for no end date.</p>
</div>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Save changes
</button>
</form>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
'use server';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
export async function createPromotion(formData: FormData) {
const message = String(formData.get('message') ?? '').trim();
const discountPercent = Math.round(Number(formData.get('discountPercent') ?? 0));
const scope = String(formData.get('scope') ?? 'ALL') === 'SPECIFIC' ? 'SPECIFIC' : 'ALL';
const productIds = formData.getAll('productIds').map(String).filter(Boolean);
const startInput = String(formData.get('startsAt') ?? '').trim();
const endInput = String(formData.get('endsAt') ?? '').trim();
if (!message) throw new Error('A banner message is required.');
if (!discountPercent || discountPercent < 1 || discountPercent > 100) {
throw new Error('Discount must be between 1 and 100 percent.');
}
if (scope === 'SPECIFIC' && productIds.length === 0) {
throw new Error('Pick at least one product, or choose "All items" instead.');
}
await prisma.promotion.create({
data: {
message,
discountPercent,
scope,
startsAt: startInput ? new Date(startInput) : null,
endsAt: endInput ? new Date(`${endInput}T23:59:59`) : null,
products: scope === 'SPECIFIC' ? { connect: productIds.map((id) => ({ id })) } : undefined,
},
});
redirect('/admin/promotions');
}
export async function updatePromotion(formData: FormData) {
const id = String(formData.get('id') ?? '');
const message = String(formData.get('message') ?? '').trim();
const discountPercent = Math.round(Number(formData.get('discountPercent') ?? 0));
const scope = String(formData.get('scope') ?? 'ALL') === 'SPECIFIC' ? 'SPECIFIC' : 'ALL';
const productIds = formData.getAll('productIds').map(String).filter(Boolean);
const startInput = String(formData.get('startsAt') ?? '').trim();
const endInput = String(formData.get('endsAt') ?? '').trim();
if (!id) throw new Error('Missing promotion id.');
if (!message) throw new Error('A banner message is required.');
if (!discountPercent || discountPercent < 1 || discountPercent > 100) {
throw new Error('Discount must be between 1 and 100 percent.');
}
if (scope === 'SPECIFIC' && productIds.length === 0) {
throw new Error('Pick at least one product, or choose "All items" instead.');
}
await prisma.promotion.update({
where: { id },
data: {
message,
discountPercent,
scope,
startsAt: startInput ? new Date(startInput) : null,
endsAt: endInput ? new Date(`${endInput}T23:59:59`) : null,
// `set` replaces the full connection list in one go — correctly handles
// switching scope (SPECIFIC -> ALL clears it) and adding/removing products.
products: { set: scope === 'SPECIFIC' ? productIds.map((pid) => ({ id: pid })) : [] },
},
});
redirect('/admin/promotions');
}
export async function deletePromotion(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
await prisma.promotion.delete({ where: { id } });
redirect('/admin/promotions');
}
+84
View File
@@ -0,0 +1,84 @@
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import PromotionScopePicker from '@/components/PromotionScopePicker';
import { createPromotion } from '../actions';
export default async function NewPromotionPage() {
const products = await prisma.product.findMany({ orderBy: { name: 'asc' } });
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Add a promotion</h1>
<p className="mt-2 text-sm text-muted">
Shown as a banner at the top of every page while its date window is active, and actually
discounts the price of whatever it covers every item, or specific ones you pick.
</p>
<form action={createPromotion} className="mt-10 space-y-6">
<div>
<label htmlFor="message" className="tag-label mb-2 block">
Banner message
</label>
<textarea
id="message"
name="message"
required
rows={2}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="20% off everything this weekend!"
/>
</div>
<div>
<label htmlFor="discountPercent" className="tag-label mb-2 block">
Discount (%)
</label>
<input
id="discountPercent"
name="discountPercent"
type="number"
min={1}
max={100}
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="20"
/>
</div>
<PromotionScopePicker products={products} />
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="startsAt" className="tag-label mb-2 block">
Starts (optional)
</label>
<input
id="startsAt"
name="startsAt"
type="date"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">Leave blank to start immediately.</p>
</div>
<div>
<label htmlFor="endsAt" className="tag-label mb-2 block">
Ends (optional)
</label>
<input
id="endsAt"
name="endsAt"
type="date"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">Leave blank for no end date.</p>
</div>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Add promotion
</button>
</form>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { deletePromotion } from './actions';
function statusFor(promotion: { startsAt: Date | null; endsAt: Date | null }, now: Date) {
if (promotion.startsAt && promotion.startsAt > now) return { label: 'Scheduled', className: 'bg-splash-orange/10 text-splash-orange' };
if (promotion.endsAt && promotion.endsAt < now) return { label: 'Ended', className: 'bg-muted/10 text-muted' };
return { label: 'Active now', className: 'bg-splash-blue/10 text-splash-blue' };
}
function formatDate(d: Date | null) {
if (!d) return null;
return d.toLocaleDateString(undefined, { dateStyle: 'medium' });
}
export default async function PromotionsPage() {
const promotions = await prisma.promotion.findMany({
orderBy: { createdAt: 'desc' },
include: { products: { select: { id: true } } },
});
const now = new Date();
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between">
<h1 className="font-display text-3xl">Promotions</h1>
<Link
href="/admin/promotions/new"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
+ Add promotion
</Link>
</div>
<p className="mt-2 text-sm text-muted">
A banner shown at the top of every page while its date window is active. If more than one
is active at once, the most recently created one shows.
</p>
{promotions.length === 0 ? (
<p className="mt-8 text-sm text-muted">No promotions yet.</p>
) : (
<div className="mt-8 divide-y divide-line border-t border-line">
{promotions.map((p) => {
const status = statusFor(p, now);
return (
<div key={p.id} className="flex items-center gap-4 py-4">
<div className="flex-1">
<p className="font-medium">{p.message}</p>
<p className="tag-label mt-0.5">
{p.discountPercent}% off {p.scope === 'ALL' ? 'All items' : `${p.products.length} item${p.products.length === 1 ? '' : 's'}`}
{' · '}
{formatDate(p.startsAt) ?? 'Starts immediately'} {formatDate(p.endsAt) ?? 'no end date'}
</p>
</div>
<span className={`tag-label rounded-full px-3 py-1 ${status.className}`}>{status.label}</span>
<Link href={`/admin/promotions/${p.id}/edit`} className="tag-label hover:text-clay">
Edit
</Link>
<form action={deletePromotion}>
<input type="hidden" name="id" value={p.id} />
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
Delete
</button>
</form>
</div>
);
})}
</div>
)}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import ShareButtons from '@/components/ShareButtons';
export default async function ProofSentPage({
params,
searchParams,
}: {
params: { id: string };
searchParams: { emailed?: string };
}) {
const proof = await prisma.designProof.findUnique({
where: { id: params.id },
include: { product: true },
});
if (!proof) notFound();
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
const link = `${siteUrl}/proofs/${proof.id}`;
const emailed = searchParams.emailed === '1';
return (
<div className="mx-auto max-w-xl px-6 py-16 text-center">
<p className="tag-label text-splash-pink">Design uploaded</p>
<h1 className="mt-2 font-display text-3xl">
{emailed ? 'Sent to your customer' : 'Ready to send'}
</h1>
{emailed ? (
<p className="mt-3 text-sm text-muted">
We emailed {proof.customerEmail} a link to review and approve this design. You can also send
it another way below.
</p>
) : (
<div className="mt-3 border border-splash-orange bg-splash-orange/5 p-4 text-left text-sm">
<p className="font-medium text-ink">Email wasn&apos;t sent automatically.</p>
<p className="mt-1 text-muted">
This usually means SMTP isn&apos;t configured yet in <code className="font-mono">.env</code> (see
the README&apos;s &quot;Sending emails&quot; section). Use one of the options below instead.
</p>
</div>
)}
<div className="mt-8 border border-line bg-surface p-6">
{proof.imageDataUrl ? (
<img
src={proof.imageDataUrl}
alt="Uploaded design"
className="mx-auto max-h-64 object-contain"
/>
) : (
<p className="text-sm text-muted">No image was saved with this upload.</p>
)}
</div>
<ShareButtons
message={`Here's your ${proof.product.name} design to review and approve: ${link}`}
link={link}
redirectUri={link}
/>
<Link href="/admin/proofs/new" className="mt-8 inline-block tag-label hover:text-ink">
Upload another design
</Link>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
'use server';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
export async function deleteProof(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
// Chat messages cascade-delete automatically (set up on the Message relation).
await prisma.designProof.delete({ where: { id } });
redirect('/admin/proofs');
}
+57
View File
@@ -0,0 +1,57 @@
'use server';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { sendProofEmail } from '@/lib/mail';
export async function createProof(formData: FormData) {
const productId = String(formData.get('productId') ?? '');
const customerName = String(formData.get('customerName') ?? '').trim();
const customerEmail = String(formData.get('customerEmail') ?? '').trim();
const color = String(formData.get('color') ?? '');
const quantity = Math.max(1, Number(formData.get('quantity') ?? 1));
const note = String(formData.get('note') ?? '').trim();
const priceOverride = formData.get('price');
const file = formData.get('image') as File | null;
if (!productId || !customerEmail || !color || !file || file.size === 0) {
throw new Error('Missing required fields: product, customer email, color, and an image are all required.');
}
const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) throw new Error('Product not found.');
const bytes = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/png';
const imageDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
const unitPrice = priceOverride && String(priceOverride).trim() !== ''
? Math.round(Number(priceOverride) * 100)
: product.basePrice;
const proof = await prisma.designProof.create({
data: {
productId,
customerName: customerName || null,
customerEmail,
color,
quantity,
unitPrice,
imageDataUrl,
note: note || null,
},
});
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
const link = `${siteUrl}/proofs/${proof.id}`;
const result = await sendProofEmail({
to: customerEmail,
customerName: customerName || null,
link,
productName: product.name,
note: note || null,
});
redirect(`/admin/proofs/${proof.id}/sent?emailed=${result.sent ? '1' : '0'}`);
}
+151
View File
@@ -0,0 +1,151 @@
import { prisma } from '@/lib/prisma';
import { createProof } from './actions';
import AdminNav from '@/components/AdminNav';
export default async function NewProofPage({
searchParams,
}: {
searchParams: { productId?: string; customerName?: string; customerEmail?: string };
}) {
const products = await prisma.product.findMany({ orderBy: { name: 'asc' } });
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Upload a design for approval</h1>
<p className="mt-2 text-sm text-muted">
Upload a finished mockup for one customer. We&apos;ll give you a link to send them they view
it, approve it, and it goes straight to their cart.
</p>
<form action={createProof} className="mt-10 space-y-6">
<div>
<label htmlFor="productId" className="tag-label mb-2 block">
Product template
</label>
<select
id="productId"
name="productId"
required
defaultValue={searchParams.productId}
className="w-full border border-line px-3 py-2 text-sm"
>
{products.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="customerName" className="tag-label mb-2 block">
Customer name (optional)
</label>
<input
id="customerName"
name="customerName"
defaultValue={searchParams.customerName}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="Jane Doe"
/>
</div>
<div>
<label htmlFor="customerEmail" className="tag-label mb-2 block">
Customer email
</label>
<input
id="customerEmail"
name="customerEmail"
type="email"
required
defaultValue={searchParams.customerEmail}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="jane@example.com"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label htmlFor="color" className="tag-label mb-2 block">
Color shown
</label>
<div className="flex items-center gap-2">
<input
id="color"
name="color"
type="color"
required
defaultValue="#17181C"
className="h-10 w-14 cursor-pointer border border-line bg-paper p-0"
/>
</div>
</div>
<div>
<label htmlFor="quantity" className="tag-label mb-2 block">
Quantity
</label>
<input
id="quantity"
name="quantity"
type="number"
min={1}
defaultValue={1}
className="w-full border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="price" className="tag-label mb-2 block">
Price override (£)
</label>
<input
id="price"
name="price"
type="number"
step="0.01"
min={0}
placeholder="Uses template price"
className="w-full border border-line px-3 py-2 text-sm"
/>
</div>
</div>
<div>
<label htmlFor="image" className="tag-label mb-2 block">
Finished design image
</label>
<input
id="image"
name="image"
type="file"
accept="image/*"
required
className="w-full border border-line px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">
Upload the actual finished mockup this is shown to the customer exactly as-is.
</p>
</div>
<div>
<label htmlFor="note" className="tag-label mb-2 block">
Note to customer (optional)
</label>
<textarea
id="note"
name="note"
rows={3}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="Here's the design we talked about — let me know if you'd like anything changed."
/>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Create approval link
</button>
</form>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { deleteProof } from './actions';
function formatPrice(cents: number) {
return `£${(cents / 100).toFixed(2)}`;
}
export default async function ProofsListPage() {
const proofs = await prisma.designProof.findMany({
include: { product: true },
orderBy: { createdAt: 'desc' },
});
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between">
<h1 className="font-display text-3xl">Design approvals</h1>
<Link href="/admin/proofs/new" className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper">
+ Upload new design
</Link>
</div>
{proofs.length === 0 ? (
<p className="mt-10 text-sm text-muted">Nothing uploaded yet.</p>
) : (
<div className="mt-8 divide-y divide-line border-t border-line">
{proofs.map((p) => (
<div key={p.id} className="flex items-center gap-4 py-4">
<img src={p.imageDataUrl} alt="" className="h-16 w-16 border border-line object-cover" />
<div className="flex-1">
<p className="font-medium">{p.customerName || p.customerEmail}</p>
<p className="text-sm text-muted">
{p.product.name} · qty {p.quantity} · {formatPrice(p.unitPrice * p.quantity)}
</p>
</div>
<span
className={`tag-label rounded-full px-3 py-1 ${
p.status === 'APPROVED' ? 'bg-splash-blue/10 text-splash-blue' : 'bg-splash-orange/10 text-splash-orange'
}`}
>
{p.status}
</span>
<Link href={`/admin/inbox/${p.id}`} className="tag-label hover:text-ink">
Chat
</Link>
<Link href={`/proofs/${p.id}`} className="tag-label hover:text-ink">
View
</Link>
<form action={deleteProof}>
<input type="hidden" name="id" value={p.id} />
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
Delete
</button>
</form>
</div>
))}
</div>
)}
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { stripe } from '@/lib/stripe';
import { getCurrentCustomer } from '@/lib/auth';
import { getEffectivePrice } from '@/lib/pricing';
import { fetchActivePromotions } from '@/lib/promotions';
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
type CheckoutCartItem = {
productId: string;
slug: string;
name: string;
color: string;
size: string | null;
quantity: number;
unitPrice: number;
designJson: { front: unknown[]; back: unknown[] };
designPreviewUrl: string;
designPreviewUrlBack: string | null;
placementPreviewUrl: string | null;
placementPreviewUrlBack: string | null;
};
export async function POST(req: Request) {
if (!process.env.STRIPE_SECRET_KEY) {
return NextResponse.json(
{ error: 'Stripe is not configured on this server yet. Add STRIPE_SECRET_KEY to .env.' },
{ status: 500 },
);
}
const ip = getClientIp(req.headers);
const { allowed } = await checkRateLimit(`checkout:${ip}`, 20, 10 * 60 * 1000);
if (!allowed) {
return NextResponse.json(
{ error: 'Too many requests — please wait a moment and try again.' },
{ status: 429 },
);
}
const body = await req.json();
const items: CheckoutCartItem[] = body.items ?? [];
if (items.length === 0) {
return NextResponse.json({ error: 'Your bag is empty.' }, { status: 400 });
}
// Never trust the client-submitted unitPrice for the actual charge — look up
// each product and recompute its current price (including any active sale)
// server-side. The client's unitPrice is only used for display before this point.
const products = await prisma.product.findMany({
where: { id: { in: items.map((i) => i.productId) } },
});
const productsById = new Map(products.map((p) => [p.id, p]));
for (const i of items) {
if (!productsById.has(i.productId)) {
return NextResponse.json(
{ error: 'One of the items in your bag is no longer available.' },
{ status: 400 },
);
}
}
const activePromotions = await fetchActivePromotions();
const pricedItems = items.map((i) => ({
...i,
unitPrice: getEffectivePrice(productsById.get(i.productId)!, activePromotions).price,
}));
const total = pricedItems.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
const customer = await getCurrentCustomer();
// Save the order up front, before redirecting to Stripe — this is what makes
// the design files (and the order itself) retrievable afterward, whether or
// not the customer ever makes it back to the success page.
const order = await prisma.order.create({
data: {
status: 'PENDING',
total,
customerId: customer?.id,
items: {
create: pricedItems.map((i) => ({
productId: i.productId,
productName: i.name,
quantity: i.quantity,
color: i.color,
size: i.size,
unitPrice: i.unitPrice,
designJson: JSON.stringify(i.designJson),
designPreviewUrl: i.designPreviewUrl,
designPreviewUrlBack: i.designPreviewUrlBack,
placementPreviewUrl: i.placementPreviewUrl,
placementPreviewUrlBack: i.placementPreviewUrlBack,
})),
},
},
});
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
try {
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: pricedItems.map((i) => ({
price_data: {
currency: 'gbp',
unit_amount: i.unitPrice,
product_data: {
name: `${i.name}${i.color}${i.size ? `, ${i.size}` : ''}`,
},
},
quantity: i.quantity,
})),
shipping_address_collection: {
allowed_countries: ['GB', 'IE', 'US', 'CA', 'AU', 'NZ'],
},
customer_email: customer?.email,
success_url: `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${siteUrl}/checkout/cancel`,
metadata: { orderId: order.id },
});
await prisma.order.update({
where: { id: order.id },
data: { stripeSessionId: session.id },
});
return NextResponse.json({ url: session.url });
} catch (err) {
// Stripe call failed — don't leave an orphaned PENDING order with no way to pay it.
await prisma.order.delete({ where: { id: order.id } });
const message = err instanceof Error ? err.message : 'Could not start checkout.';
return NextResponse.json({ error: message }, { status: 500 });
}
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function POST(_req: Request, { params }: { params: { id: string } }) {
const proof = await prisma.designProof.findUnique({ where: { id: params.id } });
if (!proof) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
await prisma.designProof.update({
where: { id: params.id },
data: { status: 'APPROVED' },
});
return NextResponse.json({ ok: true });
}
+51
View File
@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import type { MessageDTO } from '@/lib/types';
export async function GET(_req: Request, { params }: { params: { id: string } }) {
const rows = await prisma.message.findMany({
where: { proofId: params.id },
orderBy: { createdAt: 'asc' },
});
const messages: MessageDTO[] = rows.map((m) => ({
id: m.id,
proofId: m.proofId,
sender: m.sender as MessageDTO['sender'],
body: m.body,
createdAt: m.createdAt.toISOString(),
}));
return NextResponse.json(messages);
}
export async function POST(req: Request, { params }: { params: { id: string } }) {
const { sender, body } = await req.json();
if (sender !== 'ADMIN' && sender !== 'CUSTOMER') {
return NextResponse.json({ error: 'Invalid sender' }, { status: 400 });
}
const trimmed = String(body ?? '').trim();
if (!trimmed) {
return NextResponse.json({ error: 'Message body is required' }, { status: 400 });
}
const proof = await prisma.designProof.findUnique({ where: { id: params.id } });
if (!proof) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const created = await prisma.message.create({
data: { proofId: params.id, sender, body: trimmed },
});
const message: MessageDTO = {
id: created.id,
proofId: created.proofId,
sender: created.sender as MessageDTO['sender'],
body: created.body,
createdAt: created.createdAt.toISOString(),
};
return NextResponse.json(message);
}
+67
View File
@@ -0,0 +1,67 @@
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
import { upsertMailingListEntry } from '@/lib/mailingList';
import { applyStockMovement } from '@/lib/stock';
import type Stripe from 'stripe';
export async function POST(req: Request) {
const signature = req.headers.get('stripe-signature');
const rawBody = await req.text();
if (!process.env.STRIPE_WEBHOOK_SECRET || !signature) {
return NextResponse.json({ error: 'Webhook not configured.' }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
const message = err instanceof Error ? err.message : 'Invalid signature';
return NextResponse.json({ error: `Webhook signature verification failed: ${message}` }, { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
const orderId = session.metadata?.orderId;
if (orderId) {
const shipping = (session as unknown as { shipping_details?: Stripe.Checkout.Session.ShippingDetails })
.shipping_details;
// Stripe retries webhooks, so the same completed event can arrive more
// than once — remember whether this is the first PENDING→PAID flip and
// only move stock on that one.
const before = await prisma.order.findUnique({ where: { id: orderId } });
const firstTimePaid = before != null && before.status !== 'PAID';
const updated = await prisma.order.update({
where: { id: orderId },
data: {
status: 'PAID',
email: session.customer_details?.email ?? undefined,
shippingAddress: shipping ? JSON.stringify(shipping) : undefined,
},
});
if (firstTimePaid) {
const items = await prisma.orderItem.findMany({ where: { orderId } });
await applyStockMovement(items, -1);
}
if (!updated.customerId && updated.email) {
await upsertMailingListEntry({ email: updated.email, source: 'GUEST' });
}
}
}
if (event.type === 'checkout.session.expired' || event.type === 'checkout.session.async_payment_failed') {
const session = event.data.object as Stripe.Checkout.Session;
const orderId = session.metadata?.orderId;
if (orderId) {
await prisma.order.update({ where: { id: orderId }, data: { status: 'FAILED' } });
}
}
return NextResponse.json({ received: true });
}
+209
View File
@@ -0,0 +1,209 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useCart } from '@/lib/cartStore';
import Price from '@/components/Price';
export default function CartPage() {
const [mounted, setMounted] = useState(false);
const [checkingOut, setCheckingOut] = useState(false);
const [checkoutError, setCheckoutError] = useState<string | null>(null);
const items = useCart((s) => s.items);
const removeItem = useCart((s) => s.removeItem);
const setQuantity = useCart((s) => s.setQuantity);
const total = useCart((s) => s.total());
const hasCustomDesign = items.some((item) => item.designJson.front.length > 0 || item.designJson.back.length > 0);
useEffect(() => setMounted(true), []);
const handleCheckout = async () => {
setCheckingOut(true);
setCheckoutError(null);
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
});
const data = await res.json();
if (!res.ok || !data.url) {
throw new Error(data.error ?? 'Something went wrong starting checkout.');
}
window.location.href = data.url;
} catch (err) {
setCheckoutError(err instanceof Error ? err.message : 'Something went wrong.');
setCheckingOut(false);
}
};
if (!mounted) return null; // avoid a hydration mismatch on first paint
if (items.length === 0) {
return (
<div className="mx-auto max-w-2xl px-6 py-20 text-center">
<h1 className="font-display text-4xl">Your bag</h1>
<p className="mt-4 text-muted">Nothing in here yet.</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Link
href="/made-to-order"
className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark"
>
Start designing
</Link>
<Link
href="/products"
className="border-2 border-ink px-6 py-3 text-sm font-medium text-ink hover:border-clay hover:text-clay"
>
Shop ready-made
</Link>
</div>
</div>
);
}
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<h1 className="font-display text-4xl">Your bag</h1>
<div className="mt-8 divide-y divide-line border-t border-line">
{items.map((item) => (
<div key={item.cartItemId} className="flex flex-wrap items-center gap-4 py-6">
<div className="h-24 w-24 shrink-0 border border-line bg-surface p-1">
{item.placementPreviewUrl || item.designPreviewUrl ? (
<img
src={item.placementPreviewUrl ?? item.designPreviewUrl}
alt={item.name}
className="h-full w-full object-contain"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-muted">No preview</div>
)}
</div>
<div className="min-w-[160px] flex-1">
<p className="font-display text-lg">{item.name}</p>
<div className="mt-1 flex items-center gap-3 text-sm text-muted">
<span className="flex items-center gap-1.5">
<span
className="h-3.5 w-3.5 rounded-full border border-line"
style={{ backgroundColor: item.color }}
/>
{item.color}
</span>
{item.size && <span>Size {item.size}</span>}
</div>
{(item.designJson.front.length > 0 || item.designJson.back.length > 0) && (
<div className="mt-1">
<p className="text-xs text-muted">
Custom design
{item.designJson.front.length > 0 && item.designJson.back.length > 0
? ' — front & back'
: item.designJson.back.length > 0
? ' — back'
: ' — front'}
</p>
<div className="mt-1 flex gap-3">
{item.designJson.front.length > 0 && item.designPreviewUrl && (
<a
href={item.designPreviewUrl}
download={`${item.slug}-front-print.png`}
className="text-xs text-clay hover:text-clay-dark"
>
Download front PNG
</a>
)}
{item.designJson.back.length > 0 && item.designPreviewUrlBack && (
<a
href={item.designPreviewUrlBack}
download={`${item.slug}-back-print.png`}
className="text-xs text-clay hover:text-clay-dark"
>
Download back PNG
</a>
)}
</div>
</div>
)}
</div>
<div className="flex items-center border border-ink">
<button
onClick={() => setQuantity(item.cartItemId, item.quantity - 1)}
className="px-3 py-2 text-sm"
aria-label="Decrease quantity"
>
</button>
<span className="w-10 text-center font-mono text-sm">{item.quantity}</span>
<button
onClick={() => setQuantity(item.cartItemId, item.quantity + 1)}
className="px-3 py-2 text-sm"
aria-label="Increase quantity"
>
+
</button>
</div>
<div className="w-24 text-right font-mono text-sm">
<Price cents={item.unitPrice * item.quantity} />
</div>
<button
onClick={() => removeItem(item.cartItemId)}
className="tag-label text-muted hover:text-splash-pink"
>
Remove
</button>
</div>
))}
</div>
{hasCustomDesign && (
<div className="mt-8 border border-splash-orange bg-splash-orange/5 px-4 py-3 text-sm">
<p className="font-medium text-ink">Please check your design before checking out</p>
<p className="mt-1 text-muted">
We print exactly what&apos;s shown in your preview above spelling, wording, and
placement included. Once your order&apos;s placed, that design goes straight to print,
so take a moment to make sure everything looks right.
</p>
</div>
)}
<div className="mt-8 flex flex-col items-end gap-4">
<div className="flex items-baseline gap-3">
<span className="tag-label">Subtotal</span>
<span className="font-mono text-xl">
<Price cents={total} />
</span>
</div>
<div className="w-full max-w-xs">
<div className="mb-4 rounded border border-splash-blue/40 bg-splash-blue/5 px-3 py-2 text-xs text-muted">
Your cart will be saved. You&apos;ll need to log in again to complete your order.
</div>
<button
onClick={handleCheckout}
disabled={checkingOut}
className="w-full bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark disabled:opacity-60"
>
{checkingOut ? 'Redirecting to checkout…' : 'Checkout'}
</button>
{checkoutError && <p className="mt-2 text-center text-xs text-splash-pink">{checkoutError}</p>}
<p className="mt-2 text-center text-xs text-muted">You&apos;ll enter payment details on the next step.</p>
<p className="mt-1 text-center text-xs text-muted">
By placing an order you agree to our{' '}
<Link href="/terms" className="text-clay hover:text-clay-dark">
Terms
</Link>{' '}
and{' '}
<Link href="/returns" className="text-clay hover:text-clay-dark">
Returns policy
</Link>
.
</p>
</div>
</div>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import Link from 'next/link';
export default function CheckoutCancelPage() {
return (
<div className="mx-auto max-w-lg px-6 py-20 text-center">
<p className="tag-label text-splash-orange">Checkout cancelled</p>
<h1 className="mt-2 font-display text-4xl">No charge was made</h1>
<p className="mt-4 text-muted">Your bag is still here, exactly as you left it.</p>
<Link
href="/cart"
className="mt-8 inline-block bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark"
>
Back to your bag
</Link>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import ClearCartOnMount from '@/components/ClearCartOnMount';
import Price from '@/components/Price';
export default async function CheckoutSuccessPage({
searchParams,
}: {
searchParams: { session_id?: string };
}) {
const sessionId = searchParams.session_id;
const order = sessionId
? await prisma.order.findUnique({
where: { stripeSessionId: sessionId },
include: { items: true },
})
: null;
return (
<div className="mx-auto max-w-2xl px-6 py-20 text-center">
<ClearCartOnMount />
<p className="tag-label text-splash-pink">Order confirmed</p>
<h1 className="mt-2 font-display text-4xl">Thank you!</h1>
{!order ? (
<p className="mt-4 text-muted">
Your payment went through. We couldn&apos;t find the order details on this page, but
you&apos;ll get a confirmation from Stripe by email.
</p>
) : (
<>
<p className="mt-4 text-muted">
{order.status === 'PAID'
? "We've got your order and we'll get started on it."
: "We're just confirming your payment — this updates automatically, no need to refresh."}
</p>
<div className="mt-10 divide-y divide-line border-t border-line text-left">
{order.items.map((item) => (
<div key={item.id} className="flex items-center gap-4 py-4">
<img
src={item.placementPreviewUrl || item.designPreviewUrl || undefined}
alt={item.productName}
className="h-16 w-16 border border-line bg-surface object-contain"
/>
<div className="flex-1">
<p className="font-medium">{item.productName}</p>
<p className="text-sm text-muted">
{item.color}
{item.size ? `, ${item.size}` : ''} · qty {item.quantity}
</p>
</div>
<Price cents={item.unitPrice * item.quantity} className="font-mono text-sm" />
</div>
))}
</div>
<div className="mt-6 flex justify-end">
<div className="flex items-baseline gap-3">
<span className="tag-label">Total</span>
<span className="font-mono text-xl">
<Price cents={order.total} />
</span>
</div>
</div>
</>
)}
<Link href="/" className="mt-10 inline-block tag-label hover:text-ink">
Back to home
</Link>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
'use server';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { sendContactMessageNotification } from '@/lib/mail';
import { verifyTurnstileToken } from '@/lib/turnstile';
import { getClientIp } from '@/lib/rateLimit';
export async function sendContactMessage(formData: FormData) {
const name = String(formData.get('name') ?? '').trim();
const email = String(formData.get('email') ?? '').trim();
const message = String(formData.get('message') ?? '').trim();
if (!name || !email || !message) {
redirect('/contact?error=missing');
}
const ip = getClientIp(headers());
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) {
redirect('/contact?error=captcha');
}
await sendContactMessageNotification({ name, email, message });
redirect('/contact?sent=1');
}
+90
View File
@@ -0,0 +1,90 @@
import { getCurrentCustomer } from '@/lib/auth';
import { sendContactMessage } from './actions';
import Turnstile from '@/components/Turnstile';
const ERROR_MESSAGES: Record<string, string> = {
missing: 'Name, email, and a message are required.',
captcha: 'CAPTCHA verification failed — please try again.',
};
export default async function ContactPage({
searchParams,
}: {
searchParams: { sent?: string; error?: string };
}) {
const customer = await getCurrentCustomer();
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<p className="tag-label text-splash-pink">Contact</p>
<h1 className="mt-2 font-display text-3xl">Get in touch</h1>
<p className="mt-2 text-sm text-muted">
Question about an order, a template, or anything else? Send us a message and we&apos;ll get
back to you.
</p>
{searchParams.sent === '1' ? (
<div className="mt-10 border border-splash-blue bg-splash-blue/5 p-6 text-sm">
Thanks we&apos;ve got your message and will be in touch soon.
</div>
) : (
<form action={sendContactMessage} className="mt-10 space-y-6">
{error && (
<p className="border border-splash-pink/40 bg-splash-pink/10 px-4 py-3 text-sm text-splash-pink">
{error}
</p>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="name" className="tag-label mb-2 block">
Your name
</label>
<input
id="name"
name="name"
required
defaultValue={customer?.name ?? ''}
className="w-full border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="email" className="tag-label mb-2 block">
Email
</label>
<input
id="email"
name="email"
type="email"
required
defaultValue={customer?.email ?? ''}
className="w-full border border-line px-3 py-2 text-sm"
/>
</div>
</div>
<div>
<label htmlFor="message" className="tag-label mb-2 block">
Message
</label>
<textarea
id="message"
name="message"
required
rows={5}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="How can we help?"
/>
</div>
<Turnstile />
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Send message
</button>
</form>
)}
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import Link from 'next/link';
import LegalPage from '@/components/LegalPage';
export const metadata = { title: 'Cookie Policy — Craft2Prints' };
export default function CookiesPage() {
return (
<LegalPage tag="Legal" title="Cookie Policy" lastUpdated="13 July 2026">
<h2>The short version</h2>
<p>
We use two cookies, both strictly necessary to make logging in work. We use no advertising,
analytics, or tracking cookies of any kind which is why you don&apos;t see a cookie
consent banner on this site: UK law only requires consent for cookies that aren&apos;t
essential, and ours all are.
</p>
<h2>The cookies we set</h2>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left text-sm">
<thead>
<tr className="border-b border-line">
<th className="py-2 pr-4 font-medium">Cookie</th>
<th className="py-2 pr-4 font-medium">Purpose</th>
<th className="py-2 font-medium">Lasts</th>
</tr>
</thead>
<tbody className="text-muted">
<tr className="border-b border-line">
<td className="py-2 pr-4 font-mono text-xs">customer_session</td>
<td className="py-2 pr-4">Keeps you signed in to your customer account</td>
<td className="py-2">30 days</td>
</tr>
<tr className="border-b border-line">
<td className="py-2 pr-4 font-mono text-xs">admin_session</td>
<td className="py-2 pr-4">Keeps shop staff signed in to the admin area (only set if you log in as staff)</td>
<td className="py-2">Until logout</td>
</tr>
</tbody>
</table>
</div>
<p>
Both are &quot;HTTP-only&quot; (scripts on the page can&apos;t read them) and contain only a
signed session token no personal details.
</p>
<h2>Other storage on your device</h2>
<p>
Like most modern shops, we also keep a few things in your browser&apos;s own storage rather
than in cookies. None of this is sent to us until you act (e.g. check out):
</p>
<ul>
<li>
<strong>Your bag</strong> the items and designs in your bag are stored on your device
so they survive a page refresh.
</li>
<li>
<strong>Preferences</strong> your light/dark mode choice and display currency.
</li>
</ul>
<h2>Third-party cookies</h2>
<ul>
<li>
<strong>Stripe</strong> when you proceed to payment you&apos;re taken to Stripe&apos;s
secure checkout page, which sets its own cookies for fraud prevention. See{' '}
<a href="https://stripe.com/gb/legal/cookies-policy" target="_blank" rel="noopener noreferrer">
Stripe&apos;s cookie policy
</a>
.
</li>
<li>
<strong>Cloudflare Turnstile</strong> the bot-protection check on our forms may use
similar technologies to tell humans from bots. It doesn&apos;t track you across other
sites.
</li>
</ul>
<h2>Managing cookies</h2>
<p>
You can delete or block cookies in your browser settings at any time the only effect on
this site is that you&apos;ll be signed out and will need to log in again. If we ever add
non-essential cookies (such as analytics), we&apos;ll update this page and ask for your
consent first.
</p>
<p>
Questions? See our <Link href="/privacy">Privacy Policy</Link> or{' '}
<Link href="/contact">get in touch</Link>.
</p>
</LegalPage>
);
}
+60
View File
@@ -0,0 +1,60 @@
'use server';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { sendCustomRequestNotification } from '@/lib/mail';
import { verifyTurnstileToken } from '@/lib/turnstile';
import { getClientIp } from '@/lib/rateLimit';
export async function createCustomRequest(formData: FormData) {
const customerId = String(formData.get('customerId') ?? '').trim() || null;
const productId = String(formData.get('productId') ?? '');
const customerName = String(formData.get('customerName') ?? '').trim();
const customerEmail = String(formData.get('customerEmail') ?? '').trim();
const customerPhone = String(formData.get('customerPhone') ?? '').trim();
const note = String(formData.get('note') ?? '').trim();
const file = formData.get('image') as File | null;
if (!productId || !customerName || !customerEmail || !file || file.size === 0) {
throw new Error('Missing required fields: product, name, email, and a photo are all required.');
}
const ip = getClientIp(headers());
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
const { success } = await verifyTurnstileToken(turnstileToken, ip);
if (!success) {
throw new Error('CAPTCHA verification failed — please try again.');
}
const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product) throw new Error('Product not found.');
const bytes = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/png';
const imageDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
const request = await prisma.customRequest.create({
data: {
customerId,
productId,
customerName,
customerEmail,
customerPhone: customerPhone || null,
imageDataUrl,
note: note || null,
},
});
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
const adminLink = `${siteUrl}/admin/custom-requests/${request.id}`;
await sendCustomRequestNotification({
customerName,
productName: product.name,
note: note || null,
adminLink,
});
redirect(`/custom-request/submitted?id=${request.id}`);
}
+121
View File
@@ -0,0 +1,121 @@
import { prisma } from '@/lib/prisma';
import { getCurrentCustomer } from '@/lib/auth';
import { createCustomRequest } from './actions';
import Turnstile from '@/components/Turnstile';
export default async function CustomRequestPage() {
const [products, customer] = await Promise.all([
prisma.product.findMany({ orderBy: { name: 'asc' } }),
getCurrentCustomer(),
]);
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<p className="tag-label text-splash-pink">Custom request</p>
<h1 className="mt-2 font-display text-3xl">Send us your photo</h1>
<p className="mt-2 text-sm text-muted">
Have a photo or artwork you'd like turned into something we can print? Send it over and
we'll get back to you with a finished design to review.
</p>
<form action={createCustomRequest} className="mt-10 space-y-6">
{customer && <input type="hidden" name="customerId" value={customer.id} />}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="customerName" className="tag-label mb-2 block">
Your name
</label>
<input
id="customerName"
name="customerName"
required
defaultValue={customer?.name ?? ''}
className="w-full border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="customerEmail" className="tag-label mb-2 block">
Email
</label>
<input
id="customerEmail"
name="customerEmail"
type="email"
required
defaultValue={customer?.email ?? ''}
className="w-full border border-line px-3 py-2 text-sm"
/>
</div>
</div>
<div>
<label htmlFor="customerPhone" className="tag-label mb-2 block">
Phone (optional)
</label>
<input
id="customerPhone"
name="customerPhone"
type="tel"
placeholder="For a faster reply via WhatsApp"
className="w-full border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="productId" className="tag-label mb-2 block">
What should we put it on?
</label>
<select
id="productId"
name="productId"
required
className="w-full border border-line px-3 py-2 text-sm"
>
{products.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div>
<label htmlFor="image" className="tag-label mb-2 block">
Your photo
</label>
<input
id="image"
name="image"
type="file"
accept="image/*"
required
className="w-full border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="note" className="tag-label mb-2 block">
Anything we should know? (optional)
</label>
<textarea
id="note"
name="note"
rows={3}
className="w-full border border-line px-3 py-2 text-sm"
placeholder="Colors, sizing, where you'd like it placed..."
/>
</div>
<Turnstile />
<button
type="submit"
className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark disabled:cursor-not-allowed disabled:opacity-50"
>
Send it over
</button>
</form>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
export default async function CustomRequestSubmittedPage({
searchParams,
}: {
searchParams: { id?: string };
}) {
const id = searchParams.id ?? '';
const request = id
? await prisma.customRequest.findUnique({ where: { id }, include: { product: true } })
: null;
if (!request) notFound();
return (
<div className="mx-auto max-w-xl px-6 py-16 text-center">
<p className="tag-label text-splash-pink">Got it</p>
<h1 className="mt-2 font-display text-3xl">Thanks we've got your photo</h1>
<p className="mt-3 text-sm text-muted">
We'll take a look and get back to you at {request.customerEmail} with a design to review.
</p>
<div className="mt-8 border border-line bg-surface p-6">
<img
src={request.imageDataUrl}
alt="Your submitted photo"
className="mx-auto max-h-64 object-contain"
/>
<p className="mt-3 text-sm text-muted">For a {request.product.name}</p>
</div>
<Link href="/" className="mt-8 inline-block tag-label hover:text-ink">
Back to the shop
</Link>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import Reveal from '@/components/Reveal';
export default async function GalleryPage({ searchParams }: { searchParams: { category?: string } }) {
const categories = await prisma.galleryCategory.findMany({ orderBy: { name: 'asc' } });
const activeSlug = searchParams.category ?? null;
const activeCategory = activeSlug ? categories.find((c) => c.slug === activeSlug) ?? null : null;
const photos = await prisma.galleryPhoto.findMany({
where: activeCategory ? { categoryId: activeCategory.id } : {},
orderBy: { createdAt: 'desc' },
});
const chipClass = (isActive: boolean) =>
`border px-3 py-1.5 text-xs transition-colors ${
isActive ? 'border-clay bg-clay text-paper' : 'border-line hover:border-clay hover:text-clay'
}`;
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<p className="font-script text-3xl text-splash-pink">Our work</p>
<h1 className="mt-2 font-display text-4xl">Gallery</h1>
<p className="mt-3 max-w-xl text-muted">
A look at pieces we&apos;ve made. Browse by category, or see everything.
</p>
{categories.length > 0 && (
<div className="mt-8 flex flex-wrap gap-2">
<Link href="/gallery" className={chipClass(activeCategory === null)}>
All
</Link>
{categories.map((c) => (
<Link key={c.id} href={`/gallery?category=${c.slug}`} className={chipClass(activeCategory?.id === c.id)}>
{c.name}
</Link>
))}
</div>
)}
{photos.length === 0 ? (
<div className="mt-10 border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">
{activeCategory ? 'Nothing in this category yet' : 'Gallery coming soon'}
</p>
<p className="mt-2 text-sm text-muted">
{activeCategory
? 'Try another category, or view all.'
: "We're putting together a collection of our completed work — check back soon."}
</p>
</div>
) : (
<Reveal className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{photos.map((p) => (
<figure key={p.id} className="group flex w-full flex-col overflow-hidden border border-line bg-surface">
<div className="aspect-square w-full overflow-hidden">
<img
src={p.imageDataUrl}
alt={p.caption ?? 'Completed work'}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
{p.caption && (
<figcaption className="px-4 py-3 text-sm text-muted">{p.caption}</figcaption>
)}
</figure>
))}
</Reveal>
)}
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-paper: 255 253 248;
--color-ink: 26 26 26;
--color-line: 234 230 221;
--color-surface: 255 255 255;
--color-muted: 138 133 120;
}
.dark {
--color-paper: 34 30 26;
--color-ink: 240 235 224;
--color-line: 64 58 48;
--color-surface: 46 41 35;
--color-muted: 173 164 148;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-paper text-ink font-body antialiased transition-colors duration-200;
}
::selection {
@apply bg-splash-pink text-paper;
}
:focus-visible {
outline: 2px solid #e5227e;
outline-offset: 2px;
}
}
@layer utilities {
.tag-label {
@apply font-mono text-[11px] uppercase tracking-tag text-muted;
}
.hairline {
@apply border-line;
}
.link-cta {
@apply inline-flex items-center gap-1 text-sm font-semibold text-clay transition-all hover:gap-2.5 hover:text-clay-dark;
}
.brand-gradient {
background-image: linear-gradient(90deg, #f5871f 0%, #e5227e 35%, #1ea7e0 70%, #5b2c86 100%);
}
.brand-gradient-text {
background-image: linear-gradient(90deg, #f5871f 0%, #e5227e 35%, #1ea7e0 70%, #5b2c86 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
@keyframes drift {
0%,
100% {
transform: translateY(0) rotate(var(--drift-rotate, 0deg));
}
50% {
transform: translateY(-8px) rotate(calc(var(--drift-rotate, 0deg) + 8deg));
}
}
.animate-drift {
animation: drift 9s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
+78
View File
@@ -0,0 +1,78 @@
import type { Metadata } from 'next';
import { Fraunces, Inter, JetBrains_Mono, Caveat } from 'next/font/google';
import { headers } from 'next/headers';
import './globals.css';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import PromotionBanner from '@/components/PromotionBanner';
import MaintenancePage from '@/components/MaintenancePage';
import { CurrencyProvider } from '@/lib/CurrencyContext';
import { getSiteSettings } from '@/lib/settings';
import { isAdminAuthenticated } from '@/lib/adminAuth';
const fraunces = Fraunces({
subsets: ['latin'],
variable: '--font-fraunces',
axes: ['opsz', 'SOFT', 'WONK'],
});
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono' });
const caveat = Caveat({ subsets: ['latin'], variable: '--font-script', weight: ['500', '700'] });
export const metadata: Metadata = {
title: 'Craft2Prints — Ink it. Print it. Love it.',
description:
'Design your own apparel, drinkware, and phone cases. Personalize a template, we print and ship it.',
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const { maintenanceMode, maintenanceMessage } = await getSiteSettings();
const pathname = headers().get('x-pathname') ?? '';
// Admins bypass maintenance (they can keep working); /admin/* is always
// reachable so the owner can log in and toggle it back off.
const showMaintenance =
maintenanceMode && !pathname.startsWith('/admin') && !(await isAdminAuthenticated());
return (
<html lang="en" className={`${fraunces.variable} ${inter.variable} ${mono.variable} ${caveat.variable}`}>
<head>
{/* Extra fonts available in the design tool's text options (loaded by real
family name so the canvas can reference them directly). */}
<link
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600&family=Oswald:wght@500&family=Bebas+Neue&family=Pacifico&family=Permanent+Marker&family=Caveat:wght@600&display=swap"
rel="stylesheet"
/>
{/* Runs before paint so there's no flash of the wrong theme on load. */}
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var isDark = stored ? stored === 'dark' : prefersDark;
if (isDark) document.documentElement.classList.add('dark');
})();
`,
}}
/>
</head>
<body className="flex min-h-screen flex-col">
<CurrencyProvider>
{showMaintenance ? (
<MaintenancePage message={maintenanceMessage} />
) : (
<>
<PromotionBanner />
<Header />
<main className="flex-1">{children}</main>
<Footer />
</>
)}
</CurrencyProvider>
</body>
</html>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { notFound } from 'next/navigation';
import dynamic from 'next/dynamic';
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import ProductCard from '@/components/ProductCard';
import Reviews from '@/components/Reviews';
const DesignCanvas = dynamic(() => import('@/components/DesignCanvas'), { ssr: false });
export default async function ProductDetailPage({ params }: { params: { slug: string } }) {
const activePromotions = await fetchActivePromotions();
const row = await prisma.product.findUnique({ where: { slug: params.slug } });
if (!row || !row.showOnPersonalised) notFound();
const product = toProductDTO(row, activePromotions);
const relatedRows = await prisma.product.findMany({
where: { category: product.category, showOnPersonalised: true, NOT: { id: product.id } },
take: 4,
});
const related = relatedRows.map((p) => toProductDTO(p, activePromotions));
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<DesignCanvas product={product} />
<section className="mt-20 border-t border-line pt-12">
<Reviews />
</section>
{related.length > 0 && (
<section className="mt-20 border-t border-line pt-12">
<h2 className="mb-8 font-display text-3xl">You might also like</h2>
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-4">
{related.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
</section>
)}
</div>
);
}
+249
View File
@@ -0,0 +1,249 @@
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import ProductCard from '@/components/ProductCard';
import type { Prisma } from '@prisma/client';
type SearchParams = {
category?: string | string[];
collection?: string | string[];
color?: string | string[];
min?: string;
max?: string;
q?: string;
};
function toArray(v?: string | string[]) {
if (!v) return [];
return Array.isArray(v) ? v : [v];
}
export default async function ProductsPage({ searchParams }: { searchParams: SearchParams }) {
const categoryRows = await prisma.category.findMany({
where: { showOnPersonalised: true },
orderBy: { name: 'asc' },
});
const CATEGORIES = categoryRows.map((c) => ({ value: c.slug, label: c.name }));
const collectionRows = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
const OCCASIONS = collectionRows.filter((c) => c.group === 'OCCASION').map((c) => ({ value: c.slug, label: c.name }));
const RECIPIENTS = collectionRows.filter((c) => c.group === 'RECIPIENT').map((c) => ({ value: c.slug, label: c.name }));
const selectedCategories = toArray(searchParams.category);
const selectedCollections = toArray(searchParams.collection);
const selectedColors = toArray(searchParams.color);
const q = searchParams.q?.trim() ?? '';
const minDollars = searchParams.min ? Number(searchParams.min) : undefined;
const maxDollars = searchParams.max ? Number(searchParams.max) : undefined;
// Full palette across every product, for the swatch filter — independent of
// whatever else is currently filtered, so switching colors doesn't hide options.
const allProducts = await prisma.product.findMany({
where: { showOnPersonalised: true },
select: { colors: true },
});
const paletteSet = new Set<string>();
for (const p of allProducts) {
(JSON.parse(p.colors) as string[]).forEach((c) => paletteSet.add(c));
}
const palette = Array.from(paletteSet);
const where: Prisma.ProductWhereInput = { showOnPersonalised: true };
if (selectedCategories.length) {
where.category = { in: selectedCategories };
}
if (selectedCollections.length) {
where.collections = { some: { slug: { in: selectedCollections } } };
}
if (selectedColors.length) {
where.AND = [{ OR: selectedColors.map((c) => ({ colors: { contains: c } })) }];
}
if (q) {
where.name = { contains: q };
}
if (minDollars !== undefined || maxDollars !== undefined) {
where.basePrice = {
...(minDollars !== undefined ? { gte: Math.round(minDollars * 100) } : {}),
...(maxDollars !== undefined ? { lte: Math.round(maxDollars * 100) } : {}),
};
}
const activePromotions = await fetchActivePromotions();
const rows = await prisma.product.findMany({ where, orderBy: { createdAt: 'asc' } });
const products = rows.map((p) => toProductDTO(p, activePromotions));
const hasFilters =
selectedCategories.length > 0 ||
selectedCollections.length > 0 ||
selectedColors.length > 0 ||
q ||
minDollars !== undefined ||
maxDollars !== undefined;
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<div className="mb-8 flex items-baseline justify-between">
<h1 className="font-display text-4xl">Shop all</h1>
<p className="tag-label">
{products.length} template{products.length === 1 ? '' : 's'}
</p>
</div>
<form method="GET" className="grid gap-10 md:grid-cols-[220px_1fr]">
{/* Sidebar filters */}
<aside className="space-y-8">
<div>
<label htmlFor="q" className="tag-label mb-2 block">
Search
</label>
<input
id="q"
name="q"
defaultValue={q}
placeholder="Search templates…"
className="w-full border border-line px-3 py-2 text-sm focus:border-ink"
/>
</div>
<div>
<p className="tag-label mb-3">Category</p>
<div className="space-y-2">
{CATEGORIES.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="category"
value={c.value}
defaultChecked={selectedCategories.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
{OCCASIONS.length > 0 && (
<div>
<p className="tag-label mb-3">Occasion</p>
<div className="space-y-2">
{OCCASIONS.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collection"
value={c.value}
defaultChecked={selectedCollections.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
)}
{RECIPIENTS.length > 0 && (
<div>
<p className="tag-label mb-3">Gifts for</p>
<div className="space-y-2">
{RECIPIENTS.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collection"
value={c.value}
defaultChecked={selectedCollections.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
)}
<div>
<p className="tag-label mb-3">Color</p>
<div className="flex flex-wrap gap-2">
{palette.map((hex) => {
const checked = selectedColors.includes(hex);
return (
<label key={hex} className="cursor-pointer">
<input
type="checkbox"
name="color"
value={hex}
defaultChecked={checked}
className="peer sr-only"
/>
<span
title={hex}
className={`block h-8 w-8 rounded-full border transition-transform peer-checked:scale-110 peer-checked:border-ink peer-checked:ring-2 peer-checked:ring-ink peer-checked:ring-offset-2 peer-checked:ring-offset-paper ${
checked ? 'border-ink' : 'border-line'
}`}
style={{ backgroundColor: hex }}
/>
</label>
);
})}
</div>
</div>
<div>
<p className="tag-label mb-3">Price</p>
<div className="flex items-center gap-2">
<input
type="number"
name="min"
min={0}
placeholder="Min"
defaultValue={searchParams.min ?? ''}
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
/>
<span className="text-muted"></span>
<input
type="number"
name="max"
min={0}
placeholder="Max"
defaultValue={searchParams.max ?? ''}
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<button
type="submit"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Apply filters
</button>
{hasFilters && (
<a href="/made-to-order" className="text-center text-sm text-muted hover:text-ink">
Clear filters
</a>
)}
</div>
</aside>
{/* Grid */}
<div>
{products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">No templates match those filters</p>
<p className="mt-2 text-sm text-muted">Try widening your price range or clearing a filter.</p>
</div>
) : (
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
)}
</div>
</form>
</div>
);
}
+332
View File
@@ -0,0 +1,332 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { getCurrentCustomer } from '@/lib/auth';
import { fetchActivePromotions } from '@/lib/promotions';
import ProductCard from '@/components/ProductCard';
import ProductMockup from '@/components/ProductMockup';
import CherryBlossom from '@/components/CherryBlossom';
import Reveal from '@/components/Reveal';
const REASSURANCES = [
{ label: 'Made to order', body: 'Printed after you check out — nothing sits pre-made on a shelf.' },
{ label: 'Ships from the UK', body: 'Every order goes out from us, start to finish.' },
{ label: 'Secure checkout', body: 'Payments handled securely via Stripe.' },
{ label: 'Questions?', body: 'sales@craft2prints.co.uk' },
];
export default async function HomePage() {
const activePromotions = await fetchActivePromotions();
const galleryPhotos = await prisma.galleryPhoto.findMany({
take: 6,
orderBy: { createdAt: 'desc' },
});
const products = await prisma.product.findMany({
where: { showOnPersonalised: true },
take: 4,
orderBy: { createdAt: 'asc' },
});
const featured = products.map((p) => toProductDTO(p, activePromotions));
const readyMadeRows = await prisma.product.findMany({
where: { showOnProducts: true },
take: 4,
orderBy: { createdAt: 'asc' },
});
const readyMade = readyMadeRows.map((p) => toProductDTO(p, activePromotions));
const readyMadeCount = readyMade.length;
const categoryRows = await prisma.category.findMany({
where: { showOnPersonalised: true },
orderBy: { name: 'asc' },
});
const categories = categoryRows.map((c) => ({ label: c.name, value: c.slug }));
const customer = await getCurrentCustomer();
const steps = [
{
n: '01',
title: 'Pick your product',
body: 'Choose a ready-made favorite, or start from a template to design your own.',
},
{
n: '02',
title: 'Make it yours (if you want)',
body: 'Personalising? Add your own text and images right on the product in our design tool. Buying ready-made? Skip straight to checkout.',
},
{
n: '03',
title: 'We print & ship',
body: 'Personalised pieces are printed after you check out. Ready-made ships as-is — either way, it comes straight from us.',
},
];
return (
<div>
{/* Hero */}
<section className="relative z-0 mx-auto max-w-6xl px-6 pb-14 pt-14 md:pt-20">
{/* Decorative cherry blossoms — subtle, behind the content, ignored by screen readers/clicks */}
<div className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<CherryBlossom size={26} className="animate-drift absolute left-[4%] top-6 opacity-20" style={{ '--drift-rotate': '-8deg' } as React.CSSProperties} />
<CherryBlossom size={18} className="animate-drift absolute left-[42%] top-28 opacity-10" style={{ animationDelay: '2s', '--drift-rotate': '15deg' } as React.CSSProperties} />
<CherryBlossom size={34} className="animate-drift absolute bottom-4 left-[12%] opacity-20" style={{ animationDelay: '1s', '--drift-rotate': '25deg' } as React.CSSProperties} />
<CherryBlossom size={20} className="animate-drift absolute right-[32%] top-2 opacity-15" style={{ animationDelay: '3s', '--drift-rotate': '-20deg' } as React.CSSProperties} />
<CherryBlossom size={28} className="animate-drift absolute bottom-10 right-[6%] opacity-20" style={{ animationDelay: '1.5s', '--drift-rotate': '10deg' } as React.CSSProperties} />
<CherryBlossom size={16} className="animate-drift absolute right-[18%] top-1/2 opacity-10" style={{ animationDelay: '4s', '--drift-rotate': '-35deg' } as React.CSSProperties} />
</div>
<div className="grid items-center gap-12 md:grid-cols-2">
<Reveal>
<p className="font-script text-3xl text-splash-pink">Ink it. Print it. Love it.</p>
<h1 className="mt-3 font-display text-5xl leading-[1.05] md:text-6xl">
Design something only <span className="brand-gradient-text">you</span> would make.
</h1>
<p className="mt-5 max-w-md text-muted">
Start from a template and make it your own or shop pieces we&apos;ve already made.
Either way, it ships from us.
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Link
href="/made-to-order"
className="bg-clay px-7 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark"
>
Start designing
</Link>
<Link
href="/products"
className="border-2 border-ink px-7 py-3 text-sm font-medium text-ink transition-colors hover:border-clay hover:text-clay"
>
Shop ready-made
</Link>
</div>
</Reveal>
<Reveal delayMs={150} className="relative mx-auto h-[380px] w-full max-w-[420px]">
<div className="absolute inset-0 rounded-full brand-gradient opacity-40 blur-3xl" />
<div className="absolute inset-8 rounded-full brand-gradient opacity-20 blur-2xl" />
<div
className="animate-float absolute left-0 top-6 h-40 w-40 -rotate-6 border-2 bg-surface p-3 shadow-md"
style={{ borderColor: '#F5871F' }}
>
<ProductMockup mockup="tshirt" color="#F5871F" />
</div>
<div
className="animate-float absolute right-2 top-0 h-36 w-36 rotate-6 border-2 bg-surface p-3 shadow-md"
style={{ borderColor: '#1EA7E0', animationDelay: '1.5s' }}
>
<ProductMockup mockup="mug" color="#1EA7E0" />
</div>
<div
className="animate-float absolute bottom-0 left-16 h-44 w-44 rotate-3 border-2 bg-surface p-3 shadow-md"
style={{ borderColor: '#5B2C86', animationDelay: '3s' }}
>
<ProductMockup mockup="phonecase" color="#5B2C86" />
</div>
</Reveal>
</div>
</section>
{/* Reassurance strip */}
<section className="border-y border-line bg-surface">
<div className="mx-auto grid max-w-6xl gap-6 px-6 py-8 sm:grid-cols-2 lg:grid-cols-4">
{REASSURANCES.map((r) => (
<div key={r.label}>
<p className="tag-label text-ink">{r.label}</p>
<p className="mt-1 text-sm text-muted">{r.body}</p>
</div>
))}
</div>
</section>
{/* Choose your path — equal-weight split so this isn't just a funnel into personalizing */}
<section className="mx-auto max-w-6xl px-6 py-16">
<Reveal className="grid gap-5 md:grid-cols-2">
{/* Personalise card */}
<div className="group relative overflow-hidden border-2 border-ink bg-surface p-8">
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full brand-gradient opacity-20 blur-2xl" />
<p className="tag-label text-splash-pink">Design it yourself</p>
<h2 className="mt-2 font-display text-3xl">Personalise</h2>
<p className="mt-3 max-w-sm text-sm text-muted">
Pick a template and make it yours with your own text and artwork, right in the
browser.
</p>
<div className="mt-5 flex flex-wrap gap-2">
{categories.map((c) => (
<Link
key={c.value}
href={`/made-to-order?category=${c.value}`}
className="border border-line px-3 py-1.5 text-xs hover:border-clay hover:text-clay"
>
{c.label}
</Link>
))}
</div>
<Link href="/made-to-order" className="link-cta mt-6">
Start designing <span aria-hidden></span>
</Link>
</div>
{/* Ready-made card */}
<div className="group relative overflow-hidden border-2 border-line bg-surface p-8">
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-splash-blue opacity-10 blur-2xl" />
{readyMadeCount === 0 && (
<span className="tag-label border border-line px-2 py-1 text-splash-blue">Coming soon</span>
)}
<h2 className="mt-3 font-display text-3xl">Ready-made</h2>
<p className="mt-3 max-w-sm text-sm text-muted">
{readyMadeCount === 0
? "Prefer to skip the design step? We're putting together a collection of ready-to-buy favorites — no personalizing required."
: 'Prefer to skip the design step? Browse pieces that are ready to buy as-is — no personalizing required.'}
</p>
<Link href="/products" className="link-cta mt-6">
Take a look <span aria-hidden></span>
</Link>
</div>
</Reveal>
</section>
{/* Gallery preview — completed work, prominent placement near the top. Always
shown (with a "coming soon" state when empty) so it never disappears
entirely — a link this prominent shouldn't come and go with photo count. */}
<section className="mx-auto max-w-6xl px-6 pb-16">
<Reveal>
<div className="mb-8 flex items-baseline justify-between">
<div>
<p className="tag-label text-splash-pink">Our work</p>
<h2 className="mt-1 font-display text-3xl">From the gallery</h2>
</div>
<Link href="/gallery" className="link-cta">
View gallery <span aria-hidden></span>
</Link>
</div>
{galleryPhotos.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{galleryPhotos.map((p) => (
<Link
key={p.id}
href="/gallery"
className="group block aspect-square overflow-hidden border border-line bg-surface"
>
<img
src={p.imageDataUrl}
alt={p.caption ?? 'Completed work'}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</Link>
))}
</div>
) : (
<Link
href="/gallery"
className="block border border-line bg-surface p-10 text-center transition-colors hover:border-clay"
>
<p className="font-display text-xl">Photos coming soon</p>
<p className="mt-2 text-sm text-muted">
We&apos;re putting together a gallery of finished work check back soon.
</p>
</Link>
)}
</Reveal>
</section>
{/* Custom photo request — only shown to logged-in customers */}
{customer && (
<section className="mx-auto max-w-6xl px-6 pb-16">
<Reveal className="flex flex-col items-start gap-4 border-2 border-splash-pink/40 bg-surface p-8 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="tag-label text-splash-pink">Got your own photo?</p>
<h2 className="mt-2 font-display text-2xl">Send it our way and we'll work our magic</h2>
<p className="mt-2 max-w-lg text-sm text-muted">
Upload a photo and tell us what you'd like it on we'll turn it into a design for
you to review.
</p>
</div>
<Link
href="/custom-request"
className="shrink-0 bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark"
>
Send us your photo
</Link>
</Reveal>
</section>
)}
{/* Featured products */}
<section className="mx-auto max-w-6xl px-6 pb-20">
<Reveal>
<div className="mb-8 flex items-baseline justify-between">
<h2 className="font-display text-3xl">Popular templates</h2>
<Link href="/made-to-order" className="link-cta">
View all <span aria-hidden></span>
</Link>
</div>
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-4">
{featured.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
</Reveal>
</section>
{/* Ready-made favourites */}
{readyMadeCount > 0 && (
<section className="mx-auto max-w-6xl px-6 pb-20">
<Reveal>
<div className="mb-8 flex items-baseline justify-between">
<h2 className="font-display text-3xl">Ready-made favorites</h2>
<Link href="/products" className="link-cta">
View all <span aria-hidden></span>
</Link>
</div>
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-4">
{readyMade.map((p) => (
<ProductCard key={p.id} product={p} hrefBase="/products" />
))}
</div>
</Reveal>
</section>
)}
{/* How it works */}
<div className="h-1 w-full brand-gradient" />
<section id="how-it-works" className="bg-surface">
<Reveal className="mx-auto max-w-6xl px-6 py-20">
<h2 className="font-display text-3xl">How it works</h2>
<div className="mt-10 grid gap-10 md:grid-cols-3">
{steps.map((s) => (
<div key={s.n}>
<p className="font-display text-4xl brand-gradient-text">{s.n}</p>
<p className="mt-3 text-lg font-medium">{s.title}</p>
<p className="mt-2 text-sm text-muted">{s.body}</p>
</div>
))}
</div>
</Reveal>
</section>
{/* CTA banner */}
<section className="brand-gradient">
<Reveal className="mx-auto flex max-w-6xl flex-col items-center gap-5 px-6 py-16 text-center text-white">
<h2 className="font-display text-3xl md:text-4xl">Ready to make it yours?</h2>
<div className="flex flex-wrap items-center justify-center gap-3">
<Link
href="/made-to-order"
className="bg-clay px-7 py-3 text-sm font-medium text-paper transition-colors hover:bg-white hover:text-clay-dark"
>
Browse templates
</Link>
<Link
href="/products"
className="border-2 border-white px-7 py-3 text-sm font-medium text-white transition-colors hover:bg-white hover:text-clay-dark"
>
See ready-made
</Link>
</div>
</Reveal>
</section>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import Link from 'next/link';
import LegalPage from '@/components/LegalPage';
export const metadata = { title: 'Privacy Policy — Craft2Prints' };
export default function PrivacyPage() {
return (
<LegalPage tag="Legal" title="Privacy Policy" lastUpdated="13 July 2026">
<h2>Who we are</h2>
<p>
Craft2Prints is a UK-based sole trader business selling personalised and ready-made printed
products at craft2prints.co.uk. For anything in this policy, you can reach us at{' '}
<a href="mailto:sales@craft2prints.co.uk">sales@craft2prints.co.uk</a> or by post at
[BUSINESS ADDRESS]. We are the &quot;data controller&quot; for the personal information
described below, and we handle it in line with UK data protection law (the UK GDPR and the
Data Protection Act 2018).
</p>
<h2>What we collect, and why</h2>
<ul>
<li>
<strong>Orders</strong> the items you buy, any design you created or uploaded (including
photos), your email address, and the delivery address you give at checkout. We need these
to make and ship your order (legal basis: performing our contract with you).
</li>
<li>
<strong>Accounts</strong> your name, email address, and password if you create an
account. Passwords are stored only in securely hashed form we never store or see the
actual password (legal basis: contract).
</li>
<li>
<strong>Custom photo requests</strong> your name, email, the photo you send us, an
optional phone number, and any notes, so we can create your design and reply (legal
basis: taking steps at your request before a contract).
</li>
<li>
<strong>Messages</strong> messages you send through our contact form (emailed to us,
not stored on the website) and design-approval chat messages (legal basis: legitimate
interest in answering you and keeping a record of design approvals).
</li>
<li>
<strong>Mailing list</strong> your email (and name, if we have it) if you sign up for
the newsletter, make an account, or place an order. Every mailing includes context on why
you&apos;re receiving it, and you can be removed at any time by contacting us (legal
basis: legitimate interest / consent for newsletter sign-ups).
</li>
<li>
<strong>Security logs</strong> your IP address is briefly recorded when you log in or
check out, purely to block automated attacks. These records are automatically deleted
within 24 hours (legal basis: legitimate interest in keeping the site secure).
</li>
</ul>
<h2>What we never collect</h2>
<p>
Your card details never touch our servers. Payment is handled entirely by Stripe, a
regulated payment processor we only receive confirmation that payment succeeded, plus the
email and delivery address you enter on Stripe&apos;s checkout page. We also run no
advertising or analytics tracking of any kind.
</p>
<h2>Who we share data with</h2>
<p>We never sell your data. It is shared only with the services that make the shop work:</p>
<ul>
<li>
<strong>Stripe</strong> processes payments (see{' '}
<a href="https://stripe.com/gb/privacy" target="_blank" rel="noopener noreferrer">
Stripe&apos;s privacy policy
</a>
).
</li>
<li>
<strong>Cloudflare</strong> provides the bot-protection check (Turnstile) on our public
forms.
</li>
<li>
<strong>Our email provider</strong> ([EMAIL PROVIDER]) delivers order and account
emails to you.
</li>
<li>
<strong>Our hosting provider</strong> ([HOSTING PROVIDER]) runs the website and stores
its database.
</li>
</ul>
<h2>How long we keep it</h2>
<ul>
<li>Order records kept for 6 years, as required for UK tax and accounting purposes.</li>
<li>Account details kept while your account exists; deleted on request.</li>
<li>Custom-request photos and design chats kept while we work on your request and for a
reasonable period afterwards in case you return to order.</li>
<li>Mailing list entries kept until you ask to be removed.</li>
<li>Security logs (IP addresses) deleted automatically within 24 hours.</li>
</ul>
<h2>Your rights</h2>
<p>Under UK GDPR you have the right to:</p>
<ul>
<li>Ask for a copy of the personal data we hold about you (access)</li>
<li>Have inaccurate data corrected (rectification)</li>
<li>Have your data deleted where we no longer need it (erasure)</li>
<li>Receive your data in a portable format (portability)</li>
<li>Object to or restrict certain uses of your data</li>
<li>Withdraw consent at any time, where consent is the basis we rely on</li>
</ul>
<p>
To exercise any of these, email{' '}
<a href="mailto:sales@craft2prints.co.uk">sales@craft2prints.co.uk</a> or use the{' '}
<Link href="/contact">contact form</Link> we&apos;ll respond within one month. If
you&apos;re unhappy with how we handle your data, you can complain to the Information
Commissioner&apos;s Office at{' '}
<a href="https://ico.org.uk" target="_blank" rel="noopener noreferrer">
ico.org.uk
</a>
.
</p>
<h2>How we protect your data</h2>
<p>
The site is served over HTTPS, passwords are stored only as secure hashes, login sessions
use signed, HTTP-only cookies, and login and checkout endpoints are rate-limited to block
automated attacks. Access to customer data is limited to the shop owner.
</p>
<h2>Changes to this policy</h2>
<p>
If we change how we handle your data for example, by adding analytics we&apos;ll update
this page and the date at the top before the change takes effect.
</p>
</LegalPage>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import StandardProductView from '@/components/StandardProductView';
import ProductCard from '@/components/ProductCard';
import Reviews from '@/components/Reviews';
export default async function StandardProductPage({ params }: { params: { slug: string } }) {
const activePromotions = await fetchActivePromotions();
const row = await prisma.product.findUnique({ where: { slug: params.slug } });
if (!row || !row.showOnProducts) notFound();
const product = toProductDTO(row, activePromotions);
const relatedRows = await prisma.product.findMany({
where: { category: product.category, showOnProducts: true, NOT: { id: product.id } },
take: 4,
});
const related = relatedRows.map((p) => toProductDTO(p, activePromotions));
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<StandardProductView product={product} />
<section className="mt-20 border-t border-line pt-12">
<Reviews />
</section>
{related.length > 0 && (
<section className="mt-20 border-t border-line pt-12">
<h2 className="mb-8 font-display text-3xl">You might also like</h2>
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-4">
{related.map((p) => (
<ProductCard key={p.id} product={p} hrefBase="/products" />
))}
</div>
</section>
)}
</div>
);
}
+258
View File
@@ -0,0 +1,258 @@
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import ProductCard from '@/components/ProductCard';
import type { Prisma } from '@prisma/client';
type SearchParams = {
category?: string | string[];
collection?: string | string[];
color?: string | string[];
min?: string;
max?: string;
q?: string;
};
function toArray(v?: string | string[]) {
if (!v) return [];
return Array.isArray(v) ? v : [v];
}
export default async function ReadyMadeProductsPage({ searchParams }: { searchParams: SearchParams }) {
const categoryRows = await prisma.category.findMany({
where: { showOnProducts: true },
orderBy: { name: 'asc' },
});
const CATEGORIES = categoryRows.map((c) => ({ value: c.slug, label: c.name }));
const collectionRows = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
const OCCASIONS = collectionRows.filter((c) => c.group === 'OCCASION').map((c) => ({ value: c.slug, label: c.name }));
const RECIPIENTS = collectionRows.filter((c) => c.group === 'RECIPIENT').map((c) => ({ value: c.slug, label: c.name }));
const selectedCategories = toArray(searchParams.category);
const selectedCollections = toArray(searchParams.collection);
const selectedColors = toArray(searchParams.color);
const q = searchParams.q?.trim() ?? '';
const minDollars = searchParams.min ? Number(searchParams.min) : undefined;
const maxDollars = searchParams.max ? Number(searchParams.max) : undefined;
// Full palette across ready-made products only, for the swatch filter.
const allProducts = await prisma.product.findMany({
where: { showOnProducts: true },
select: { colors: true },
});
const paletteSet = new Set<string>();
for (const p of allProducts) {
(JSON.parse(p.colors) as string[]).forEach((c) => paletteSet.add(c));
}
const palette = Array.from(paletteSet);
const where: Prisma.ProductWhereInput = { showOnProducts: true };
if (selectedCategories.length) {
where.category = { in: selectedCategories };
}
if (selectedCollections.length) {
where.collections = { some: { slug: { in: selectedCollections } } };
}
if (selectedColors.length) {
where.AND = [{ OR: selectedColors.map((c) => ({ colors: { contains: c } })) }];
}
if (q) {
where.name = { contains: q };
}
if (minDollars !== undefined || maxDollars !== undefined) {
where.basePrice = {
...(minDollars !== undefined ? { gte: Math.round(minDollars * 100) } : {}),
...(maxDollars !== undefined ? { lte: Math.round(maxDollars * 100) } : {}),
};
}
const activePromotions = await fetchActivePromotions();
const rows = await prisma.product.findMany({ where, orderBy: { createdAt: 'asc' } });
const products = rows.map((p) => toProductDTO(p, activePromotions));
const hasFilters =
selectedCategories.length > 0 ||
selectedCollections.length > 0 ||
selectedColors.length > 0 ||
q ||
minDollars !== undefined ||
maxDollars !== undefined;
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<div className="mb-8 flex items-baseline justify-between">
<h1 className="font-display text-4xl">Products</h1>
<p className="tag-label">
{products.length} item{products.length === 1 ? '' : 's'}
</p>
</div>
{products.length === 0 && !hasFilters ? (
<div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">Nothing here yet</p>
<p className="mt-2 text-sm text-muted">
Ready-made items will show up here once added check Admin Add product and enable
&quot;Products catalog&quot;.
</p>
</div>
) : (
<form method="GET" className="grid gap-10 md:grid-cols-[220px_1fr]">
{/* Sidebar filters */}
<aside className="space-y-8">
<div>
<label htmlFor="q" className="tag-label mb-2 block">
Search
</label>
<input
id="q"
name="q"
defaultValue={q}
placeholder="Search products…"
className="w-full border border-line px-3 py-2 text-sm focus:border-ink"
/>
</div>
<div>
<p className="tag-label mb-3">Category</p>
<div className="space-y-2">
{CATEGORIES.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="category"
value={c.value}
defaultChecked={selectedCategories.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
{OCCASIONS.length > 0 && (
<div>
<p className="tag-label mb-3">Occasion</p>
<div className="space-y-2">
{OCCASIONS.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collection"
value={c.value}
defaultChecked={selectedCollections.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
)}
{RECIPIENTS.length > 0 && (
<div>
<p className="tag-label mb-3">Gifts for</p>
<div className="space-y-2">
{RECIPIENTS.map((c) => (
<label key={c.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collection"
value={c.value}
defaultChecked={selectedCollections.includes(c.value)}
className="h-4 w-4 accent-splash-pink"
/>
{c.label}
</label>
))}
</div>
</div>
)}
<div>
<p className="tag-label mb-3">Color</p>
<div className="flex flex-wrap gap-2">
{palette.map((hex) => {
const checked = selectedColors.includes(hex);
return (
<label key={hex} className="cursor-pointer">
<input
type="checkbox"
name="color"
value={hex}
defaultChecked={checked}
className="peer sr-only"
/>
<span
title={hex}
className={`block h-8 w-8 rounded-full border transition-transform peer-checked:scale-110 peer-checked:border-ink peer-checked:ring-2 peer-checked:ring-ink peer-checked:ring-offset-2 peer-checked:ring-offset-paper ${
checked ? 'border-ink' : 'border-line'
}`}
style={{ backgroundColor: hex }}
/>
</label>
);
})}
</div>
</div>
<div>
<p className="tag-label mb-3">Price</p>
<div className="flex items-center gap-2">
<input
type="number"
name="min"
min={0}
placeholder="Min"
defaultValue={searchParams.min ?? ''}
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
/>
<span className="text-muted"></span>
<input
type="number"
name="max"
min={0}
placeholder="Max"
defaultValue={searchParams.max ?? ''}
className="w-full border border-line px-2 py-2 text-sm focus:border-ink"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<button
type="submit"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Apply filters
</button>
{hasFilters && (
<a href="/products" className="text-center text-sm text-muted hover:text-ink">
Clear filters
</a>
)}
</div>
</aside>
{/* Grid */}
<div>
{products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">No products match those filters</p>
<p className="mt-2 text-sm text-muted">Try widening your price range or clearing a filter.</p>
</div>
) : (
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<ProductCard key={p.id} product={p} hrefBase="/products" />
))}
</div>
)}
</div>
</form>
)}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { toProofDTO } from '@/lib/product';
import ApproveSection from '@/components/ApproveSection';
import Chat from '@/components/Chat';
import Price from '@/components/Price';
export default async function ProofPage({ params }: { params: { id: string } }) {
const row = await prisma.designProof.findUnique({
where: { id: params.id },
include: { product: true },
});
if (!row) notFound();
const proof = toProofDTO(row);
return (
<div className="mx-auto max-w-4xl px-6 py-14">
<p className="tag-label text-splash-pink">Your design is ready to review</p>
<h1 className="mt-2 font-display text-4xl">{proof.productName}</h1>
{proof.customerName && <p className="mt-1 text-muted">Hi {proof.customerName} here's what we made.</p>}
<div className="mt-10 grid gap-10 md:grid-cols-2">
<div className="border border-line bg-surface p-6">
{proof.imageDataUrl ? (
<img src={proof.imageDataUrl} alt={`${proof.productName} design`} className="w-full object-contain" />
) : (
<p className="text-center text-sm text-muted">No image was uploaded with this design.</p>
)}
</div>
<div className="flex flex-col gap-6">
{proof.note && (
<div className="border-l-2 border-splash-pink pl-4 text-sm italic text-ink">"{proof.note}"</div>
)}
<div className="space-y-2 text-sm">
<div className="flex justify-between border-b border-line pb-2">
<span className="text-muted">Color</span>
<span className="flex items-center gap-2">
<span
className="h-4 w-4 rounded-full border border-line"
style={{ backgroundColor: proof.color }}
/>
{proof.color}
</span>
</div>
<div className="flex justify-between border-b border-line pb-2">
<span className="text-muted">Quantity</span>
<span>{proof.quantity}</span>
</div>
<div className="flex justify-between border-b border-line pb-2">
<span className="text-muted">Price</span>
<span className="font-mono"><Price cents={proof.unitPrice * proof.quantity} /></span>
</div>
</div>
<ApproveSection proof={proof} />
<p className="text-center text-sm text-muted">
Not quite right? Send a message below and we'll fix it up.
</p>
</div>
</div>
<div className="mt-10">
<Chat proofId={proof.id} role="CUSTOMER" otherPartyLabel="Craft2Prints" />
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
export default function ReadyMadePage() {
return (
<div className="mx-auto max-w-3xl px-6 py-20 text-center">
<p className="tag-label text-splash-pink">Ready-made</p>
<h1 className="mt-2 font-display text-4xl">Coming soon</h1>
<p className="mx-auto mt-4 max-w-md text-muted">
This is where ready-to-buy items no personalization needed will live. We&apos;re still
setting this section up.
</p>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import Link from 'next/link';
import LegalPage from '@/components/LegalPage';
export const metadata = { title: 'Returns & Refunds — Craft2Prints' };
export default function ReturnsPage() {
return (
<LegalPage tag="Legal" title="Returns & Refunds" lastUpdated="13 July 2026">
<h2>The short version</h2>
<ul>
<li>
<strong>Faulty or wrong items</strong> always our problem to fix, personalised or not.
Full refund or free replacement.
</li>
<li>
<strong>Ready-made items</strong> you can change your mind within 14 days of delivery.
</li>
<li>
<strong>Personalised items</strong> made just for you, so they can&apos;t be returned
for a change of mind. Please check your design preview carefully before ordering.
</li>
</ul>
<h2>Faulty, damaged, or incorrect items</h2>
<p>
If your item arrives damaged, misprinted (different from the design preview you approved),
or isn&apos;t what you ordered, <Link href="/contact">contact us</Link> within 30 days of
delivery with your order details and a photo of the problem. We&apos;ll send a free
replacement or give you a full refund, including delivery your choice. This applies to
every item we sell, personalised or not, and is your right under the Consumer Rights Act
2015.
</p>
<h2>Changed your mind? (ready-made items)</h2>
<ul>
<li>
You can cancel an order for ready-made (non-personalised) items up to 14 days after the
day you receive them no reason needed. This is your right under the Consumer Contracts
Regulations 2013.
</li>
<li>
Tell us within those 14 days via the <Link href="/contact">contact form</Link> or{' '}
<a href="mailto:sales@craft2prints.co.uk">sales@craft2prints.co.uk</a>, then send the
items back within 14 days of telling us. Return postage is your responsibility unless the
item is faulty.
</li>
<li>
Items should come back unworn, unwashed, and in their original condition. We may reduce
the refund if an item has been used beyond what&apos;s needed to inspect it.
</li>
<li>
We&apos;ll refund you (including standard outbound delivery) within 14 days of receiving
the items back, to your original payment method.
</li>
</ul>
<h2>Personalised items</h2>
<p>
Items printed with your own text, images, or design are made to your specification, so the
14-day change-of-mind right doesn&apos;t apply to them this is a standard exemption under
UK law for personalised goods. That&apos;s why we show your exact print preview in the bag
before checkout: what&apos;s in the preview, spelling and all, is what gets printed. If we
made a mistake the print differs from your approved preview, or the item is faulty the
&quot;faulty items&quot; section above applies in full and we&apos;ll make it right.
</p>
<h2>How refunds are paid</h2>
<p>
All refunds go back to the payment method you used at checkout, handled by Stripe. Once
we&apos;ve processed a refund it typically appears in your account within 510 working
days, depending on your bank.
</p>
<p>
Anything unclear, or a problem not covered here? <Link href="/contact">Get in touch</Link>{' '}
we&apos;re a small business and we&apos;d always rather sort it out directly.
</p>
</LegalPage>
);
}
+115
View File
@@ -0,0 +1,115 @@
import Link from 'next/link';
import LegalPage from '@/components/LegalPage';
export const metadata = { title: 'Terms & Conditions — Craft2Prints' };
export default function TermsPage() {
return (
<LegalPage tag="Legal" title="Terms & Conditions" lastUpdated="13 July 2026">
<h2>About us</h2>
<p>
These terms apply to every order placed at craft2prints.co.uk. Craft2Prints is a UK-based
sole trader business. You can contact us at{' '}
<a href="mailto:sales@craft2prints.co.uk">sales@craft2prints.co.uk</a> or by post at
[BUSINESS ADDRESS]. By placing an order you agree to these terms please also read our{' '}
<Link href="/returns">Returns &amp; Refunds policy</Link> and{' '}
<Link href="/privacy">Privacy Policy</Link>.
</p>
<h2>Ordering</h2>
<ul>
<li>
Placing an order is an offer to buy. A contract is formed when your payment is accepted
and we confirm the order.
</li>
<li>
Personalised items are printed after you order, exactly as shown in your design preview
including spelling, wording, and placement. Please check your preview carefully before
checking out; what you approve is what we print.
</li>
<li>
For designs we prepare for you (custom photo requests), we&apos;ll send you a proof to
review first. Approving the proof counts as approving the final design for print.
</li>
<li>
We may decline or cancel an order at our discretion for example if a design breaches
the content rules below, an item is unavailable, or there was an obvious pricing error.
If we cancel, you&apos;ll receive a full refund.
</li>
</ul>
<h2>Your designs and uploads</h2>
<ul>
<li>
You keep ownership of anything you upload. You grant us permission to use it solely to
produce, preview, and fulfil your order.
</li>
<li>
By uploading an image or text, you confirm you own it or have the right to use it
including photographs, logos, characters, and brand names. You are responsible for any
claim that a design you supplied infringes someone else&apos;s rights.
</li>
<li>
We won&apos;t print material that is illegal, hateful, or that we reasonably believe
infringes someone else&apos;s intellectual property, and we may cancel (and refund) such
orders.
</li>
</ul>
<h2>Prices and payment</h2>
<ul>
<li>
All prices are in pounds sterling (GBP) and you will be charged in GBP. Prices shown in
other currencies are approximate conversions for convenience only.
</li>
<li>
The price charged is the listed price (including any active sale or promotion) at the
moment you check out, calculated on our servers.
</li>
<li>
Payment is taken securely by Stripe at checkout. We never see or store your card details.
</li>
</ul>
<h2>Delivery</h2>
<ul>
<li>
Personalised items are made to order, so please allow production time before dispatch.
Any timescales shown are our best estimates, not guarantees.
</li>
<li>
If your order hasn&apos;t arrived within a reasonable time of the estimate,{' '}
<Link href="/contact">contact us</Link> and we&apos;ll put it right.
</li>
</ul>
<h2>Returns and faulty items</h2>
<p>
Your rights differ between personalised and ready-made items see the full{' '}
<Link href="/returns">Returns &amp; Refunds policy</Link>. Nothing in these terms affects
your statutory rights as a UK consumer.
</p>
<h2>Our liability</h2>
<p>
If we get something wrong, our liability to you is limited to the amount you paid for the
order, except where the law doesn&apos;t allow liability to be limited (for example, for
death or personal injury caused by negligence, or fraud). We aren&apos;t responsible for
losses that weren&apos;t reasonably foreseeable when the order was placed.
</p>
<h2>General</h2>
<ul>
<li>
We may update these terms from time to time; the version published when you order is the
one that applies to that order.
</li>
<li>
These terms are governed by the law of England and Wales, and any dispute belongs to the
courts of England and Wales though we&apos;d much rather you{' '}
<Link href="/contact">talk to us</Link> first.
</li>
</ul>
</LegalPage>
);
}
+82
View File
@@ -0,0 +1,82 @@
'use client';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import { logoutCustomer } from '@/app/account/actions';
type AccountMenuProps = {
customer: { name: string | null; email: string } | null;
};
export default function AccountMenu({ customer }: AccountMenuProps) {
const [open, setOpen] = useState(false);
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, []);
if (!customer) {
return (
<Link href="/account/login" className="tag-label hover:text-ink">
Login
</Link>
);
}
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="tag-label flex items-center gap-1 hover:text-ink"
>
{customer.name ?? 'Account'}
<span className={`inline-block transition-transform ${open ? 'rotate-180' : ''}`}></span>
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-48 border border-line bg-paper shadow-sm">
<Link href="/account" onClick={() => setOpen(false)} className="block px-4 py-3 text-sm hover:bg-surface">
My account
</Link>
<button
onClick={() => setShowLogoutConfirm(true)}
className="block w-full px-4 py-3 text-left text-sm hover:bg-surface"
>
Log out
</button>
</div>
)}
{showLogoutConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-96 border border-line bg-paper p-6 shadow-lg">
<h2 className="font-display text-lg">Log out?</h2>
<p className="mt-3 text-sm text-muted">
Your cart will be saved. You&apos;ll need to log in again to complete your order.
</p>
<div className="mt-6 flex gap-3">
<button
onClick={() => setShowLogoutConfirm(false)}
className="flex-1 border border-line px-4 py-2 text-sm hover:bg-surface"
>
Cancel
</button>
<form action={logoutCustomer} className="flex-1">
<button type="submit" className="w-full bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay-dark">
Log out
</button>
</form>
</div>
</div>
</div>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import Link from 'next/link';
const TABS = [
{ href: '/admin/accounts', label: 'Ledger' },
{ href: '/admin/accounts/stock', label: 'Stock' },
{ href: '/admin/accounts/purchases', label: 'Purchases' },
] as const;
export default function AccountsNav({ active }: { active: (typeof TABS)[number]['href'] }) {
return (
<div className="mt-4 inline-flex border border-line">
{TABS.map((tab) => (
<Link
key={tab.href}
href={tab.href}
className={`px-4 py-1.5 text-sm ${active === tab.href ? 'bg-ink text-paper' : 'hover:text-clay'}`}
>
{tab.label}
</Link>
))}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
'use client';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import { logoutAdmin } from '@/app/admin/login/actions';
const ADMIN_LINKS = [
{ href: '/admin/orders', label: 'Orders' },
{ href: '/admin/accounts', label: 'Accounts' },
{ href: '/admin/products/new', label: 'Add product' },
{ href: '/admin/products', label: 'All products' },
{ href: '/admin/categories/new', label: 'Add category' },
{ href: '/admin/categories', label: 'All categories' },
{ href: '/admin/proofs/new', label: 'Upload new design' },
{ href: '/admin/proofs', label: 'All proofs' },
{ href: '/admin/inbox', label: 'Inbox' },
{ href: '/admin/custom-requests', label: 'Photo requests' },
{ href: '/admin/promotions', label: 'Promotions' },
{ href: '/admin/gallery', label: 'Gallery' },
{ href: '/admin/gallery/categories', label: 'Gallery categories' },
{ href: '/admin/collections/new', label: 'Add collection' },
{ href: '/admin/collections', label: 'All collections' },
{ href: '/admin/customers', label: 'Email customers' },
{ href: '/admin/maintenance', label: 'Maintenance' },
];
export default function AdminMenu() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, []);
return (
<div ref={ref} className="relative hidden md:block">
<button
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="tag-label flex items-center gap-1 hover:text-ink"
>
Admin
<span className={`inline-block transition-transform ${open ? 'rotate-180' : ''}`}></span>
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-48 border border-line bg-paper shadow-sm">
{ADMIN_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setOpen(false)}
className="block px-4 py-3 text-sm hover:bg-surface"
>
{link.label}
</Link>
))}
<form action={logoutAdmin}>
<button type="submit" className="block w-full px-4 py-3 text-left text-sm hover:bg-surface">
Log out
</button>
</form>
</div>
)}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import Link from 'next/link';
export default function AdminNav() {
return (
<div className="mb-8 flex flex-wrap gap-6 border-b border-line pb-4">
<Link href="/admin/orders" className="tag-label hover:text-ink">
Orders
</Link>
<Link href="/admin/products/new" className="tag-label hover:text-ink">
Add product
</Link>
<Link href="/admin/products" className="tag-label hover:text-ink">
All products
</Link>
<Link href="/admin/categories/new" className="tag-label hover:text-ink">
Add category
</Link>
<Link href="/admin/categories" className="tag-label hover:text-ink">
All categories
</Link>
<Link href="/admin/proofs/new" className="tag-label hover:text-ink">
Upload new design
</Link>
<Link href="/admin/proofs" className="tag-label hover:text-ink">
All proofs
</Link>
<Link href="/admin/inbox" className="tag-label hover:text-ink">
Inbox
</Link>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { v4 as uuid } from 'uuid';
import { useCart } from '@/lib/cartStore';
import type { ProofDTO } from '@/lib/types';
export default function ApproveSection({ proof }: { proof: ProofDTO }) {
const [status, setStatus] = useState<ProofDTO['status']>(proof.status);
const [loading, setLoading] = useState(false);
const addItem = useCart((s) => s.addItem);
const router = useRouter();
const approve = async () => {
setLoading(true);
try {
await fetch(`/api/proofs/${proof.id}/approve`, { method: 'POST' });
addItem({
cartItemId: uuid(),
productId: proof.productId,
slug: proof.productSlug,
name: proof.productName,
mockup: '',
color: proof.color,
size: null,
quantity: proof.quantity,
unitPrice: proof.unitPrice,
designJson: { front: [], back: [] },
designPreviewUrl: proof.imageDataUrl,
designPreviewUrlBack: null,
placementPreviewUrl: proof.imageDataUrl,
placementPreviewUrlBack: null,
});
setStatus('APPROVED');
router.push('/cart');
} finally {
setLoading(false);
}
};
if (status === 'APPROVED') {
return (
<div className="border border-splash-blue bg-splash-blue/5 p-4 text-sm text-ink">
Approved this design is in your cart.
</div>
);
}
return (
<button
onClick={approve}
disabled={loading}
className="w-full bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark disabled:opacity-60"
>
{loading ? 'Adding to cart…' : 'Approve & add to bag'}
</button>
);
}
+25
View File
@@ -0,0 +1,25 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useCart } from '@/lib/cartStore';
export default function CartButton() {
// Avoid hydration mismatch: item count only read after mount.
const [mounted, setMounted] = useState(false);
const items = useCart((s) => s.items);
useEffect(() => setMounted(true), []);
const count = mounted ? items.reduce((n, i) => n + i.quantity, 0) : 0;
return (
<Link
href="/cart"
className="group flex items-center gap-2 border border-ink px-4 py-2 text-sm transition-colors hover:border-clay hover:bg-clay hover:text-paper"
>
<span className="font-mono">Bag</span>
<span className="font-mono tabular-nums">({count})</span>
</Link>
);
}
+126
View File
@@ -0,0 +1,126 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import type { MessageDTO } from '@/lib/types';
const POLL_MS = 4000;
function formatTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
export default function Chat({
proofId,
role,
otherPartyLabel,
}: {
proofId: string;
role: 'ADMIN' | 'CUSTOMER';
otherPartyLabel: string;
}) {
const [messages, setMessages] = useState<MessageDTO[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const res = await fetch(`/api/proofs/${proofId}/messages`);
if (!res.ok || cancelled) return;
const data: MessageDTO[] = await res.json();
if (!cancelled) setMessages(data);
} catch {
// silently retry on next poll
}
};
load();
const interval = setInterval(load, POLL_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [proofId]);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [messages.length]);
const send = async () => {
const trimmed = input.trim();
if (!trimmed || sending) return;
setSending(true);
setInput('');
try {
const res = await fetch(`/api/proofs/${proofId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sender: role, body: trimmed }),
});
if (res.ok) {
const created: MessageDTO = await res.json();
setMessages((prev) => [...prev, created]);
}
} finally {
setSending(false);
}
};
return (
<div className="border border-line bg-surface">
<div className="border-b border-line px-4 py-3">
<p className="tag-label">Chat with {otherPartyLabel}</p>
</div>
<div ref={scrollRef} className="max-h-72 space-y-3 overflow-y-auto p-4">
{messages.length === 0 ? (
<p className="text-sm text-muted">No messages yet say hello.</p>
) : (
messages.map((m) => {
const mine = m.sender === role;
return (
<div key={m.id} className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[75%] ${mine ? 'bg-clay text-paper' : 'bg-paper border border-line text-ink'} px-3 py-2`}>
<p className="text-sm">{m.body}</p>
<p className={`mt-1 text-[10px] ${mine ? 'text-paper/60' : 'text-muted'}`}>
{formatTime(m.createdAt)}
</p>
</div>
</div>
);
})
)}
</div>
<div className="flex gap-2 border-t border-line p-3">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
send();
}
}}
placeholder="Type a message…"
className="flex-1 border border-line px-3 py-2 text-sm"
/>
<button
onClick={send}
disabled={sending || !input.trim()}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper disabled:opacity-50"
>
Send
</button>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
// A small decorative 5-petal blossom — plain flat SVG, no assets, matches the
// hand-drawn look of ProductMockup.tsx. Purely decorative: aria-hidden, no
// interactivity needed.
export default function CherryBlossom({
className = '',
size = 32,
style,
}: {
className?: string;
size?: number;
style?: React.CSSProperties;
}) {
return (
<svg viewBox="0 0 40 40" width={size} height={size} className={className} style={style} aria-hidden>
<g fill="#E5227E">
{[0, 72, 144, 216, 288].map((angle) => (
<ellipse key={angle} cx="20" cy="11" rx="5.5" ry="8" transform={`rotate(${angle} 20 20)`} />
))}
</g>
<circle cx="20" cy="20" r="2.5" fill="#F5871F" />
</svg>
);
}
+12
View File
@@ -0,0 +1,12 @@
'use client';
import { useEffect } from 'react';
import { useCart } from '@/lib/cartStore';
export default function ClearCartOnMount() {
const clear = useCart((s) => s.clear);
useEffect(() => {
clear();
}, [clear]);
return null;
}
+313
View File
@@ -0,0 +1,313 @@
'use client';
import { useEffect, useRef, useState } from 'react';
function frontFieldName(hex: string) {
return `colorPhoto__${hex.replace('#', '')}`;
}
function backFieldName(hex: string) {
return `colorPhotoBack__${hex.replace('#', '')}`;
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new window.Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
// Samples the photo on an offscreen canvas and averages the non-background
// pixels — product photos are typically shot on a plain white/light backdrop,
// so pixels that are both very light and low-saturation (the backdrop) are
// excluded, leaving mostly garment pixels to average into one hex color.
async function detectDominantColor(file: File): Promise<string> {
const objectUrl = URL.createObjectURL(file);
try {
const img = await loadImage(objectUrl);
const size = 120;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return '#808080';
ctx.drawImage(img, 0, 0, size, size);
const { data } = ctx.getImageData(0, 0, size, size);
let r = 0;
let g = 0;
let b = 0;
let count = 0;
for (let i = 0; i < data.length; i += 4) {
const pr = data[i];
const pg = data[i + 1];
const pb = data[i + 2];
const pa = data[i + 3];
if (pa < 200) continue;
const max = Math.max(pr, pg, pb);
const min = Math.min(pr, pg, pb);
const lightness = (max + min) / 2 / 255;
const sat = max === min ? 0 : (max - min) / (255 - Math.abs(max + min - 255));
if (lightness > 0.92 && sat < 0.12) continue; // near-white/gray backdrop
r += pr;
g += pg;
b += pb;
count += 1;
}
if (count === 0) {
r = data[0];
g = data[1];
b = data[2];
count = 1;
}
r = Math.round(r / count);
g = Math.round(g / count);
b = Math.round(b / count);
return `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`;
} finally {
URL.revokeObjectURL(objectUrl);
}
}
function hexDistance(a: string, b: string) {
const [ar, ag, ab] = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16));
const [br, bg, bb] = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16));
return Math.sqrt((ar - br) ** 2 + (ag - bg) ** 2 + (ab - bb) ** 2);
}
// Snaps a freshly-detected color onto an existing one if they're close
// (different lighting/JPEG noise shouldn't produce two near-identical
// swatches for what's really the same shirt color).
function findNearbyColor(hex: string, existing: string[], threshold = 30) {
return existing.find((c) => hexDistance(c, hex) < threshold) ?? null;
}
type BulkResult = { filename: string; hex: string; variant: 'front' | 'back' };
export default function ColorPicker({
name,
defaultColors = [],
defaultColorPhotos = {},
defaultColorPhotosBack = {},
}: {
name: string;
defaultColors?: string[];
defaultColorPhotos?: Record<string, string>;
defaultColorPhotosBack?: Record<string, string>;
}) {
const [colors, setColors] = useState<string[]>(defaultColors.length ? defaultColors : ['#17181C']);
const [picked, setPicked] = useState('#1EA7E0');
// Local previews for newly-picked files, so admins see what they just attached.
const [previews, setPreviews] = useState<Record<string, string>>({});
const [previewsBack, setPreviewsBack] = useState<Record<string, string>>({});
const bulkInputRef = useRef<HTMLInputElement | null>(null);
const fileInputRefs = useRef<Map<string, HTMLInputElement>>(new Map());
const pendingFilesRef = useRef<Map<string, File>>(new Map());
const [bulkBusy, setBulkBusy] = useState(false);
const [bulkResults, setBulkResults] = useState<BulkResult[]>([]);
const addColor = () => {
if (colors.includes(picked)) return;
setColors((prev) => [...prev, picked]);
};
const removeColor = (c: string) => {
setColors((prev) => prev.filter((x) => x !== c));
};
const handlePhotoChange = (hex: string, file: File | null, back: boolean) => {
const setter = back ? setPreviewsBack : setPreviews;
if (!file) {
setter((prev) => {
const next = { ...prev };
delete next[hex];
return next;
});
return;
}
setter((prev) => ({ ...prev, [hex]: URL.createObjectURL(file) }));
};
// Once a pending bulk-detected file's color has a rendered <input> (i.e. its
// color row just mounted), push the File into that native input via
// DataTransfer and fire the same change handler a manual pick would — so
// the preview and the real file both update, and the form submits it
// exactly like any other per-color upload.
useEffect(() => {
if (pendingFilesRef.current.size === 0) return;
for (const [key, file] of pendingFilesRef.current) {
const input = fileInputRefs.current.get(key);
if (!input) continue;
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
const [hex, variant] = key.split('__');
handlePhotoChange(hex, file, variant === 'back');
pendingFilesRef.current.delete(key);
}
}, [colors]);
const handleBulkFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return;
setBulkBusy(true);
setBulkResults([]);
const results: BulkResult[] = [];
const nextColors = [...colors];
for (const file of Array.from(files)) {
if (!file.type.startsWith('image/')) continue;
const variant: 'front' | 'back' = /back/i.test(file.name) ? 'back' : 'front';
const detected = await detectDominantColor(file);
const hex = findNearbyColor(detected, nextColors) ?? detected;
if (!nextColors.includes(hex)) nextColors.push(hex);
const key = `${hex}__${variant}`;
pendingFilesRef.current.set(key, file);
results.push({ filename: file.name, hex, variant });
}
setColors(nextColors);
setBulkResults(results);
setBulkBusy(false);
if (bulkInputRef.current) bulkInputRef.current.value = '';
};
return (
<div>
<input type="hidden" name={name} value={colors.join(',')} />
<div className="mb-4 border border-line bg-surface p-3">
<p className="tag-label mb-1">Bulk upload from a folder</p>
<p className="mb-2 text-xs text-muted">
Point at a folder of T-shirt photos and each one is scanned to detect the shirt&apos;s
color and added automatically. Name a file with &quot;back&quot; in it (e.g.{' '}
<code>navy-back.jpg</code>) to attach it as that color&apos;s back photo instead of front.
</p>
<input
ref={bulkInputRef}
type="file"
accept="image/*"
multiple
// @ts-expect-error non-standard attributes for folder selection, supported in Chromium/Firefox
webkitdirectory=""
directory=""
onChange={(e) => handleBulkFiles(e.target.files)}
className="text-xs"
/>
{bulkBusy && <p className="mt-2 text-xs text-muted">Scanning images</p>}
{bulkResults.length > 0 && !bulkBusy && (
<div className="mt-3 space-y-1">
{bulkResults.map((r, i) => (
<div key={`${r.filename}-${i}`} className="flex items-center gap-2 text-xs">
<div className="h-4 w-4 shrink-0 rounded-full border border-line" style={{ backgroundColor: r.hex }} />
<span className="font-mono text-muted">{r.hex}</span>
<span className="truncate text-muted">{r.filename}</span>
<span className="tag-label shrink-0 text-muted">{r.variant}</span>
</div>
))}
</div>
)}
</div>
<div className="flex flex-wrap gap-3">
{colors.map((c) => (
<div key={c} className="group relative">
<div
className="h-10 w-10 rounded-full border border-line"
style={{ backgroundColor: c }}
title={c}
/>
<button
type="button"
onClick={() => removeColor(c)}
aria-label={`Remove ${c}`}
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full border border-line bg-paper text-xs opacity-0 transition-opacity group-hover:opacity-100 hover:border-splash-pink hover:text-splash-pink"
>
×
</button>
</div>
))}
</div>
<div className="mt-3 flex items-center gap-3">
<input
type="color"
value={picked}
onChange={(e) => setPicked(e.target.value)}
className="h-9 w-9 cursor-pointer border border-line bg-paper p-0"
/>
<span className="font-mono text-xs text-muted">{picked}</span>
<button
type="button"
onClick={addColor}
className="border border-ink px-3 py-1.5 text-xs hover:border-clay hover:bg-clay hover:text-paper"
>
+ Add color
</button>
</div>
{colors.length === 0 && <p className="mt-2 text-xs text-splash-pink">Add at least one color.</p>}
{colors.length > 0 && (
<div className="mt-5 border-t border-line pt-4">
<p className="tag-label mb-1">Photo per color (optional)</p>
<p className="mb-3 text-xs text-muted">
Attach the real front and/or back photo for a color and it&apos;ll be shown exactly as-is
when a customer picks that color more accurate than tinting one photo. Leave either
blank to fall back to the product&apos;s main front/back photo (or the illustration).
</p>
<div className="space-y-3">
{colors.map((hex) => {
const preview = previews[hex] ?? defaultColorPhotos[hex];
const previewBack = previewsBack[hex] ?? defaultColorPhotosBack[hex];
return (
<div key={hex} className="border border-line p-2">
<div className="mb-2 flex items-center gap-2">
<div className="h-6 w-6 shrink-0 rounded-full border border-line" style={{ backgroundColor: hex }} />
<span className="font-mono text-xs text-muted">{hex}</span>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="flex items-center gap-2">
<span className="tag-label w-10 shrink-0">Front</span>
{preview && (
<img src={preview} alt={`${hex} front preview`} className="h-9 w-9 shrink-0 border border-line object-cover" />
)}
<input
ref={(el) => {
if (el) fileInputRefs.current.set(`${hex}__front`, el);
}}
type="file"
accept="image/*"
name={frontFieldName(hex)}
onChange={(e) => handlePhotoChange(hex, e.target.files?.[0] ?? null, false)}
className="min-w-0 flex-1 text-xs"
/>
</div>
<div className="flex items-center gap-2">
<span className="tag-label w-10 shrink-0">Back</span>
{previewBack && (
<img src={previewBack} alt={`${hex} back preview`} className="h-9 w-9 shrink-0 border border-line object-cover" />
)}
<input
ref={(el) => {
if (el) fileInputRefs.current.set(`${hex}__back`, el);
}}
type="file"
accept="image/*"
name={backFieldName(hex)}
onChange={(e) => handlePhotoChange(hex, e.target.files?.[0] ?? null, true)}
className="min-w-0 flex-1 text-xs"
/>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
'use client';
import { useState } from 'react';
export default function CopyLink({ link }: { link: string }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
await navigator.clipboard.writeText(link);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
};
return (
<div className="mt-6 flex items-center gap-2">
<input
readOnly
value={link}
onFocus={(e) => e.target.select()}
className="w-full border border-line px-3 py-2 font-mono text-xs"
/>
<button
onClick={copy}
className="whitespace-nowrap border border-ink px-4 py-2 text-sm hover:bg-ink hover:text-paper"
>
{copied ? 'Copied' : 'Copy link'}
</button>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useCurrency } from '@/lib/CurrencyContext';
import { CURRENCIES } from '@/lib/currency';
export default function CurrencySelector() {
const { currency, setCurrency } = useCurrency();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, []);
const active = CURRENCIES.find((c) => c.code === currency) ?? CURRENCIES[0];
return (
<div ref={ref} className="relative hidden md:block">
<button
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="tag-label flex items-center gap-1 hover:text-ink"
>
{active.symbol} {active.code}
<span className={`inline-block transition-transform ${open ? 'rotate-180' : ''}`}></span>
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-44 border border-line bg-paper shadow-sm">
{CURRENCIES.map((c) => (
<button
key={c.code}
onClick={() => {
setCurrency(c.code);
setOpen(false);
}}
className={`block w-full px-4 py-3 text-left text-sm hover:bg-surface ${
c.code === currency ? 'text-clay' : ''
}`}
>
{c.symbol} {c.label}
</button>
))}
</div>
)}
</div>
);
}
+866
View File
@@ -0,0 +1,866 @@
'use client';
import { useRef, useState, useEffect, useLayoutEffect, useCallback } from 'react';
import { Stage, Layer, Text, Image as KonvaImage, Transformer, Rect } from 'react-konva';
import type Konva from 'konva';
import { v4 as uuid } from 'uuid';
import ProductMockup from './ProductMockup';
import Price from './Price';
import { useCart } from '@/lib/cartStore';
import { getEffectivePrice } from '@/lib/pricing';
import type { DesignElement, PrintArea, ProductDTO } from '@/lib/types';
const DISPLAY = 560; // css px size the mockup + stage are rendered at
const CAPTURE_SCALE = 2; // matches pixelRatio used for exports, for crisp downloads
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new window.Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
// Draws `img` into the canvas region like CSS `object-fit: cover` would.
function drawCover(ctx: CanvasRenderingContext2D, img: HTMLImageElement, dw: number, dh: number) {
const imgRatio = img.width / img.height;
const boxRatio = dw / dh;
let sx: number, sy: number, sw: number, sh: number;
if (imgRatio > boxRatio) {
sh = img.height;
sw = sh * boxRatio;
sx = (img.width - sw) / 2;
sy = 0;
} else {
sw = img.width;
sh = sw / boxRatio;
sx = 0;
sy = (img.height - sh) / 2;
}
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, dw, dh);
}
// Builds a reference image showing the design placed on the actual product photo
// (garment + artwork composited together) — separate from the clean, print-ready,
// transparent-background export. Returns null if there's no real photo to composite
// onto (illustration-only products skip this; there's nothing photographic to show).
async function composePlacementImage(
photoUrl: string,
recolor: { tint: boolean; color: string } | null,
stage: Konva.Stage,
box: { left: number; top: number; w: number; h: number },
): Promise<string> {
const canvas = document.createElement('canvas');
canvas.width = DISPLAY * CAPTURE_SCALE;
canvas.height = DISPLAY * CAPTURE_SCALE;
const ctx = canvas.getContext('2d');
if (!ctx) return '';
const photoImg = await loadImage(photoUrl);
if (recolor?.tint) {
ctx.filter = 'grayscale(1) brightness(2.1) contrast(1.15)';
drawCover(ctx, photoImg, canvas.width, canvas.height);
ctx.filter = 'none';
ctx.globalCompositeOperation = 'multiply';
ctx.fillStyle = recolor.color;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.globalCompositeOperation = 'source-over';
} else {
drawCover(ctx, photoImg, canvas.width, canvas.height);
}
const designCanvas = stage.toCanvas({ pixelRatio: CAPTURE_SCALE });
ctx.drawImage(
designCanvas,
box.left * CAPTURE_SCALE,
box.top * CAPTURE_SCALE,
box.w * CAPTURE_SCALE,
box.h * CAPTURE_SCALE,
);
return canvas.toDataURL('image/png');
}
const FONTS = [
'Inter',
'Fraunces',
'Caveat',
'Playfair Display',
'Oswald',
'Bebas Neue',
'Pacifico',
'Permanent Marker',
'JetBrains Mono',
'Georgia',
'Arial',
];
function UploadedImage({
el,
onSelect,
onChange,
shapeRef,
}: {
el: Extract<DesignElement, { type: 'image' }>;
onSelect: () => void;
onChange: (attrs: Partial<DesignElement>) => void;
shapeRef: (node: Konva.Image | null) => void;
}) {
const [img, setImg] = useState<HTMLImageElement | null>(null);
useEffect(() => {
const image = new window.Image();
image.src = el.src;
image.onload = () => setImg(image);
}, [el.src]);
if (!img) return null;
return (
<KonvaImage
ref={shapeRef}
image={img}
x={el.x}
y={el.y}
width={el.width}
height={el.height}
rotation={el.rotation}
draggable
listening={true}
onClick={onSelect}
onTap={onSelect}
onDragEnd={(e) => onChange({ x: e.target.x(), y: e.target.y() })}
onTransformEnd={(e) => {
const node = e.target;
onChange({
x: node.x(),
y: node.y(),
rotation: node.rotation(),
width: Math.max(10, node.width() * node.scaleX()),
height: Math.max(10, node.height() * node.scaleY()),
});
node.scaleX(1);
node.scaleY(1);
}}
/>
);
}
// One print surface (photo + the draggable design layer on top). Front and back
// each get their own instance, both always mounted (just shown/hidden with CSS)
// so their Konva stages stay exportable even when you're looking at the other side.
function PrintSurface({
photo,
printArea,
elements,
selectedId,
setSelectedId,
updateElement,
nodeRefs,
stageRef,
transformerRef,
visible,
caption,
onNodeReady,
scale,
}: {
photo: React.ReactNode;
printArea: PrintArea;
elements: DesignElement[];
selectedId: string | null;
setSelectedId: (id: string | null) => void;
updateElement: (id: string, attrs: Partial<DesignElement>) => void;
nodeRefs: React.MutableRefObject<Map<string, Konva.Node>>;
stageRef: React.RefObject<Konva.Stage>;
transformerRef: React.RefObject<Konva.Transformer>;
visible: boolean;
caption: string;
onNodeReady: () => void;
scale: number;
}) {
const stageW = printArea.wPct * DISPLAY;
const stageH = printArea.hPct * DISPLAY;
const stageLeft = printArea.xPct * DISPLAY;
const stageTop = printArea.yPct * DISPLAY;
return (
<div className={visible ? '' : 'hidden'}>
{/* Konva's Stage stays a fixed 560x560 internally so element coordinates,
print-area math, and export resolution never change — only the visual
box shrinks via CSS transform to fit narrow viewports. */}
<div style={{ width: DISPLAY * scale, height: DISPLAY * scale }}>
<div
className="relative border border-line bg-surface"
style={{ width: DISPLAY, height: DISPLAY, transform: `scale(${scale})`, transformOrigin: 'top left' }}
>
<div className="pointer-events-none absolute inset-0" style={{ width: DISPLAY, height: DISPLAY }}>
{photo}
</div>
<div className="absolute" style={{ left: stageLeft, top: stageTop, width: stageW, height: stageH }}>
<Stage
ref={stageRef}
width={stageW}
height={stageH}
onMouseDown={(e) => {
if (e.target === e.target.getStage()) setSelectedId(null);
}}
>
<Layer>
<Rect
name="printGuide"
x={0}
y={0}
width={stageW}
height={stageH}
stroke="#E5227E"
dash={[10, 6]}
strokeWidth={3}
shadowColor="#E5227E"
shadowBlur={12}
shadowOpacity={0.5}
listening={false}
/>
<Rect
name="printGuide"
x={0}
y={0}
width={stageW}
height={stageH}
fill="#E5227E"
opacity={0.07}
listening={false}
/>
{elements.map((el) =>
el.type === 'text' ? (
<Text
key={el.id}
ref={(node) => {
if (node) nodeRefs.current.set(el.id, node);
}}
x={el.x}
y={el.y}
text={el.text}
fontFamily={el.fontFamily}
fontSize={el.fontSize}
fill={el.fill}
rotation={el.rotation}
draggable
listening={true}
onClick={() => setSelectedId(el.id)}
onTap={() => setSelectedId(el.id)}
onDragEnd={(e) => updateElement(el.id, { x: e.target.x(), y: e.target.y() })}
onTransformEnd={(e) => {
const node = e.target;
updateElement(el.id, {
x: node.x(),
y: node.y(),
rotation: node.rotation(),
fontSize: Math.max(8, el.fontSize * node.scaleX()),
});
node.scaleX(1);
node.scaleY(1);
}}
/>
) : (
<UploadedImage
key={el.id}
el={el}
onSelect={() => setSelectedId(el.id)}
onChange={(attrs) => updateElement(el.id, attrs)}
shapeRef={(node) => {
if (node && nodeRefs.current.get(el.id) !== node) {
nodeRefs.current.set(el.id, node);
onNodeReady();
}
}}
/>
),
)}
<Transformer
ref={transformerRef}
rotateEnabled
flipEnabled={false}
rotateAnchorOffset={30}
anchorCornerRadius={4}
anchorSize={8}
borderStroke="#17181C"
borderStrokeWidth={2}
anchorStroke="#17181C"
anchorFill="#FFF"
boundBoxFunc={(oldBox, newBox) => (newBox.width < 8 || newBox.height < 8 ? oldBox : newBox)}
/>
</Layer>
</Stage>
</div>
</div>
</div>
<p className="tag-label mt-3">{caption}</p>
</div>
);
}
export default function DesignCanvas({ product }: { product: ProductDTO }) {
const [color, setColor] = useState(product.colors[0]);
const [size, setSize] = useState<string | null>(product.sizes[0] ?? null);
const [view, setView] = useState<'front' | 'back'>('front');
const [elementsFront, setElementsFront] = useState<DesignElement[]>([]);
const [elementsBack, setElementsBack] = useState<DesignElement[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [quantity, setQuantity] = useState(1);
const [justAdded, setJustAdded] = useState(false);
const [nodeReadyTick, setNodeReadyTick] = useState(0);
const stageFrontRef = useRef<Konva.Stage | null>(null);
const stageBackRef = useRef<Konva.Stage | null>(null);
const transformerFrontRef = useRef<Konva.Transformer | null>(null);
const transformerBackRef = useRef<Konva.Transformer | null>(null);
const nodeRefs = useRef<Map<string, Konva.Node>>(new Map());
const fileInputRef = useRef<HTMLInputElement | null>(null);
const canvasContainerRef = useRef<HTMLDivElement | null>(null);
const [canvasScale, setCanvasScale] = useState(1);
const addItem = useCart((s) => s.addItem);
// Konva's Stage keeps a fixed 560x560 internal pixel size (element x/y,
// print-area math, and export resolution all key off DISPLAY) — on narrow
// viewports we shrink it purely visually via CSS transform instead of
// touching that coordinate system. Konva reads the container's actual
// rendered (post-transform) bounding box for pointer math, so clicks/drags
// still line up correctly at any scale.
useLayoutEffect(() => {
const el = canvasContainerRef.current;
if (!el) return;
const update = () => setCanvasScale(Math.min(1, el.clientWidth / DISPLAY));
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, []);
const hasBack = Boolean(product.imageUrlBack);
const canPersonalize = product.showOnPersonalised;
const frontPrintArea = product.printArea;
const backPrintArea = product.printAreaBack ?? product.printArea;
const activePrintArea = view === 'front' ? frontPrintArea : backPrintArea;
const activeStageW = activePrintArea.wPct * DISPLAY;
const activeStageH = activePrintArea.hPct * DISPLAY;
const setActiveElements = view === 'front' ? setElementsFront : setElementsBack;
// Keep whichever transformer actually owns the selected element in sync, and
// clear the other one — an id only ever lives in one of the two arrays.
// nodeReadyTick re-runs this once an uploaded image's Konva node actually
// mounts (it loads asynchronously, unlike text, so it isn't there yet on the
// same render where it gets selected).
useEffect(() => {
const frontNode = selectedId && elementsFront.some((e) => e.id === selectedId) ? nodeRefs.current.get(selectedId) : null;
const backNode = selectedId && elementsBack.some((e) => e.id === selectedId) ? nodeRefs.current.get(selectedId) : null;
if (transformerFrontRef.current) {
transformerFrontRef.current.nodes(frontNode ? [frontNode] : []);
transformerFrontRef.current.getLayer()?.draw();
}
if (transformerBackRef.current) {
transformerBackRef.current.nodes(backNode ? [backNode] : []);
transformerBackRef.current.getLayer()?.draw();
}
}, [selectedId, elementsFront.length, elementsBack.length, nodeReadyTick]);
// Canvas text can render with a fallback font if drawn before a web font finishes
// loading — redraw once fonts are ready so custom font choices show up correctly.
useEffect(() => {
document.fonts?.ready.then(() => {
stageFrontRef.current?.getLayers().forEach((layer) => layer.batchDraw());
stageBackRef.current?.getLayers().forEach((layer) => layer.batchDraw());
});
}, []);
const updateElement = useCallback((id: string, attrs: Partial<DesignElement>) => {
setElementsFront((prev) =>
prev.some((el) => el.id === id) ? prev.map((el) => (el.id === id ? ({ ...el, ...attrs } as DesignElement) : el)) : prev,
);
setElementsBack((prev) =>
prev.some((el) => el.id === id) ? prev.map((el) => (el.id === id ? ({ ...el, ...attrs } as DesignElement) : el)) : prev,
);
}, []);
const addText = () => {
const id = uuid();
setActiveElements((prev) => [
...prev,
{
id,
type: 'text',
x: activeStageW / 2 - 40,
y: activeStageH / 2 - 10,
rotation: 0,
text: 'Your text',
fontFamily: 'Fraunces',
fontSize: 28,
fill: '#17181C',
},
]);
setSelectedId(id);
};
const handleUploadClick = () => fileInputRef.current?.click();
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const src = reader.result as string;
const img = new window.Image();
img.onload = () => {
const maxDim = Math.min(activeStageW, activeStageH) * 0.7;
const scale = Math.min(maxDim / img.width, maxDim / img.height, 1);
const id = uuid();
setActiveElements((prev) => [
...prev,
{
id,
type: 'image',
x: activeStageW / 2 - (img.width * scale) / 2,
y: activeStageH / 2 - (img.height * scale) / 2,
rotation: 0,
width: img.width * scale,
height: img.height * scale,
src,
},
]);
setSelectedId(id);
};
img.src = src;
};
reader.readAsDataURL(file);
e.target.value = '';
};
const deleteSelected = () => {
if (!selectedId) return;
setElementsFront((prev) => prev.filter((el) => el.id !== selectedId));
setElementsBack((prev) => prev.filter((el) => el.id !== selectedId));
setSelectedId(null);
};
const selected = elementsFront.find((el) => el.id === selectedId) ?? elementsBack.find((el) => el.id === selectedId);
// Calculate the effective price for made-to-order items (use personalisedPrice if available)
const effectivePriceForPersonalised = canPersonalize
? getEffectivePrice(
{
id: product.id,
basePrice: product.basePrice,
personalisedPrice: product.personalisedPrice,
salePrice: product.onSale ? product.effectivePrice : null,
saleStartsAt: null,
saleEndsAt: null,
},
[],
new Date(),
true,
).price
: product.effectivePrice;
const handleAddToBag = () => {
if (!canPersonalize) {
const flatPhoto = product.colorPhotos[color] ?? product.imageUrl ?? '';
addItem({
cartItemId: uuid(),
productId: product.id,
slug: product.slug,
name: product.name,
mockup: product.mockup,
color,
size,
quantity,
unitPrice: product.effectivePrice,
designJson: { front: [], back: [] },
designPreviewUrl: flatPhoto,
designPreviewUrlBack: null,
placementPreviewUrl: flatPhoto || null,
placementPreviewUrlBack: product.imageUrlBack ?? null,
});
setJustAdded(true);
setTimeout(() => setJustAdded(false), 2200);
return;
}
setSelectedId(null); // clear transformer handles before export
requestAnimationFrame(async () => {
const frontStage = stageFrontRef.current;
if (!frontStage) return;
// Hide the dashed guide box + tint before exporting, so the saved PNG is
// just the design itself — transparent background, no shirt, no guide
// marks — ready to hand off for printing.
const frontGuides = frontStage.find('.printGuide');
frontGuides.forEach((g) => g.hide());
const frontDataUrl = frontStage.toDataURL({ pixelRatio: 2 });
const frontBox = {
left: frontPrintArea.xPct * DISPLAY,
top: frontPrintArea.yPct * DISPLAY,
w: frontPrintArea.wPct * DISPLAY,
h: frontPrintArea.hPct * DISPLAY,
};
const frontPhotoInfo = getPhotoInfo('front');
const frontPlacementUrl = frontPhotoInfo
? await composePlacementImage(
frontPhotoInfo.url,
frontPhotoInfo.tint ? { tint: true, color } : null,
frontStage,
frontBox,
).catch(() => null)
: null;
frontGuides.forEach((g) => g.show());
let backDataUrl: string | null = null;
let backPlacementUrl: string | null = null;
if (hasBack && stageBackRef.current) {
const backStage = stageBackRef.current;
const backGuides = backStage.find('.printGuide');
backGuides.forEach((g) => g.hide());
backDataUrl = backStage.toDataURL({ pixelRatio: 2 });
const backBox = {
left: backPrintArea.xPct * DISPLAY,
top: backPrintArea.yPct * DISPLAY,
w: backPrintArea.wPct * DISPLAY,
h: backPrintArea.hPct * DISPLAY,
};
const backPhotoInfo = getPhotoInfo('back');
backPlacementUrl = backPhotoInfo
? await composePlacementImage(
backPhotoInfo.url,
backPhotoInfo.tint ? { tint: true, color } : null,
backStage,
backBox,
).catch(() => null)
: null;
backGuides.forEach((g) => g.show());
}
addItem({
cartItemId: uuid(),
productId: product.id,
slug: product.slug,
name: product.name,
mockup: product.mockup,
color,
size,
quantity,
unitPrice: effectivePriceForPersonalised,
designJson: { front: elementsFront, back: elementsBack },
designPreviewUrl: frontDataUrl,
designPreviewUrlBack: backDataUrl,
placementPreviewUrl: frontPlacementUrl ?? frontDataUrl,
placementPreviewUrlBack: backPlacementUrl ?? backDataUrl,
});
setJustAdded(true);
setTimeout(() => setJustAdded(false), 2200);
});
};
function renderPhoto(v: 'front' | 'back') {
const colorPhoto = v === 'front' ? product.colorPhotos[color] : product.colorPhotosBack[color];
if (colorPhoto) {
return <img src={colorPhoto} alt={`${product.name} in ${color}`} className="h-full w-full object-cover" />;
}
const activePhoto = v === 'front' ? product.imageUrl : product.imageUrlBack;
if (!activePhoto) {
return <ProductMockup mockup={product.mockup} color={color} />;
}
if (product.photoRecolorable) {
return (
<div className="relative h-full w-full">
<img
src={activePhoto}
alt={product.name}
className="h-full w-full object-cover"
style={{ filter: 'grayscale(1) brightness(2.1) contrast(1.15)' }}
/>
<div className="absolute inset-0" style={{ backgroundColor: color, mixBlendMode: 'multiply' }} />
</div>
);
}
return <img src={activePhoto} alt={product.name} className="h-full w-full object-cover" />;
}
// Same resolution logic as renderPhoto, but returns raw data instead of JSX —
// used when compositing the placement-reference image for the order record.
function getPhotoInfo(v: 'front' | 'back'): { url: string; tint: boolean } | null {
const colorPhoto = v === 'front' ? product.colorPhotos[color] : product.colorPhotosBack[color];
if (colorPhoto) return { url: colorPhoto, tint: false };
const activePhoto = v === 'front' ? product.imageUrl : product.imageUrlBack;
if (!activePhoto) return null; // illustration only — nothing photographic to composite onto
return { url: activePhoto, tint: product.photoRecolorable };
}
return (
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[560px_1fr]">
{/* Canvas */}
<div ref={canvasContainerRef} className="w-full min-w-0 max-w-[560px]">
{hasBack && (
<div className="mb-3 inline-flex border border-line">
<button
onClick={() => setView('front')}
className={`px-4 py-1.5 text-sm ${view === 'front' ? 'bg-ink text-paper' : 'hover:text-clay'}`}
>
Front
</button>
<button
onClick={() => setView('back')}
className={`px-4 py-1.5 text-sm ${view === 'back' ? 'bg-ink text-paper' : 'hover:text-clay'}`}
>
Back
</button>
</div>
)}
{canPersonalize ? (
<>
<PrintSurface
photo={renderPhoto('front')}
printArea={frontPrintArea}
elements={elementsFront}
selectedId={selectedId}
setSelectedId={setSelectedId}
updateElement={updateElement}
nodeRefs={nodeRefs}
stageRef={stageFrontRef}
transformerRef={transformerFrontRef}
visible={view === 'front'}
caption="Dashed line marks the print area"
onNodeReady={() => setNodeReadyTick((t) => t + 1)}
scale={canvasScale}
/>
{hasBack && (
<PrintSurface
photo={renderPhoto('back')}
printArea={backPrintArea}
elements={elementsBack}
selectedId={selectedId}
setSelectedId={setSelectedId}
updateElement={updateElement}
nodeRefs={nodeRefs}
stageRef={stageBackRef}
transformerRef={transformerBackRef}
visible={view === 'back'}
caption="Dashed line marks the print area (back)"
onNodeReady={() => setNodeReadyTick((t) => t + 1)}
scale={canvasScale}
/>
)}
</>
) : (
<div className="relative aspect-square w-full max-w-[560px] border border-line bg-surface">
{renderPhoto(view)}
</div>
)}
</div>
{/* Controls */}
<div className="flex flex-col gap-8">
<div>
<h1 className="font-display text-4xl">{product.name}</h1>
<p className="mt-2 text-muted">{product.description}</p>
<p className="mt-4 flex items-baseline gap-2 font-mono text-lg">
{(() => {
const { onSale, price, originalPrice } = getEffectivePrice(
{
id: product.id,
basePrice: product.basePrice,
personalisedPrice: product.personalisedPrice,
salePrice: product.onSale ? product.effectivePrice : null,
saleStartsAt: null,
saleEndsAt: null,
},
[],
new Date(),
true,
);
return (
<>
{onSale && originalPrice != null && (
<Price cents={originalPrice} className="text-base text-muted line-through" />
)}
<Price cents={price} className={onSale ? 'text-splash-pink' : undefined} />
</>
);
})()}
</p>
</div>
<div>
<p className="tag-label mb-3">Color</p>
<div className="flex flex-wrap gap-3">
{product.colors.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
aria-label={`Choose color ${c}`}
className={`h-9 w-9 rounded-full border transition-transform ${
color === c ? 'scale-110 border-ink ring-2 ring-ink ring-offset-2 ring-offset-paper' : 'border-line'
}`}
style={{ backgroundColor: c }}
/>
))}
</div>
{product.imageUrl && !product.photoRecolorable && Object.keys(product.colorPhotos).length === 0 && (
<p className="mt-2 text-xs text-muted">
This product uses a real photo, so the picture won&apos;t change color your pick is
still saved with the order.
</p>
)}
</div>
{product.sizes.length > 0 && (
<div>
<p className="tag-label mb-3">Size</p>
<div className="flex flex-wrap gap-2">
{product.sizes.map((s) => (
<button
key={s}
onClick={() => setSize(s)}
className={`min-w-[44px] border px-3 py-2 text-sm transition-colors ${
size === s
? 'border-clay bg-clay text-paper'
: 'border-line text-ink hover:border-clay hover:text-clay'
}`}
>
{s}
</button>
))}
</div>
</div>
)}
{canPersonalize && (
<div>
<p className="tag-label mb-3">
Your design {hasBack && <span className="normal-case text-muted"> editing the {view}</span>}
</p>
<div className="flex flex-wrap gap-3">
<button onClick={addText} className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper">
+ Add text
</button>
<button
onClick={handleUploadClick}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
+ Upload image
</button>
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
{selected && (
<button
onClick={deleteSelected}
className="border border-line px-4 py-2 text-sm text-muted hover:border-ink hover:text-ink"
>
Delete selected
</button>
)}
</div>
{selected?.type === 'text' && (
<div className="mt-4 flex flex-wrap items-center gap-3 border border-line bg-surface p-4">
<input
value={selected.text}
onChange={(e) => updateElement(selected.id, { text: e.target.value })}
className="border border-line bg-paper px-2 py-1 text-sm text-ink placeholder:text-muted"
placeholder="Text"
/>
<select
value={selected.fontFamily}
onChange={(e) => updateElement(selected.id, { fontFamily: e.target.value })}
className="border border-line bg-paper px-2 py-1 text-sm text-ink"
style={{ fontFamily: selected.fontFamily }}
>
{FONTS.map((f) => (
<option key={f} value={f} style={{ fontFamily: f }}>
{f}
</option>
))}
</select>
<input
type="color"
value={selected.fill}
onChange={(e) => updateElement(selected.id, { fill: e.target.value })}
className="h-8 w-8 border border-line bg-paper p-0"
/>
<label className="flex items-center gap-2 text-sm text-muted">
Size
<input
type="range"
min={10}
max={80}
value={selected.fontSize}
onChange={(e) => updateElement(selected.id, { fontSize: Number(e.target.value) })}
/>
</label>
<label className="flex items-center gap-2 text-sm text-muted">
Angle
<input
type="range"
min={0}
max={360}
value={selected.rotation || 0}
onChange={(e) => updateElement(selected.id, { rotation: Number(e.target.value) })}
/>
<span className="w-10 text-right font-mono text-xs">{selected.rotation || 0}°</span>
</label>
</div>
)}
{selected?.type === 'image' && (
<div className="mt-4 flex flex-wrap items-center gap-3 border border-line bg-surface p-4">
<label className="flex items-center gap-2 text-sm text-muted">
Angle
<input
type="range"
min={0}
max={360}
value={selected.rotation || 0}
onChange={(e) => updateElement(selected.id, { rotation: Number(e.target.value) })}
/>
<span className="w-10 text-right font-mono text-xs">{selected.rotation || 0}°</span>
</label>
</div>
)}
</div>
)}
<div className="flex items-center gap-4">
<div className="flex items-center border border-ink">
<button
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
className="px-3 py-2 text-sm"
aria-label="Decrease quantity"
>
</button>
<span className="w-10 text-center font-mono text-sm">{quantity}</span>
<button
onClick={() => setQuantity((q) => q + 1)}
className="px-3 py-2 text-sm"
aria-label="Increase quantity"
>
+
</button>
</div>
<button
onClick={handleAddToBag}
className="flex-1 bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark"
>
Add to bag <Price cents={product.effectivePrice * quantity} />
</button>
</div>
{justAdded && <p className="text-sm text-pine">Added to your bag.</p>}
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import Image from 'next/image';
import Link from 'next/link';
import NewsletterForm from './NewsletterForm';
export default function Footer() {
return (
<footer className="border-t border-line">
<div className="h-1 w-full brand-gradient" />
<div className="mx-auto max-w-6xl px-6 py-10">
<div className="grid gap-8 md:grid-cols-4">
<div>
<div className="flex items-center gap-3">
<Image src="/logo.png" alt="Craft2Prints" width={64} height={64} className="h-14 w-14 object-contain" />
<span className="font-script text-4xl leading-none">Craft2Prints</span>
</div>
<p className="mt-3 max-w-xs text-sm text-muted">
Ink it. Print it. Love it. Every piece made to order, from your own design.
</p>
<a
href="https://www.facebook.com/Needlestocrafts"
target="_blank"
rel="noopener noreferrer"
aria-label="Craft2Prints on Facebook"
className="mt-5 inline-flex items-center gap-2 border border-line px-4 py-2.5 text-sm font-medium text-ink transition-colors hover:border-splash-blue hover:bg-splash-blue hover:text-paper"
>
<svg viewBox="0 0 24 24" fill="currentColor" className="h-6 w-6 shrink-0">
<path d="M22 12a10 10 0 1 0-11.5 9.87v-6.98H7.9V12h2.6V9.8c0-2.6 1.55-4.03 3.92-4.03 1.13 0 2.32.2 2.32.2v2.55h-1.3c-1.29 0-1.69.8-1.69 1.62V12h2.88l-.46 2.89h-2.42v6.98A10 10 0 0 0 22 12z" />
</svg>
Follow us on Facebook
</a>
</div>
<div className="text-sm text-muted">
<p className="tag-label mb-3 text-ink">Made to order</p>
<p>Printed after you check out nothing sits on a shelf first.</p>
</div>
<div className="text-sm text-muted">
<p className="tag-label mb-3 text-ink">Contact</p>
<p>sales@craft2prints.co.uk</p>
<Link href="/contact" className="mt-2 inline-block text-clay hover:text-clay-dark">
Contact us
</Link>
</div>
<div className="text-sm text-muted">
<p className="tag-label mb-3 text-ink">Stay in the loop</p>
<p>New templates and the occasional offer no spam.</p>
<NewsletterForm />
</div>
</div>
<div className="mt-10 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
<p>© {new Date().getFullYear()} Craft2Prints.</p>
<nav className="flex flex-wrap gap-x-4 gap-y-1">
<Link href="/privacy" className="hover:text-ink">
Privacy
</Link>
<Link href="/cookies" className="hover:text-ink">
Cookies
</Link>
<Link href="/terms" className="hover:text-ink">
Terms
</Link>
<Link href="/returns" className="hover:text-ink">
Returns
</Link>
</nav>
</div>
</div>
</footer>
);
}
+83
View File
@@ -0,0 +1,83 @@
import Link from 'next/link';
import Image from 'next/image';
import CartButton from './CartButton';
import AdminMenu from './AdminMenu';
import AccountMenu from './AccountMenu';
import ThemeToggle from './ThemeToggle';
import NavDropdown from './NavDropdown';
import CurrencySelector from './CurrencySelector';
import MobileNav from './MobileNav';
import { prisma } from '@/lib/prisma';
import { getCurrentCustomer } from '@/lib/auth';
export default async function Header() {
const [categories, collections, customer] = await Promise.all([
prisma.category.findMany({
where: { showOnPersonalised: true },
orderBy: { name: 'asc' },
}),
prisma.collection.findMany({ orderBy: { name: 'asc' } }),
getCurrentCustomer(),
]);
const personalisedLinks = [
{ href: '/made-to-order', label: 'Shop all' },
...categories.map((c) => ({ href: `/made-to-order?category=${c.slug}`, label: c.name })),
];
const shopBySections = [
{
title: 'Occasions',
links: collections
.filter((c) => c.group === 'OCCASION')
.map((c) => ({ href: `/made-to-order?collection=${c.slug}`, label: c.name })),
},
{
title: 'Gifts for',
links: collections
.filter((c) => c.group === 'RECIPIENT')
.map((c) => ({ href: `/made-to-order?collection=${c.slug}`, label: c.name })),
},
];
return (
<header className="sticky top-0 z-40 border-b border-line bg-paper/90 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-4 py-3 sm:px-6 sm:py-4">
<div className="flex items-center gap-3">
<MobileNav
personalisedLinks={personalisedLinks}
shopBySections={shopBySections}
customer={customer ? { name: customer.name, email: customer.email } : null}
/>
<Link href="/" className="flex items-center gap-2 sm:gap-3">
<Image
src="/logo.png"
alt="Craft2Prints"
width={96}
height={96}
className="h-12 w-12 object-contain sm:h-20 sm:w-20"
/>
<span className="font-script text-3xl leading-none text-ink sm:text-5xl">Craft2Prints</span>
</Link>
</div>
<nav className="hidden items-center gap-6 md:flex">
<NavDropdown label="Personalised" links={personalisedLinks} />
<Link href="/products" className="tag-label hover:text-ink">
Products
</Link>
<NavDropdown label="Shop by" sections={shopBySections} />
<Link href="/gallery" className="tag-label hover:text-ink">
Gallery
</Link>
</nav>
<div className="flex items-center gap-2 sm:gap-4">
<CurrencySelector />
<div className="hidden md:block">
<AccountMenu customer={customer ? { name: customer.name, email: customer.email } : null} />
</div>
<AdminMenu />
<ThemeToggle />
<CartButton />
</div>
</div>
</header>
);
}
+25
View File
@@ -0,0 +1,25 @@
// Shared wrapper for the static legal pages (/privacy, /cookies, /terms,
// /returns) so they stay visually identical and each page file is just content.
export default function LegalPage({
tag,
title,
lastUpdated,
children,
}: {
tag: string;
title: string;
lastUpdated: string;
children: React.ReactNode;
}) {
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<p className="tag-label text-splash-pink">{tag}</p>
<h1 className="mt-2 font-display text-3xl">{title}</h1>
<p className="mt-2 text-xs text-muted">Last updated: {lastUpdated}</p>
<div className="mt-8 space-y-6 text-sm leading-relaxed text-ink [&_h2]:font-display [&_h2]:text-xl [&_h2]:pt-2 [&_ul]:list-disc [&_ul]:space-y-1 [&_ul]:pl-5 [&_a]:text-clay hover:[&_a]:text-clay-dark [&_p]:text-muted [&_li]:text-muted">
{children}
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import Link from 'next/link';
import Image from 'next/image';
import { DEFAULT_MAINTENANCE_MESSAGE } from '@/lib/settings';
export default function MaintenancePage({ message }: { message: string | null }) {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
<div className="h-1 w-24 brand-gradient" />
<Image
src="/logo.png"
alt="Craft2Prints"
width={96}
height={96}
className="mt-8 h-24 w-24 object-contain"
/>
<p className="mt-4 font-script text-3xl text-splash-pink">Back soon</p>
<h1 className="mt-2 font-display text-4xl">We&apos;re making some changes</h1>
<p className="mt-4 max-w-md text-muted">{message?.trim() || DEFAULT_MAINTENANCE_MESSAGE}</p>
<Link href="/admin/login" className="mt-12 tag-label text-muted hover:text-clay">
Admin login
</Link>
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
'use client';
import { useState } from 'react';
import { createManualSale } from '@/app/admin/accounts/actions';
import Price from './Price';
type ProductOption = {
id: string;
name: string;
basePrice: number; // pence
colors: string[];
sizes: string[];
};
type Row = {
key: number;
productId: string; // '' = free-text item
description: string;
color: string;
size: string;
quantity: number;
unitPriceInput: string; // pounds, as typed — parsed on serialize
};
function todayInput() {
return new Date().toISOString().slice(0, 10);
}
export default function ManualSaleForm({ products }: { products: ProductOption[] }) {
const [rows, setRows] = useState<Row[]>([
{ key: 1, productId: '', description: '', color: '', size: '', quantity: 1, unitPriceInput: '' },
]);
const [nextKey, setNextKey] = useState(2);
const updateRow = (key: number, patch: Partial<Row>) => {
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r)));
};
const pickProduct = (key: number, productId: string) => {
const product = products.find((p) => p.id === productId);
if (!product) {
// switched to free text — keep whatever was typed, clear product-specific fields
updateRow(key, { productId: '', color: '', size: '' });
return;
}
updateRow(key, {
productId,
description: product.name,
color: product.colors[0] ?? '',
size: product.sizes[0] ?? '',
unitPriceInput: (product.basePrice / 100).toFixed(2),
});
};
const addRow = () => {
setRows((prev) => [
...prev,
{ key: nextKey, productId: '', description: '', color: '', size: '', quantity: 1, unitPriceInput: '' },
]);
setNextKey((k) => k + 1);
};
const removeRow = (key: number) => {
setRows((prev) => (prev.length > 1 ? prev.filter((r) => r.key !== key) : prev));
};
const pence = (input: string) => Math.max(0, Math.round(Number(input || '0') * 100));
const total = rows.reduce((sum, r) => sum + r.quantity * pence(r.unitPriceInput), 0);
// What actually gets submitted — the server recomputes the total itself.
const itemsJson = JSON.stringify(
rows
.filter((r) => r.description.trim())
.map((r) => ({
productId: r.productId || null,
description: r.description,
color: r.productId ? r.color || null : null,
size: r.productId ? r.size || null : null,
quantity: r.quantity,
unitPrice: pence(r.unitPriceInput),
})),
);
return (
<form action={createManualSale} className="mt-10 space-y-6">
<input type="hidden" name="items" value={itemsJson} />
<div className="grid gap-4 sm:grid-cols-3">
<div>
<label htmlFor="soldAt" className="tag-label mb-2 block">
Date of sale
</label>
<input
id="soldAt"
name="soldAt"
type="date"
defaultValue={todayInput()}
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="buyerName" className="tag-label mb-2 block">
Buyer name (optional)
</label>
<input
id="buyerName"
name="buyerName"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Cash buyer at a fair? Leave blank"
/>
</div>
<div>
<label htmlFor="buyerEmail" className="tag-label mb-2 block">
Buyer email (optional)
</label>
<input
id="buyerEmail"
name="buyerEmail"
type="email"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Links to their account if they have one"
/>
</div>
</div>
<div>
<p className="tag-label mb-2">Items</p>
<div className="space-y-3">
{rows.map((row) => {
const product = products.find((p) => p.id === row.productId);
return (
<div key={row.key} className="border border-line bg-surface p-3">
<div className="grid gap-2 sm:grid-cols-2">
<select
value={row.productId}
onChange={(e) => pickProduct(row.key, e.target.value)}
className="border border-line bg-paper px-2 py-2 text-sm"
aria-label="Product"
>
<option value="">Free-text item</option>
{products.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<input
value={row.description}
onChange={(e) => updateRow(row.key, { description: e.target.value })}
placeholder="What was sold, e.g. Custom commission"
className="border border-line bg-paper px-3 py-2 text-sm"
aria-label="Description"
/>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{product && product.colors.length > 0 && (
<select
value={row.color}
onChange={(e) => updateRow(row.key, { color: e.target.value })}
className="border border-line bg-paper px-2 py-2 text-sm"
aria-label="Colour"
>
{product.colors.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
)}
{product && product.sizes.length > 0 && (
<select
value={row.size}
onChange={(e) => updateRow(row.key, { size: e.target.value })}
className="border border-line bg-paper px-2 py-2 text-sm"
aria-label="Size"
>
{product.sizes.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
)}
<label className="flex items-center gap-1 text-sm text-muted">
Qty
<input
type="number"
min={1}
value={row.quantity}
onChange={(e) => updateRow(row.key, { quantity: Math.max(1, Math.floor(Number(e.target.value) || 1)) })}
className="w-16 border border-line bg-paper px-2 py-2 text-sm"
/>
</label>
<label className="flex items-center gap-1 text-sm text-muted">
£
<input
type="number"
step="0.01"
min={0}
value={row.unitPriceInput}
onChange={(e) => updateRow(row.key, { unitPriceInput: e.target.value })}
placeholder="0.00"
className="w-24 border border-line bg-paper px-2 py-2 text-sm"
aria-label="Unit price in pounds"
/>
</label>
<span className="ml-auto font-mono text-sm">
<Price cents={row.quantity * pence(row.unitPriceInput)} />
</span>
{rows.length > 1 && (
<button
type="button"
onClick={() => removeRow(row.key)}
className="tag-label text-muted hover:text-splash-pink"
>
Remove
</button>
)}
</div>
</div>
);
})}
</div>
<button
type="button"
onClick={addRow}
className="mt-3 border border-ink px-3 py-1.5 text-xs hover:border-clay hover:bg-clay hover:text-paper"
>
+ Add another item
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="paymentMethod" className="tag-label mb-2 block">
Paid by
</label>
<select
id="paymentMethod"
name="paymentMethod"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
>
<option value="CASH">Cash</option>
<option value="BANK_TRANSFER">Bank transfer</option>
</select>
</div>
<div>
<label htmlFor="notes" className="tag-label mb-2 block">
Notes (optional)
</label>
<input
id="notes"
name="notes"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="e.g. Spring fair, collected in person"
/>
</div>
</div>
<div className="flex items-center justify-between border-t border-line pt-4">
<p className="font-mono text-lg">
Total <Price cents={total} />
</p>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Record sale
</button>
</div>
</form>
);
}
+131
View File
@@ -0,0 +1,131 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
type NavLink = { href: string; label: string };
type NavSection = { title: string; links: NavLink[] };
export default function MobileNav({
personalisedLinks,
shopBySections,
customer,
}: {
personalisedLinks: NavLink[];
shopBySections: NavSection[];
customer: { name: string | null; email: string } | null;
}) {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
// Lock background scroll while the drawer is open, and always start closed
// on route change (this component stays mounted across client-side nav).
useEffect(() => {
document.body.style.overflow = open ? 'hidden' : '';
return () => {
document.body.style.overflow = '';
};
}, [open]);
const close = () => {
setOpen(false);
setExpanded(null);
};
return (
<div className="md:hidden">
<button
onClick={() => setOpen(true)}
aria-label="Open menu"
aria-expanded={open}
className="flex h-9 w-9 items-center justify-center border border-line text-ink"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
</button>
{open && (
<div className="fixed inset-0 z-50 flex">
<div className="absolute inset-0 bg-black/30" onClick={close} />
<div className="relative ml-auto flex h-full w-full max-w-xs flex-col overflow-y-auto bg-paper p-6">
<div className="flex items-center justify-between">
<span className="font-script text-3xl leading-none text-ink">Craft2Prints</span>
<button onClick={close} aria-label="Close menu" className="flex h-9 w-9 items-center justify-center border border-line text-ink">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<nav className="mt-8 flex flex-col divide-y divide-line">
<div className="py-2">
<button
onClick={() => setExpanded((s) => (s === 'personalised' ? null : 'personalised'))}
aria-expanded={expanded === 'personalised'}
className="flex w-full items-center justify-between py-2 text-left tag-label"
>
Personalised
<span className={`transition-transform ${expanded === 'personalised' ? 'rotate-180' : ''}`}></span>
</button>
{expanded === 'personalised' && (
<div className="flex flex-col pb-2 pl-2">
{personalisedLinks.map((link) => (
<Link key={link.href} href={link.href} onClick={close} className="py-2 text-sm text-muted hover:text-ink">
{link.label}
</Link>
))}
</div>
)}
</div>
<Link href="/products" onClick={close} className="py-4 tag-label">
Products
</Link>
<div className="py-2">
<button
onClick={() => setExpanded((s) => (s === 'shopby' ? null : 'shopby'))}
aria-expanded={expanded === 'shopby'}
className="flex w-full items-center justify-between py-2 text-left tag-label"
>
Shop by
<span className={`transition-transform ${expanded === 'shopby' ? 'rotate-180' : ''}`}></span>
</button>
{expanded === 'shopby' && (
<div className="flex flex-col gap-3 pb-2 pl-2">
{shopBySections
.filter((s) => s.links.length > 0)
.map((section) => (
<div key={section.title}>
<p className="text-xs font-medium uppercase tracking-wide text-muted">{section.title}</p>
<div className="flex flex-col">
{section.links.map((link) => (
<Link key={link.href} href={link.href} onClick={close} className="py-2 text-sm text-muted hover:text-ink">
{link.label}
</Link>
))}
</div>
</div>
))}
{shopBySections.every((s) => s.links.length === 0) && (
<p className="text-sm text-muted">Coming soon.</p>
)}
</div>
)}
</div>
<Link href="/gallery" onClick={close} className="py-4 tag-label">
Gallery
</Link>
<Link href={customer ? '/account' : '/account/login'} onClick={close} className="py-4 tag-label">
{customer ? (customer.name ?? 'My account') : 'Login'}
</Link>
</nav>
</div>
</div>
)}
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
'use client';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
type NavLink = { href: string; label: string };
type NavSection = { title: string; links: NavLink[] };
export default function NavDropdown({
label,
links,
sections,
}: {
label: string;
links?: NavLink[];
sections?: NavSection[];
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, []);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="tag-label flex items-center gap-1 hover:text-ink"
>
{label}
<span className={`inline-block transition-transform ${open ? 'rotate-180' : ''}`}></span>
</button>
{open && (
<div className="absolute left-0 top-full mt-2 w-52 border border-line bg-paper shadow-sm">
{sections ? (
sections.every((s) => s.links.length === 0) ? (
<p className="px-4 py-3 text-sm text-muted">Coming soon.</p>
) : (
<div className="divide-y divide-line">
{sections
.filter((s) => s.links.length > 0)
.map((section) => (
<div key={section.title} className="py-1">
<p className="px-4 pt-2 text-xs font-medium uppercase tracking-wide text-muted">{section.title}</p>
{section.links.map((link) => (
<Link
key={link.href + link.label}
href={link.href}
onClick={() => setOpen(false)}
className="block px-4 py-2.5 text-sm hover:bg-surface"
>
{link.label}
</Link>
))}
</div>
))}
</div>
)
) : links && links.length > 0 ? (
links.map((link) => (
<Link
key={link.href + link.label}
href={link.href}
onClick={() => setOpen(false)}
className="block px-4 py-3 text-sm hover:bg-surface"
>
{link.label}
</Link>
))
) : (
<p className="px-4 py-3 text-sm text-muted">Coming soon.</p>
)}
</div>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More