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>
);
}