Initial commit: Craft2Prints with Stages 1-3
This commit is contained in:
@@ -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');
|
||||
}
|
||||
@@ -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's email matches a
|
||||
customer account, the sale is linked to them automatically.
|
||||
</p>
|
||||
|
||||
<ManualSaleForm products={products} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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's stored with the purchase. Lines matched to a product add straight to
|
||||
its stock count.
|
||||
</p>
|
||||
|
||||
<PurchaseForm products={products} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "Occasions" and "Gifts For" menus, and are tagged onto
|
||||
products from each product'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 "we're updating" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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't sent automatically.</p>
|
||||
<p className="mt-1 text-muted">
|
||||
This usually means SMTP isn't configured yet in <code className="font-mono">.env</code> (see
|
||||
the README's "Sending emails" 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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'}`);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user