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": {
|
||||
"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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user