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