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