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:
co-authored by
Claude Haiku 4.5
parent
14fa3acc7a
commit
313943aeaa
Generated
+21
-21
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user