Initial commit: Craft2Prints with Stages 1-3
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user