'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__" for front, "colorPhotoBack__" 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> { const prefix = variant === 'front' ? 'colorPhoto__' : 'colorPhotoBack__'; const result: Record = {}; 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'); }