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": { "node_modules/baseline-browser-mapping": {
"version": "2.10.42", "version": "2.10.43",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
"integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -1155,9 +1155,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.5", "version": "4.28.6",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
"integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1176,9 +1176,9 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.42", "baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800", "caniuse-lite": "^1.0.30001803",
"electron-to-chromium": "^1.5.387", "electron-to-chromium": "^1.5.389",
"node-releases": "^2.0.50", "node-releases": "^2.0.51",
"update-browserslist-db": "^1.2.3" "update-browserslist-db": "^1.2.3"
}, },
"bin": { "bin": {
@@ -1239,9 +1239,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001803", "version": "1.0.30001806",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
"integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -1360,9 +1360,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.388", "version": "1.5.393",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.388.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz",
"integrity": "sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw==", "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -1847,9 +1847,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.15", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -1964,9 +1964,9 @@
} }
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.50", "version": "2.0.51",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
+51 -9
View File
@@ -39,6 +39,8 @@ model Product {
printAreaBack String? // same shape, for the back print area — only used when imageUrlBack is set 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 printAreaWidthMm Int @default(200) // width of the printable area in millimeters
printAreaHeightMm Int @default(280) // height 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 imageUrl String? // optional real product photo — shown instead of the illustration when set
imageUrlBack String? // optional back-view photo, toggled alongside the front photo imageUrlBack String? // optional back-view photo, toggled alongside the front photo
photoRecolorable Boolean @default(false) // if true, the photo is treated as a grayscale 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 saleStartsAt DateTime? // leave null to start the sale immediately
saleEndsAt DateTime? // leave null for an open-ended sale saleEndsAt DateTime? // leave null for an open-ended sale
orderItems OrderItem[] orderItems OrderItem[]
proofs DesignProof[] proofs DesignProof[]
customRequests CustomRequest[] customRequests CustomRequest[]
promotions Promotion[] // only relevant to promotions scoped to specific items, not "all items" designApprovals DesignApproval[]
collections Collection[] // occasion/recipient tags, e.g. "Christmas", "For Teachers" promotions Promotion[] // only relevant to promotions scoped to specific items, not "all items"
manualSaleItems ManualSaleItem[] collections Collection[] // occasion/recipient tags, e.g. "Christmas", "For Teachers"
stockLevels StockLevel[] manualSaleItems ManualSaleItem[]
purchaseItems PurchaseItem[] stockLevels StockLevel[]
productImages ProductImage[] purchaseItems PurchaseItem[]
productImages ProductImage[]
} }
// Bulk-uploaded product images with auto-detected colors. Supports any product type // Bulk-uploaded product images with auto-detected colors. Supports any product type
@@ -174,6 +177,41 @@ model Message {
createdAt DateTime @default(now()) 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 { model Order {
id String @id @default(cuid()) id String @id @default(cuid())
stripeSessionId String? @unique stripeSessionId String? @unique
@@ -207,6 +245,7 @@ model Customer {
passwordResetToken PasswordResetToken? passwordResetToken PasswordResetToken?
customRequests CustomRequest[] customRequests CustomRequest[]
manualSales ManualSale[] manualSales ManualSale[]
designApprovals DesignApproval[]
} }
// A sale that didn't go through the website checkout — cash at a fair, a bank // 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 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 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 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 // Audit log for admin logins — tracks when the admin panel is accessed
+4
View File
@@ -19,6 +19,8 @@ type CheckoutCartItem = {
designPreviewUrlBack: string | null; designPreviewUrlBack: string | null;
placementPreviewUrl: string | null; placementPreviewUrl: string | null;
placementPreviewUrlBack: string | null; placementPreviewUrlBack: string | null;
designWidthCm: number | null;
designHeightCm: number | null;
}; };
type GuestInfo = { type GuestInfo = {
@@ -111,6 +113,8 @@ export async function POST(req: Request) {
designPreviewUrlBack: i.designPreviewUrlBack, designPreviewUrlBack: i.designPreviewUrlBack,
placementPreviewUrl: i.placementPreviewUrl, placementPreviewUrl: i.placementPreviewUrl,
placementPreviewUrlBack: i.placementPreviewUrlBack, 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>Order ID:</strong> ${order.id}</p>
<p><strong>Product:</strong> ${item.productName}</p> <p><strong>Product:</strong> ${item.productName}</p>
<p><strong>Color:</strong> ${item.color} ${item.size ? `| Size: ${item.size}` : ''}</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> <p><strong>Order Date:</strong> ${new Date(order.createdAt).toLocaleDateString()}</p>
</div> </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('📡 Fetching /api/checkout with', items.length, 'items');
console.log('📦 Cart items:', JSON.stringify(items, null, 2));
const res = await fetch('/api/checkout', { const res = await fetch('/api/checkout', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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"> <Link href="/admin/inbox" className="tag-label hover:text-ink">
Inbox Inbox
</Link> </Link>
<Link href="/admin/designs" className="tag-label hover:text-ink">
Design Approvals
</Link>
</div> </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' }; 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; designPreviewUrlBack: string | null;
placementPreviewUrl: string | null; placementPreviewUrl: string | null;
placementPreviewUrlBack: string | null; placementPreviewUrlBack: string | null;
designWidthCm: number | null;
designHeightCm: number | null;
}; };
export type ProofDTO = { export type ProofDTO = {