Initial commit: Craft2Prints with Stages 1-3

This commit is contained in:
Andymick
2026-07-15 18:09:59 +01:00
commit 41937ffc15
158 changed files with 16054 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import { updateProduct } from '../../actions';
import AdminNav from '@/components/AdminNav';
import ColorPicker from '@/components/ColorPicker';
import SizePicker from '@/components/SizePicker';
import PhotoPrintAreaField from '@/components/PhotoPrintAreaField';
import { MOCKUP_OPTIONS } from '@/lib/mockupDefaults';
export default async function EditProductPage({ params }: { params: { id: string } }) {
const activePromotions = await fetchActivePromotions();
const row = await prisma.product.findUnique({ where: { id: params.id }, include: { collections: true } });
if (!row) notFound();
const product = toProductDTO(row, activePromotions);
const categories = await prisma.category.findMany({ orderBy: { name: 'asc' } });
const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
const occasions = collections.filter((c) => c.group === 'OCCASION');
const recipients = collections.filter((c) => c.group === 'RECIPIENT');
const selectedCollectionIds = new Set(row.collections.map((c) => c.id));
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Edit {product.name}</h1>
<p className="mt-2 text-sm text-muted">
Changes apply immediately to the live product at /made-to-order/{product.slug}.
</p>
<form action={updateProduct} className="mt-10 space-y-6" encType="multipart/form-data">
<input type="hidden" name="id" value={product.id} />
<div>
<label className="tag-label mb-2 block">Show on</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="showOnPersonalised"
value="1"
defaultChecked={product.showOnPersonalised}
className="h-4 w-4 accent-clay"
/>
Personalised catalog (/made-to-order) customers design their own
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="showOnProducts"
value="1"
defaultChecked={product.showOnProducts}
className="h-4 w-4 accent-clay"
/>
Products catalog (/products) same photos, browse and buy as-is
</label>
</div>
<p className="mt-1 text-xs text-muted">Check both to list the same product on both pages.</p>
</div>
<div>
<label htmlFor="name" className="tag-label mb-2 block">
Product name
</label>
<input
id="name"
name="name"
required
defaultValue={product.name}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="category" className="tag-label mb-2 block">
Category
</label>
<select
id="category"
name="category"
required
defaultValue={product.category}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
>
{categories.map((c) => (
<option key={c.id} value={c.slug}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label htmlFor="mockup" className="tag-label mb-2 block">
Shape (used if no photo)
</label>
<select
id="mockup"
name="mockup"
required
defaultValue={product.mockup}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
>
{MOCKUP_OPTIONS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
</div>
{(occasions.length > 0 || recipients.length > 0) && (
<div>
<label className="tag-label mb-2 block">Collections (optional)</label>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="mb-2 text-xs text-muted">Occasions</p>
<div className="space-y-1.5">
{occasions.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collections"
value={c.id}
defaultChecked={selectedCollectionIds.has(c.id)}
className="h-4 w-4 accent-clay"
/>
{c.name}
</label>
))}
{occasions.length === 0 && <p className="text-xs text-muted">None yet.</p>}
</div>
</div>
<div>
<p className="mb-2 text-xs text-muted">Gifts for</p>
<div className="space-y-1.5">
{recipients.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="collections"
value={c.id}
defaultChecked={selectedCollectionIds.has(c.id)}
className="h-4 w-4 accent-clay"
/>
{c.name}
</label>
))}
{recipients.length === 0 && <p className="text-xs text-muted">None yet.</p>}
</div>
</div>
</div>
<a href="/admin/collections/new" className="mt-1 inline-block text-xs text-clay hover:text-clay-dark">
+ Add a new collection
</a>
</div>
)}
<div>
<label htmlFor="price" className="tag-label mb-2 block">
Price (£)
</label>
<input
id="price"
name="price"
type="number"
step="0.01"
min={0}
required
defaultValue={(product.basePrice / 100).toFixed(2)}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="salePrice" className="tag-label mb-2 block">
Sale price (£, optional)
</label>
<input
id="salePrice"
name="salePrice"
type="number"
step="0.01"
min={0}
defaultValue={row.salePrice != null ? (row.salePrice / 100).toFixed(2) : ''}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Leave blank for no sale"
/>
<p className="mt-1 text-xs text-muted">
Set a start/end date below too outside that window the normal price shows instead.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="saleStartsAt" className="tag-label mb-2 block">
Sale starts (optional)
</label>
<input
id="saleStartsAt"
name="saleStartsAt"
type="date"
defaultValue={row.saleStartsAt ? row.saleStartsAt.toISOString().slice(0, 10) : ''}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="saleEndsAt" className="tag-label mb-2 block">
Sale ends (optional)
</label>
<input
id="saleEndsAt"
name="saleEndsAt"
type="date"
defaultValue={row.saleEndsAt ? row.saleEndsAt.toISOString().slice(0, 10) : ''}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
</div>
<div>
<label htmlFor="personalisedPrice" className="tag-label mb-2 block">
Personalised price (£, optional)
</label>
<input
id="personalisedPrice"
name="personalisedPrice"
type="number"
step="0.01"
min={0}
defaultValue={row.personalisedPrice != null ? (row.personalisedPrice / 100).toFixed(2) : ''}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Leave blank to use the regular price"
/>
<p className="mt-1 text-xs text-muted">
Higher price for made-to-order items. Leave blank to use the same price as ready-made.
</p>
</div>
<div>
<label className="tag-label mb-2 block">Front photo</label>
{product.imageUrl && (
<div className="mb-3 flex items-center gap-3">
<img src={product.imageUrl} alt={product.name} className="h-20 w-20 border border-line object-contain" />
<p className="text-xs text-muted">Current photo. Upload a new one below to replace it.</p>
</div>
)}
<PhotoPrintAreaField />
</div>
<div>
<label className="tag-label mb-2 block">Back photo</label>
{product.imageUrlBack && (
<div className="mb-3 flex items-center gap-3">
<img
src={product.imageUrlBack}
alt={`${product.name} back`}
className="h-20 w-20 border border-line object-contain"
/>
<p className="text-xs text-muted">Current back photo and print area. Upload a new one below to replace both.</p>
</div>
)}
<PhotoPrintAreaField variant="back" />
</div>
<div>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="photoRecolorable"
value="1"
defaultChecked={product.photoRecolorable}
className="mt-1 h-4 w-4 accent-clay"
/>
<span>
Tint this photo per color (fallback)
<span className="mt-1 block text-xs text-muted">
Only works well for black, white, or gray photos the color is applied as a live
tint. If you have real photos for specific colors, add them below instead this
only kicks in for colors that don&apos;t have their own photo.
</span>
</span>
</label>
</div>
<div>
<label className="tag-label mb-2 block">Colors</label>
<ColorPicker
name="colors"
defaultColors={product.colors}
defaultColorPhotos={product.colorPhotos}
defaultColorPhotosBack={product.colorPhotosBack}
/>
</div>
<div>
<label className="tag-label mb-2 block">Sizes (optional)</label>
<SizePicker name="sizes" defaultSizes={product.sizes} />
</div>
<div>
<label htmlFor="description" className="tag-label mb-2 block">
Description
</label>
<textarea
id="description"
name="description"
rows={3}
defaultValue={product.description}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Save changes
</button>
</form>
</div>
);
}
+305
View File
@@ -0,0 +1,305 @@
'use server';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { DEFAULT_PRINT_AREAS } from '@/lib/mockupDefaults';
function slugify(input: string) {
return input
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
async function uniqueSlug(base: string) {
let slug = base || 'product';
let n = 2;
while (await prisma.product.findUnique({ where: { slug } })) {
slug = `${base}-${n}`;
n += 1;
}
return slug;
}
async function fileToDataUrl(file: File): Promise<string> {
const bytes = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/png';
return `data:${mimeType};base64,${bytes.toString('base64')}`;
}
// Reads the per-color photo file inputs the ColorPicker renders (named
// "colorPhoto__<hexWithoutHash>" for front, "colorPhotoBack__<hexWithoutHash>" for
// back) and builds { hex: dataUrl } for whichever colors actually got a file attached.
async function extractColorPhotos(
formData: FormData,
colors: string[],
variant: 'front' | 'back' = 'front',
): Promise<Record<string, string>> {
const prefix = variant === 'front' ? 'colorPhoto__' : 'colorPhotoBack__';
const result: Record<string, string> = {};
for (const hex of colors) {
const file = formData.get(`${prefix}${hex.replace('#', '')}`) as File | null;
if (file && file.size > 0) {
result[hex] = await fileToDataUrl(file);
}
}
return result;
}
// Shared by createProduct/updateProduct — empty inputs mean "no sale". The end
// date is pushed to the end of that day so the sale stays active through it.
function parseSaleFields(formData: FormData) {
const salePriceInput = String(formData.get('salePrice') ?? '').trim();
const startInput = String(formData.get('saleStartsAt') ?? '').trim();
const endInput = String(formData.get('saleEndsAt') ?? '').trim();
return {
salePrice: salePriceInput ? Math.round(Number(salePriceInput) * 100) : null,
saleStartsAt: startInput ? new Date(startInput) : null,
saleEndsAt: endInput ? new Date(`${endInput}T23:59:59`) : null,
};
}
export async function createProduct(formData: FormData) {
const name = String(formData.get('name') ?? '').trim();
const slugInput = String(formData.get('slug') ?? '').trim();
const category = String(formData.get('category') ?? '');
const description = String(formData.get('description') ?? '').trim();
const priceDollars = Number(formData.get('price') ?? 0);
const personalisedPriceDollars = String(formData.get('personalisedPrice') ?? '').trim();
const mockup = String(formData.get('mockup') ?? 'tshirt');
const colorsInput = String(formData.get('colors') ?? '');
const sizesInput = String(formData.get('sizes') ?? '');
const imageFile = formData.get('image') as File | null;
const imageBackFile = formData.get('imageBack') as File | null;
const photoRecolorable = String(formData.get('photoRecolorable') ?? '') === '1';
const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1';
const showOnProducts = String(formData.get('showOnProducts') ?? '') === '1';
const collectionIds = formData.getAll('collections').map((v) => String(v));
if (!name || !category || !priceDollars) {
throw new Error('Name, category, and price are required.');
}
const colors = colorsInput
.split(',')
.map((c) => c.trim())
.filter(Boolean);
if (colors.length === 0) {
throw new Error('At least one color is required.');
}
const sizes = sizesInput
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const imageUrl = imageFile && imageFile.size > 0 ? await fileToDataUrl(imageFile) : null;
const imageUrlBack = imageBackFile && imageBackFile.size > 0 ? await fileToDataUrl(imageBackFile) : null;
const colorPhotos = await extractColorPhotos(formData, colors, 'front');
const colorPhotosBack = await extractColorPhotos(formData, colors, 'back');
const saleFields = parseSaleFields(formData);
const slug = await uniqueSlug(slugify(slugInput || name));
const hasCustomPrintArea = String(formData.get('hasCustomPrintArea') ?? '') === '1' && imageUrl;
const printArea = hasCustomPrintArea
? {
xPct: Number(formData.get('printX') ?? 0.3),
yPct: Number(formData.get('printY') ?? 0.25),
wPct: Number(formData.get('printW') ?? 0.4),
hPct: Number(formData.get('printH') ?? 0.35),
}
: DEFAULT_PRINT_AREAS[mockup] ?? DEFAULT_PRINT_AREAS.tshirt;
const hasCustomPrintAreaBack = String(formData.get('hasCustomPrintAreaBack') ?? '') === '1' && imageUrlBack;
const printAreaBack = hasCustomPrintAreaBack
? {
xPct: Number(formData.get('printXBack') ?? 0.3),
yPct: Number(formData.get('printYBack') ?? 0.25),
wPct: Number(formData.get('printWBack') ?? 0.4),
hPct: Number(formData.get('printHBack') ?? 0.35),
}
: null;
await prisma.product.create({
data: {
slug,
name,
category,
description: description || `${name} — personalize it your way.`,
basePrice: Math.round(priceDollars * 100),
personalisedPrice: personalisedPriceDollars ? Math.round(Number(personalisedPriceDollars) * 100) : null,
...saleFields,
colors: JSON.stringify(colors),
colorPhotos: Object.keys(colorPhotos).length > 0 ? JSON.stringify(colorPhotos) : null,
colorPhotosBack: Object.keys(colorPhotosBack).length > 0 ? JSON.stringify(colorPhotosBack) : null,
sizes: sizes.length > 0 ? JSON.stringify(sizes) : null,
mockup,
printArea: JSON.stringify(printArea),
printAreaBack: printAreaBack ? JSON.stringify(printAreaBack) : null,
imageUrl,
imageUrlBack,
photoRecolorable,
showOnPersonalised,
showOnProducts,
collections: { connect: collectionIds.map((id) => ({ id })) },
},
});
redirect('/admin/products');
}
export async function updateProduct(formData: FormData) {
const id = String(formData.get('id') ?? '');
const name = String(formData.get('name') ?? '').trim();
const category = String(formData.get('category') ?? '');
const description = String(formData.get('description') ?? '').trim();
const priceDollars = Number(formData.get('price') ?? 0);
const personalisedPriceDollars = String(formData.get('personalisedPrice') ?? '').trim();
const mockup = String(formData.get('mockup') ?? 'tshirt');
const colorsInput = String(formData.get('colors') ?? '');
const sizesInput = String(formData.get('sizes') ?? '');
const imageFile = formData.get('image') as File | null;
const imageBackFile = formData.get('imageBack') as File | null;
const photoRecolorable = String(formData.get('photoRecolorable') ?? '') === '1';
const showOnPersonalised = String(formData.get('showOnPersonalised') ?? '') === '1';
const showOnProducts = String(formData.get('showOnProducts') ?? '') === '1';
const collectionIds = formData.getAll('collections').map((v) => String(v));
if (!id) throw new Error('Missing product id.');
if (!name || !category || !priceDollars) {
throw new Error('Name, category, and price are required.');
}
const colors = colorsInput
.split(',')
.map((c) => c.trim())
.filter(Boolean);
if (colors.length === 0) {
throw new Error('At least one color is required.');
}
const sizes = sizesInput
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const existing = await prisma.product.findUnique({ where: { id } });
if (!existing) throw new Error('Product not found.');
const existingColorPhotos: Record<string, string> = existing.colorPhotos
? JSON.parse(existing.colorPhotos)
: {};
const existingColorPhotosBack: Record<string, string> = existing.colorPhotosBack
? JSON.parse(existing.colorPhotosBack)
: {};
const newColorPhotos = await extractColorPhotos(formData, colors, 'front');
const newColorPhotosBack = await extractColorPhotos(formData, colors, 'back');
// Merge: newly uploaded photos override, everything else keeps whatever was
// already saved (and only for colors still in the list).
const mergedColorPhotos: Record<string, string> = {};
const mergedColorPhotosBack: Record<string, string> = {};
for (const hex of colors) {
if (newColorPhotos[hex]) mergedColorPhotos[hex] = newColorPhotos[hex];
else if (existingColorPhotos[hex]) mergedColorPhotos[hex] = existingColorPhotos[hex];
if (newColorPhotosBack[hex]) mergedColorPhotosBack[hex] = newColorPhotosBack[hex];
else if (existingColorPhotosBack[hex]) mergedColorPhotosBack[hex] = existingColorPhotosBack[hex];
}
const data: {
name: string;
category: string;
description: string;
basePrice: number;
personalisedPrice: number | null;
salePrice: number | null;
saleStartsAt: Date | null;
saleEndsAt: Date | null;
colors: string;
colorPhotos: string | null;
colorPhotosBack: string | null;
sizes: string | null;
mockup: string;
photoRecolorable: boolean;
showOnPersonalised: boolean;
showOnProducts: boolean;
imageUrl?: string;
imageUrlBack?: string;
printArea?: string;
printAreaBack?: string;
} = {
name,
category,
description: description || `${name} — personalize it your way.`,
basePrice: Math.round(priceDollars * 100),
personalisedPrice: personalisedPriceDollars ? Math.round(Number(personalisedPriceDollars) * 100) : null,
...parseSaleFields(formData),
colors: JSON.stringify(colors),
colorPhotos: Object.keys(mergedColorPhotos).length > 0 ? JSON.stringify(mergedColorPhotos) : null,
colorPhotosBack: Object.keys(mergedColorPhotosBack).length > 0 ? JSON.stringify(mergedColorPhotosBack) : null,
sizes: sizes.length > 0 ? JSON.stringify(sizes) : null,
mockup,
photoRecolorable,
showOnPersonalised,
showOnProducts,
};
// Only touch the front photo/print area if a new photo was actually uploaded —
// otherwise leave whatever's already there alone.
if (imageFile && imageFile.size > 0) {
data.imageUrl = await fileToDataUrl(imageFile);
const hasCustomPrintArea = String(formData.get('hasCustomPrintArea') ?? '') === '1';
data.printArea = JSON.stringify(
hasCustomPrintArea
? {
xPct: Number(formData.get('printX') ?? 0.3),
yPct: Number(formData.get('printY') ?? 0.25),
wPct: Number(formData.get('printW') ?? 0.4),
hPct: Number(formData.get('printH') ?? 0.35),
}
: DEFAULT_PRINT_AREAS[mockup] ?? DEFAULT_PRINT_AREAS.tshirt,
);
}
if (imageBackFile && imageBackFile.size > 0) {
data.imageUrlBack = await fileToDataUrl(imageBackFile);
const hasCustomPrintAreaBack = String(formData.get('hasCustomPrintAreaBack') ?? '') === '1';
if (hasCustomPrintAreaBack) {
data.printAreaBack = JSON.stringify({
xPct: Number(formData.get('printXBack') ?? 0.3),
yPct: Number(formData.get('printYBack') ?? 0.25),
wPct: Number(formData.get('printWBack') ?? 0.4),
hPct: Number(formData.get('printHBack') ?? 0.35),
});
}
}
await prisma.product.update({
where: { id },
data: { ...data, collections: { set: collectionIds.map((cid) => ({ id: cid })) } },
});
redirect('/admin/products');
}
export async function deleteProduct(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
try {
await prisma.product.delete({ where: { id } });
} catch {
// Product has existing orders/design proofs tied to it — deleting would
// orphan that history, so we block it instead of cascading.
redirect('/admin/products?error=in_use');
}
redirect('/admin/products');
}
+270
View File
@@ -0,0 +1,270 @@
import { createProduct } from '../actions';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import ColorPicker from '@/components/ColorPicker';
import SizePicker from '@/components/SizePicker';
import PhotoPrintAreaField from '@/components/PhotoPrintAreaField';
import { MOCKUP_OPTIONS } from '@/lib/mockupDefaults';
export default async function NewProductPage() {
const categories = await prisma.category.findMany({ orderBy: { name: 'asc' } });
const collections = await prisma.collection.findMany({ orderBy: { name: 'asc' } });
const occasions = collections.filter((c) => c.group === 'OCCASION');
const recipients = collections.filter((c) => c.group === 'RECIPIENT');
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Add a product</h1>
<p className="mt-2 text-sm text-muted">
Choose which catalog(s) this shows up in below the personalized one where customers
design their own version, the ready-made one where they just buy it as pictured, or both.
</p>
<form action={createProduct} className="mt-10 space-y-6" encType="multipart/form-data">
<div>
<label className="tag-label mb-2 block">Show on</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" name="showOnPersonalised" value="1" defaultChecked className="h-4 w-4 accent-clay" />
Personalised catalog (/made-to-order) customers design their own
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" name="showOnProducts" value="1" className="h-4 w-4 accent-clay" />
Products catalog (/products) same photos, browse and buy as-is
</label>
</div>
<p className="mt-1 text-xs text-muted">Check both to list the same product on both pages.</p>
</div>
<div>
<label htmlFor="name" className="tag-label mb-2 block">
Product name
</label>
<input
id="name"
name="name"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Classic Tee"
/>
</div>
<div>
<label htmlFor="slug" className="tag-label mb-2 block">
URL slug (optional)
</label>
<input
id="slug"
name="slug"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Leave blank to generate from the name"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="category" className="tag-label mb-2 block">
Category
</label>
<select
id="category"
name="category"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
>
{categories.map((c) => (
<option key={c.id} value={c.slug}>
{c.name}
</option>
))}
</select>
<a href="/admin/categories/new" className="mt-1 inline-block text-xs text-clay hover:text-clay-dark">
+ Add a new category
</a>
</div>
<div>
<label htmlFor="mockup" className="tag-label mb-2 block">
Shape (used if no photo)
</label>
<select
id="mockup"
name="mockup"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
>
{MOCKUP_OPTIONS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
</div>
{(occasions.length > 0 || recipients.length > 0) && (
<div>
<label className="tag-label mb-2 block">Collections (optional)</label>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="mb-2 text-xs text-muted">Occasions</p>
<div className="space-y-1.5">
{occasions.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-sm">
<input type="checkbox" name="collections" value={c.id} className="h-4 w-4 accent-clay" />
{c.name}
</label>
))}
{occasions.length === 0 && <p className="text-xs text-muted">None yet.</p>}
</div>
</div>
<div>
<p className="mb-2 text-xs text-muted">Gifts for</p>
<div className="space-y-1.5">
{recipients.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-sm">
<input type="checkbox" name="collections" value={c.id} className="h-4 w-4 accent-clay" />
{c.name}
</label>
))}
{recipients.length === 0 && <p className="text-xs text-muted">None yet.</p>}
</div>
</div>
</div>
<a href="/admin/collections/new" className="mt-1 inline-block text-xs text-clay hover:text-clay-dark">
+ Add a new collection
</a>
</div>
)}
<div>
<label htmlFor="price" className="tag-label mb-2 block">
Price (£)
</label>
<input
id="price"
name="price"
type="number"
step="0.01"
min={0}
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="29.00"
/>
</div>
<div>
<label htmlFor="salePrice" className="tag-label mb-2 block">
Sale price (£, optional)
</label>
<input
id="salePrice"
name="salePrice"
type="number"
step="0.01"
min={0}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Leave blank for no sale"
/>
<p className="mt-1 text-xs text-muted">
Set a start/end date below too outside that window the normal price shows instead.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="saleStartsAt" className="tag-label mb-2 block">
Sale starts (optional)
</label>
<input
id="saleStartsAt"
name="saleStartsAt"
type="date"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="saleEndsAt" className="tag-label mb-2 block">
Sale ends (optional)
</label>
<input
id="saleEndsAt"
name="saleEndsAt"
type="date"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
</div>
<div>
<label htmlFor="personalisedPrice" className="tag-label mb-2 block">
Personalised price (£, optional)
</label>
<input
id="personalisedPrice"
name="personalisedPrice"
type="number"
step="0.01"
min={0}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Leave blank to use the regular price"
/>
<p className="mt-1 text-xs text-muted">
Higher price for made-to-order items. Leave blank to use the same price as ready-made.
</p>
</div>
<div>
<label className="tag-label mb-2 block">Front photo (optional)</label>
<PhotoPrintAreaField />
</div>
<div>
<label className="tag-label mb-2 block">Back photo (optional)</label>
<PhotoPrintAreaField variant="back" />
</div>
<div>
<label className="flex items-start gap-2 text-sm">
<input type="checkbox" name="photoRecolorable" value="1" className="mt-1 h-4 w-4 accent-clay" />
<span>
Tint this photo per color (fallback)
<span className="mt-1 block text-xs text-muted">
Only works well for black, white, or gray photos the color is applied as a live
tint. If you have real photos for specific colors, add them below instead this
only kicks in for colors that don&apos;t have their own photo.
</span>
</span>
</label>
</div>
<div>
<label className="tag-label mb-2 block">Colors</label>
<ColorPicker name="colors" />
</div>
<div>
<label className="tag-label mb-2 block">Sizes (optional)</label>
<SizePicker name="sizes" />
</div>
<div>
<label htmlFor="description" className="tag-label mb-2 block">
Description
</label>
<textarea
id="description"
name="description"
rows={3}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Heavyweight combed cotton, printed dead-center on the chest."
/>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Add product
</button>
</form>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import AdminNav from '@/components/AdminNav';
import ProductMockup from '@/components/ProductMockup';
import { deleteProduct } from './actions';
export default async function AdminProductsPage({
searchParams,
}: {
searchParams: { error?: string };
}) {
const activePromotions = await fetchActivePromotions();
const rows = await prisma.product.findMany({ orderBy: { createdAt: 'desc' } });
const products = rows.map((p) => toProductDTO(p, activePromotions));
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between">
<h1 className="font-display text-3xl">Products</h1>
<Link
href="/admin/products/new"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
+ Add product
</Link>
</div>
{searchParams.error === 'in_use' && (
<div className="mt-6 border border-splash-orange bg-splash-orange/5 p-4 text-sm">
Can&apos;t delete that product it has existing orders or design proofs tied to it.
</div>
)}
{products.length === 0 ? (
<p className="mt-10 text-sm text-muted">No products yet.</p>
) : (
<div className="mt-8 divide-y divide-line border-t border-line">
{products.map((p) => (
<div key={p.id} className="flex items-center gap-4 py-4">
<div className="h-16 w-16 border border-line bg-surface p-1">
{(p.colorPhotos[p.colors[0]] ?? p.imageUrl) ? (
<img
src={p.colorPhotos[p.colors[0]] ?? p.imageUrl!}
alt={p.name}
className="h-full w-full object-contain"
/>
) : (
<ProductMockup mockup={p.mockup} color={p.colors[0]} />
)}
</div>
<div className="flex-1">
<p className="font-medium">{p.name}</p>
<p className="text-sm text-muted">
{p.category.replace('_', ' ')} · £${(p.basePrice / 100).toFixed(2)} · {p.colors.length}{' '}
color{p.colors.length === 1 ? '' : 's'}
{p.sizes.length > 0 && ` · ${p.sizes.join('/')}`}
</p>
</div>
<Link href={`/admin/products/${p.id}/edit`} className="tag-label hover:text-ink">
Edit
</Link>
<Link href={`/made-to-order/${p.slug}`} className="tag-label hover:text-ink">
View
</Link>
<form action={deleteProduct}>
<input type="hidden" name="id" value={p.id} />
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
Delete
</button>
</form>
</div>
))}
</div>
)}
</div>
);
}