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 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-18 15:42:30 +01:00
co-authored by Claude Haiku 4.5
parent 14fa3acc7a
commit 313943aeaa
8 changed files with 158 additions and 30 deletions
+21 -21
View File
@@ -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": {
+42
View File
@@ -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
@@ -56,6 +58,7 @@ model Product {
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[]
@@ -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
+4
View File
@@ -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,
})),
},
},
@@ -249,6 +249,15 @@ export async function GET(req: Request, { params }: { params: { id: string } })
<p><strong>Order ID:</strong> ${order.id}</p>
<p><strong>Product:</strong> ${item.productName}</p>
<p><strong>Color:</strong> ${item.color} ${item.size ? `| Size: ${item.size}` : ''}</p>
${item.designWidthCm && item.designHeightCm ? `<p><strong>Design Dimensions (Overall):</strong> ${item.designWidthCm} cm × ${item.designHeightCm} cm</p>` : ''}
${item.designImageDimensions ? (() => {
try {
const imageDims = JSON.parse(item.designImageDimensions);
return imageDims && imageDims.length > 0 ? `<p><strong>Image Dimensions:</strong><ul style="margin: 5px 0 0 20px;">${imageDims.map((img, idx) => `<li>Image ${idx + 1}: ${img.widthCm} cm × ${img.heightCm} cm</li>`).join('')}</ul></p>` : '';
} catch (e) {
return '';
}
})() : ''}
<p><strong>Order Date:</strong> ${new Date(order.createdAt).toLocaleDateString()}</p>
</div>
+1
View File
@@ -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' },
+3
View File
@@ -27,6 +27,9 @@ export default function AdminNav() {
<Link href="/admin/inbox" className="tag-label hover:text-ink">
Inbox
</Link>
<Link href="/admin/designs" className="tag-label hover:text-ink">
Design Approvals
</Link>
</div>
);
}
+67
View File
@@ -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: `<p>A new ${productName} design has been submitted for your review and approval.</p><p><a href="${adminLink}">${adminLink}</a></p>`,
});
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: `<p>${greeting}</p><p>Your ${productName} design has been approved! You can now add it to your cart and proceed to checkout.</p><p><a href="${checkoutLink}">${checkoutLink}</a></p><p>— Craft2Prints</p>`,
});
return { sent: true as const };
} catch (err) {
return { sent: false as const, reason: err instanceof Error ? err.message : 'Unknown error sending email' };
}
}
+2
View File
@@ -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 = {