Homepage was rendering 71MB of HTML because every uploaded image (gallery, products, proofs, custom requests, design previews, invoices, receipts) was stored as base64 in Postgres and double-embedded via RSC hydration payloads. Adds src/lib/storage.ts (sharp-based compression, disk storage under public/uploads/) and a new /api/design-uploads endpoint for images added inside the design tool, migrates existing rows, and drops the now-unused base64 columns and the dead ProductImage table. Also fixes a systemic bug from the Next.js 16 upgrade where dynamic pages across the app still destructured params/searchParams synchronously instead of awaiting them as Promises, which was silently breaking filters/params and crashing /products/[slug] outright. Updates README.md and SETUP_AND_BUILD.md to reflect Postgres + Next.js 16 and document the new image storage architecture and backup requirements. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
382 lines
15 KiB
TypeScript
382 lines
15 KiB
TypeScript
'use server';
|
|
|
|
import { redirect } from 'next/navigation';
|
|
import { prisma } from '@/lib/prisma';
|
|
import { DEFAULT_PRINT_AREAS } from '@/lib/mockupDefaults';
|
|
import { saveUploadedFile } from '@/lib/storage';
|
|
|
|
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;
|
|
}
|
|
|
|
// Reads the per-color photo file inputs the ColorPicker renders (named
|
|
// "colorPhoto__<hexWithoutHash>" for front, "colorPhotoBack__<hexWithoutHash>" for
|
|
// back) and builds { hex: url } 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 saveUploadedFile(file, 'products');
|
|
}
|
|
}
|
|
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 plainPriceDollars = String(formData.get('plainPrice') ?? '').trim();
|
|
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 showAsPlain = String(formData.get('showAsPlain') ?? '') === '1';
|
|
const showAsReadyMade = String(formData.get('showAsReadyMade') ?? '') === '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);
|
|
|
|
// Weight in kilograms converted to grams
|
|
const weightKgInput = String(formData.get('weightKg') ?? '').trim();
|
|
const weightGrams = weightKgInput ? Math.round(Number(weightKgInput) * 1000) : null;
|
|
|
|
const imageUrl = imageFile && imageFile.size > 0 ? await saveUploadedFile(imageFile, 'products') : null;
|
|
const imageUrlBack = imageBackFile && imageBackFile.size > 0 ? await saveUploadedFile(imageBackFile, 'products') : 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),
|
|
plainPrice: plainPriceDollars ? Math.round(Number(plainPriceDollars) * 100) : null,
|
|
personalisedPrice: personalisedPriceDollars ? Math.round(Number(personalisedPriceDollars) * 100) : null,
|
|
weightGrams,
|
|
...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,
|
|
showAsPlain,
|
|
showAsReadyMade,
|
|
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 plainPriceDollars = String(formData.get('plainPrice') ?? '').trim();
|
|
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 showAsPlain = String(formData.get('showAsPlain') ?? '') === '1';
|
|
const showAsReadyMade = String(formData.get('showAsReadyMade') ?? '') === '1';
|
|
const collectionIds = formData.getAll('collections').map((v) => String(v));
|
|
const referenceWidthCmInput = String(formData.get('referenceWidthCm') ?? '').trim();
|
|
const referenceHeightCmInput = String(formData.get('referenceHeightCm') ?? '').trim();
|
|
const referenceWidthCm = referenceWidthCmInput ? Number(referenceWidthCmInput) : null;
|
|
const referenceHeightCm = referenceHeightCmInput ? Number(referenceHeightCmInput) : null;
|
|
|
|
// Weight in kilograms converted to grams
|
|
const weightKgInput = String(formData.get('weightKg') ?? '').trim();
|
|
const weightGrams = weightKgInput ? Math.round(Number(weightKgInput) * 1000) : null;
|
|
|
|
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 },
|
|
select: {
|
|
colorPhotos: true,
|
|
colorPhotosBack: true,
|
|
imageUrl: true,
|
|
imageUrlBack: true,
|
|
},
|
|
});
|
|
if (!existing) throw new Error('Product not found.');
|
|
|
|
// Only parse and process color photos if new ones were uploaded
|
|
const hasNewColorPhotos = colors.some((hex) => formData.get(`colorPhoto__${hex.replace('#', '')}`));
|
|
const hasNewColorPhotosBack = colors.some((hex) => formData.get(`colorPhotoBack__${hex.replace('#', '')}`));
|
|
|
|
let mergedColorPhotos = existing.colorPhotos ? JSON.parse(existing.colorPhotos) : {};
|
|
let mergedColorPhotosBack = existing.colorPhotosBack ? JSON.parse(existing.colorPhotosBack) : {};
|
|
|
|
// Only extract and merge new photos if any were uploaded
|
|
if (hasNewColorPhotos) {
|
|
const existingPhotos = mergedColorPhotos;
|
|
const newPhotos = await extractColorPhotos(formData, colors, 'front');
|
|
mergedColorPhotos = {};
|
|
for (const hex of colors) {
|
|
mergedColorPhotos[hex] = newPhotos[hex] || existingPhotos[hex];
|
|
}
|
|
}
|
|
|
|
if (hasNewColorPhotosBack) {
|
|
const existingPhotos = mergedColorPhotosBack;
|
|
const newPhotos = await extractColorPhotos(formData, colors, 'back');
|
|
mergedColorPhotosBack = {};
|
|
for (const hex of colors) {
|
|
mergedColorPhotosBack[hex] = newPhotos[hex] || existingPhotos[hex];
|
|
}
|
|
}
|
|
|
|
// Remove colors that are no longer in the list
|
|
const colorSet = new Set(colors);
|
|
mergedColorPhotos = Object.fromEntries(
|
|
Object.entries(mergedColorPhotos).filter(([hex]) => colorSet.has(hex))
|
|
);
|
|
mergedColorPhotosBack = Object.fromEntries(
|
|
Object.entries(mergedColorPhotosBack).filter(([hex]) => colorSet.has(hex))
|
|
);
|
|
|
|
const data: {
|
|
name: string;
|
|
category: string;
|
|
description: string;
|
|
basePrice: number;
|
|
plainPrice: number | null;
|
|
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;
|
|
showAsPlain: boolean;
|
|
showAsReadyMade: boolean;
|
|
referenceWidthCm: number | null;
|
|
referenceHeightCm: number | null;
|
|
weightGrams: number | null;
|
|
imageUrl?: string | null;
|
|
imageUrlBack?: string | null;
|
|
printArea?: string;
|
|
printAreaBack?: string | null;
|
|
} = {
|
|
name,
|
|
category,
|
|
description: description || `${name} — personalize it your way.`,
|
|
basePrice: Math.round(priceDollars * 100),
|
|
plainPrice: plainPriceDollars ? Math.round(Number(plainPriceDollars) * 100) : null,
|
|
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,
|
|
showAsPlain,
|
|
showAsReadyMade,
|
|
referenceWidthCm,
|
|
referenceHeightCm,
|
|
weightGrams,
|
|
};
|
|
|
|
// Handle front photo: new upload, removal, or keep existing
|
|
const removeImage = String(formData.get('removeImage') ?? '') === '1';
|
|
const hasCustomPrintArea = String(formData.get('hasCustomPrintArea') ?? '') === '1';
|
|
|
|
if (removeImage) {
|
|
data.imageUrl = null;
|
|
data.printArea = JSON.stringify(DEFAULT_PRINT_AREAS[mockup] ?? DEFAULT_PRINT_AREAS.tshirt);
|
|
} else if (imageFile && imageFile.size > 0) {
|
|
data.imageUrl = await saveUploadedFile(imageFile, 'products');
|
|
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,
|
|
);
|
|
} else if (existing.imageUrl) {
|
|
// No new image uploaded, but existing image present — still update print area if custom
|
|
if (hasCustomPrintArea) {
|
|
data.printArea = JSON.stringify({
|
|
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),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Handle back photo: new upload, removal, or keep existing
|
|
const removeImageBack = String(formData.get('removeImageBack') ?? '') === '1';
|
|
const hasCustomPrintAreaBack = String(formData.get('hasCustomPrintAreaBack') ?? '') === '1';
|
|
|
|
if (removeImageBack) {
|
|
data.imageUrlBack = null;
|
|
data.printAreaBack = null;
|
|
} else if (imageBackFile && imageBackFile.size > 0) {
|
|
data.imageUrlBack = await saveUploadedFile(imageBackFile, 'products');
|
|
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),
|
|
});
|
|
}
|
|
} else if (existing.imageUrlBack) {
|
|
// No new image uploaded, but existing image present — still update print area if custom
|
|
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');
|
|
}
|