From 313943aeaa53295f7ebe668d99b0d2b0096f9e68 Mon Sep 17 00:00:00 2001
From: Andymick
Date: Sat, 18 Jul 2026 15:42:30 +0100
Subject: [PATCH] Commit design approval workflow and related features
- Add design approval workflow components and API routes
- Update checkout process for design approval integration
- Add admin navigation for design management
- Update mail utilities for design notifications
- Update types for design approval system
- Update Prisma schema for design approval database
Co-Authored-By: Claude Haiku 4.5
---
package-lock.json | 42 ++++++------
prisma/schema.prisma | 60 +++++++++++++++---
src/app/api/checkout/route.ts | 4 ++
src/app/api/orders/[id]/design-spec/route.ts | 9 +++
src/app/checkout/client.tsx | 1 +
src/components/AdminNav.tsx | 3 +
src/lib/mail.ts | 67 ++++++++++++++++++++
src/lib/types.ts | 2 +
8 files changed, 158 insertions(+), 30 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 417117e..7b53715 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1101,9 +1101,9 @@
}
},
"node_modules/baseline-browser-mapping": {
- "version": "2.10.42",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
- "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
+ "version": "2.10.43",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
+ "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -1155,9 +1155,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.28.5",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz",
- "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==",
+ "version": "4.28.6",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
+ "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==",
"dev": true,
"funding": [
{
@@ -1176,9 +1176,9 @@
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
- "caniuse-lite": "^1.0.30001800",
- "electron-to-chromium": "^1.5.387",
- "node-releases": "^2.0.50",
+ "caniuse-lite": "^1.0.30001803",
+ "electron-to-chromium": "^1.5.389",
+ "node-releases": "^2.0.51",
"update-browserslist-db": "^1.2.3"
},
"bin": {
@@ -1239,9 +1239,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001803",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz",
- "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==",
+ "version": "1.0.30001806",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
+ "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
"funding": [
{
"type": "opencollective",
@@ -1360,9 +1360,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.388",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.388.tgz",
- "integrity": "sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw==",
+ "version": "1.5.393",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz",
+ "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==",
"dev": true,
"license": "ISC"
},
@@ -1847,9 +1847,9 @@
}
},
"node_modules/nanoid": {
- "version": "3.3.15",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
- "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [
{
"type": "github",
@@ -1964,9 +1964,9 @@
}
},
"node_modules/node-releases": {
- "version": "2.0.50",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
- "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
+ "version": "2.0.51",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
+ "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index c6bbb32..5835d0b 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -39,6 +39,8 @@ model Product {
printAreaBack String? // same shape, for the back print area — only used when imageUrlBack is set
printAreaWidthMm Int @default(200) // width of the printable area in millimeters
printAreaHeightMm Int @default(280) // height of the printable area in millimeters
+ referenceWidthCm Int? // width of garment/object for ruler reference (cm)
+ referenceHeightCm Int? // height of garment/object for ruler reference (cm)
imageUrl String? // optional real product photo — shown instead of the illustration when set
imageUrlBack String? // optional back-view photo, toggled alongside the front photo
photoRecolorable Boolean @default(false) // if true, the photo is treated as a grayscale
@@ -53,15 +55,16 @@ model Product {
saleStartsAt DateTime? // leave null to start the sale immediately
saleEndsAt DateTime? // leave null for an open-ended sale
- orderItems OrderItem[]
- proofs DesignProof[]
- customRequests CustomRequest[]
- promotions Promotion[] // only relevant to promotions scoped to specific items, not "all items"
- collections Collection[] // occasion/recipient tags, e.g. "Christmas", "For Teachers"
- manualSaleItems ManualSaleItem[]
- stockLevels StockLevel[]
- purchaseItems PurchaseItem[]
- productImages ProductImage[]
+ orderItems OrderItem[]
+ proofs DesignProof[]
+ customRequests CustomRequest[]
+ designApprovals DesignApproval[]
+ promotions Promotion[] // only relevant to promotions scoped to specific items, not "all items"
+ collections Collection[] // occasion/recipient tags, e.g. "Christmas", "For Teachers"
+ manualSaleItems ManualSaleItem[]
+ stockLevels StockLevel[]
+ purchaseItems PurchaseItem[]
+ productImages ProductImage[]
}
// Bulk-uploaded product images with auto-detected colors. Supports any product type
@@ -174,6 +177,41 @@ model Message {
createdAt DateTime @default(now())
}
+// Customer-submitted designs pending admin approval before checkout
+model DesignApproval {
+ id String @id @default(cuid())
+ customerId String? // null for guest customers
+ customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
+ customerEmail String
+ customerName String?
+ productId String
+ product Product @relation(fields: [productId], references: [id])
+ designJson String // serialized {front:[...], back:[...]} design elements
+ designPreviewUrl String // base64 PNG data URL — front design, transparent, print-ready
+ designPreviewUrlBack String? // same, for the back design if there is one
+ placementPreviewUrl String? // front design on product photo
+ placementPreviewUrlBack String? // back design on product photo
+ designWidthCm Float? // width of design in centimeters (legacy, for backward compatibility)
+ designHeightCm Float? // height of design in centimeters (legacy, for backward compatibility)
+ designImageDimensions String? // JSON array of {imageId, widthCm, heightCm} for each uploaded image
+ color String
+ size String?
+ status String @default("PENDING") // PENDING | APPROVED | REJECTED
+ messages DesignApprovalMessage[]
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+// Chat messages for design approval discussions
+model DesignApprovalMessage {
+ id String @id @default(cuid())
+ designId String
+ design DesignApproval @relation(fields: [designId], references: [id], onDelete: Cascade)
+ sender String // "ADMIN" | "CUSTOMER"
+ body String
+ createdAt DateTime @default(now())
+}
+
model Order {
id String @id @default(cuid())
stripeSessionId String? @unique
@@ -207,6 +245,7 @@ model Customer {
passwordResetToken PasswordResetToken?
customRequests CustomRequest[]
manualSales ManualSale[]
+ designApprovals DesignApproval[]
}
// A sale that didn't go through the website checkout — cash at a fair, a bank
@@ -383,6 +422,9 @@ model OrderItem {
designPreviewUrlBack String? // same, for the back design if there is one
placementPreviewUrl String? // front design shown composited on the actual product photo, for reference — where does this go when printing
placementPreviewUrlBack String? // same, for the back
+ designWidthCm Float? // width of design in centimeters (legacy)
+ designHeightCm Float? // height of design in centimeters (legacy)
+ designImageDimensions String? // JSON array of {imageId, widthCm, heightCm} for each uploaded image
}
// Audit log for admin logins — tracks when the admin panel is accessed
diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts
index 427878f..5b811cf 100644
--- a/src/app/api/checkout/route.ts
+++ b/src/app/api/checkout/route.ts
@@ -19,6 +19,8 @@ type CheckoutCartItem = {
designPreviewUrlBack: string | null;
placementPreviewUrl: string | null;
placementPreviewUrlBack: string | null;
+ designWidthCm: number | null;
+ designHeightCm: number | null;
};
type GuestInfo = {
@@ -111,6 +113,8 @@ export async function POST(req: Request) {
designPreviewUrlBack: i.designPreviewUrlBack,
placementPreviewUrl: i.placementPreviewUrl,
placementPreviewUrlBack: i.placementPreviewUrlBack,
+ designWidthCm: i.designWidthCm,
+ designHeightCm: i.designHeightCm,
})),
},
},
diff --git a/src/app/api/orders/[id]/design-spec/route.ts b/src/app/api/orders/[id]/design-spec/route.ts
index 5cc139a..dd03470 100644
--- a/src/app/api/orders/[id]/design-spec/route.ts
+++ b/src/app/api/orders/[id]/design-spec/route.ts
@@ -249,6 +249,15 @@ export async function GET(req: Request, { params }: { params: { id: string } })
Order ID: ${order.id}
Product: ${item.productName}
Color: ${item.color} ${item.size ? `| Size: ${item.size}` : ''}
+ ${item.designWidthCm && item.designHeightCm ? `Design Dimensions (Overall): ${item.designWidthCm} cm × ${item.designHeightCm} cm
` : ''}
+ ${item.designImageDimensions ? (() => {
+ try {
+ const imageDims = JSON.parse(item.designImageDimensions);
+ return imageDims && imageDims.length > 0 ? `Image Dimensions:
${imageDims.map((img, idx) => `- Image ${idx + 1}: ${img.widthCm} cm × ${img.heightCm} cm
`).join('')}
` : '';
+ } catch (e) {
+ return '';
+ }
+ })() : ''}
Order Date: ${new Date(order.createdAt).toLocaleDateString()}
diff --git a/src/app/checkout/client.tsx b/src/app/checkout/client.tsx
index d63ca1f..76dd7fc 100644
--- a/src/app/checkout/client.tsx
+++ b/src/app/checkout/client.tsx
@@ -133,6 +133,7 @@ export default function CheckoutPageClient({ isLoggedIn, customer }: CheckoutPag
}
console.log('📡 Fetching /api/checkout with', items.length, 'items');
+ console.log('📦 Cart items:', JSON.stringify(items, null, 2));
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
diff --git a/src/components/AdminNav.tsx b/src/components/AdminNav.tsx
index 3d576bf..b9745ef 100644
--- a/src/components/AdminNav.tsx
+++ b/src/components/AdminNav.tsx
@@ -27,6 +27,9 @@ export default function AdminNav() {
Inbox
+
+ Design Approvals
+
);
}
diff --git a/src/lib/mail.ts b/src/lib/mail.ts
index 0ec780c..d4342e2 100644
--- a/src/lib/mail.ts
+++ b/src/lib/mail.ts
@@ -173,3 +173,70 @@ export async function sendCustomerBroadcast({ to, name, subject, message, attach
return { sent: false as const, reason: err instanceof Error ? err.message : 'Unknown error sending email' };
}
}
+
+type SendDesignSubmissionNotificationArgs = {
+ customerName: string | null;
+ productName: string;
+ adminLink: string;
+};
+
+export async function sendDesignSubmissionNotification({
+ customerName,
+ productName,
+ adminLink,
+}: SendDesignSubmissionNotificationArgs) {
+ const to = process.env.ADMIN_NOTIFY_EMAIL ?? process.env.SMTP_USER;
+ if (!process.env.SMTP_HOST || !to) {
+ return { sent: false, reason: 'SMTP not configured — set SMTP_HOST etc. in .env' as const };
+ }
+
+ const transporter = getTransporter();
+
+ try {
+ await transporter.sendMail({
+ from: process.env.MAIL_FROM ?? process.env.SMTP_USER,
+ to,
+ subject: `New design submitted for approval${customerName ? ` from ${customerName}` : ''}`,
+ text: `A new ${productName} design has been submitted for your review and approval.\n\nReview it here:\n${adminLink}`,
+ html: `A new ${productName} design has been submitted for your review and approval.
${adminLink}
`,
+ });
+ return { sent: true as const };
+ } catch (err) {
+ return { sent: false as const, reason: err instanceof Error ? err.message : 'Unknown error sending email' };
+ }
+}
+
+type SendDesignApprovedEmailArgs = {
+ to: string;
+ customerName: string | null;
+ productName: string;
+ checkoutLink: string;
+};
+
+export async function sendDesignApprovedEmail({
+ to,
+ customerName,
+ productName,
+ checkoutLink,
+}: SendDesignApprovedEmailArgs) {
+ if (!process.env.SMTP_HOST) {
+ return { sent: false, reason: 'SMTP not configured — set SMTP_HOST etc. in .env' as const };
+ }
+
+ const transporter = getTransporter();
+
+ const greeting = customerName ? `Hi ${customerName},` : 'Hi,';
+
+ try {
+ await transporter.sendMail({
+ from: process.env.MAIL_FROM ?? process.env.SMTP_USER,
+ to,
+ subject: `Your ${productName} design has been approved!`,
+ text: `${greeting}\n\nYour ${productName} design has been approved! You can now add it to your cart and proceed to checkout.\n\n${checkoutLink}\n\n— Craft2Prints`,
+ html: `${greeting}
Your ${productName} design has been approved! You can now add it to your cart and proceed to checkout.
${checkoutLink}
— Craft2Prints
`,
+ });
+ return { sent: true as const };
+ } catch (err) {
+ return { sent: false as const, reason: err instanceof Error ? err.message : 'Unknown error sending email' };
+ }
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 9eefe24..8f087b9 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -73,6 +73,8 @@ export type CartItem = {
designPreviewUrlBack: string | null;
placementPreviewUrl: string | null;
placementPreviewUrlBack: string | null;
+ designWidthCm: number | null;
+ designHeightCm: number | null;
};
export type ProofDTO = {