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:
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.
`, + }); + 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.
— 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 = {