Add product dimensions and downloadable design specification sheet

- Add printAreaWidthMm and printAreaHeightMm fields to Product model
- Create API endpoint to generate design specs with CM measurements
- Add download button to order detail page
- Specs include design elements with positions/sizes in centimeters
This commit is contained in:
Andymick
2026-07-18 10:29:37 +01:00
parent ac61d95ac8
commit 4d9cfa4023
4 changed files with 269 additions and 0 deletions
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "printAreaHeightMm" INTEGER NOT NULL DEFAULT 280,
ADD COLUMN "printAreaWidthMm" INTEGER NOT NULL DEFAULT 200;
+2
View File
@@ -37,6 +37,8 @@ model Product {
mockup String // which SVG mockup component to render, e.g. "tshirt" | "hoodie" | "mug" | "phonecase" mockup String // which SVG mockup component to render, e.g. "tshirt" | "hoodie" | "mug" | "phonecase"
printArea String // JSON: {"xPct":..,"yPct":..,"wPct":..,"hPct":..} printArea String // JSON: {"xPct":..,"yPct":..,"wPct":..,"hPct":..}
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
printAreaHeightMm Int @default(280) // height of the printable area in millimeters
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
+6
View File
@@ -23,6 +23,12 @@ export default async function OrderDetailPage({ params }: { params: { id: string
<h1 className="font-display text-3xl">Order</h1> <h1 className="font-display text-3xl">Order</h1>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{order.archived && <span className="tag-label">Archived</span>} {order.archived && <span className="tag-label">Archived</span>}
<a
href={`/api/orders/${order.id}/design-spec`}
className="tag-label border border-line px-3 py-1.5 hover:border-clay hover:text-clay"
>
Download specs
</a>
<form action={order.archived ? unarchiveOrder : archiveOrder}> <form action={order.archived ? unarchiveOrder : archiveOrder}>
<input type="hidden" name="id" value={order.id} /> <input type="hidden" name="id" value={order.id} />
<button type="submit" className="tag-label border border-line px-3 py-1.5 hover:border-clay hover:text-clay"> <button type="submit" className="tag-label border border-line px-3 py-1.5 hover:border-clay hover:text-clay">
@@ -0,0 +1,258 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(req: Request, { params }: { params: { id: string } }) {
try {
const { id } = params;
const order = await prisma.order.findUnique({
where: { id },
include: { items: true },
});
if (!order) {
return NextResponse.json({ error: 'Order not found' }, { status: 404 });
}
const item = order.items[0];
if (!item) {
return NextResponse.json({ error: 'No items in order' }, { status: 400 });
}
const design = JSON.parse(item.designJson) as { front: any[]; back: any[] };
const product = await prisma.product.findUnique({
where: { id: item.productId },
});
if (!product) {
return NextResponse.json({ error: 'Product not found' }, { status: 404 });
}
// Generate HTML/CSS for a printable spec sheet
const printAreaWidthMm = product.printAreaWidthMm || 200;
const printAreaHeightMm = product.printAreaHeightMm || 280;
// Calculate scale factor: pixels to millimeters
const DISPLAY = 560; // Same as in DesignCanvas
const printArea = JSON.parse(product.printArea) as {
xPct: number;
yPct: number;
wPct: number;
hPct: number;
};
const printAreaWidthPx = DISPLAY * (printArea.wPct / 100);
const printAreaHeightPx = DISPLAY * (printArea.hPct / 100);
const pxPerMm = printAreaWidthPx / printAreaWidthMm;
// Convert pixel positions to CM
const convertToCm = (px: number) => (px / pxPerMm / 10).toFixed(1);
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Design Specification - Order ${order.id}</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
max-width: 1000px;
margin: 0 auto;
}
h1 { font-size: 24px; margin-bottom: 10px; }
.order-info {
background: #f5f5f5;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.order-info p {
margin: 5px 0;
font-size: 14px;
}
.spec-section {
margin-top: 30px;
page-break-inside: avoid;
}
.spec-section h2 {
font-size: 18px;
border-bottom: 2px solid #333;
padding-bottom: 10px;
margin-bottom: 15px;
}
.mockup-container {
text-align: center;
margin: 20px 0;
}
.mockup-image {
max-width: 100%;
height: auto;
border: 1px solid #ddd;
margin: 10px 0;
}
.measurements-table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
font-size: 14px;
}
.measurements-table th,
.measurements-table td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.measurements-table th {
background: #f0f0f0;
font-weight: bold;
}
.element-type {
font-weight: bold;
color: #333;
}
.print-area-info {
background: #e8f4f8;
padding: 10px;
border-radius: 5px;
margin: 15px 0;
font-size: 13px;
}
@media print {
body { padding: 0; }
.spec-section { page-break-inside: avoid; }
}
</style>
</head>
<body>
<h1>Design Specification Sheet</h1>
<div class="order-info">
<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>
<p><strong>Order Date:</strong> ${new Date(order.createdAt).toLocaleDateString()}</p>
</div>
${design.front && design.front.length > 0 ? `
<div class="spec-section">
<h2>Front Design</h2>
<div class="print-area-info">
<strong>Printable Area:</strong> ${printAreaWidthMm}mm × ${printAreaHeightMm}mm (${convertToCm(printAreaWidthPx)} cm × ${convertToCm(printAreaHeightPx)} cm)
</div>
${item.placementPreviewUrl ? `
<div class="mockup-container">
<p><em>Design on product (reference only)</em></p>
<img src="${item.placementPreviewUrl}" alt="Front design placement" class="mockup-image" style="max-height: 400px;">
</div>
` : ''}
<table class="measurements-table">
<thead>
<tr>
<th>Element</th>
<th>Type</th>
<th>Position (CM)</th>
<th>Size (CM)</th>
<th>Details</th>
</tr>
</thead>
<tbody>
${(design.front as any[]).map((el, idx) => `
<tr>
<td>${idx + 1}</td>
<td class="element-type">${el.type === 'text' ? '📝 Text' : '🖼️ Image'}</td>
<td>X: ${convertToCm(el.x)} | Y: ${convertToCm(el.y)}</td>
<td>${el.type === 'image' ? `${convertToCm(el.width)} × ${convertToCm(el.height)}` : 'N/A'}</td>
<td>
${el.type === 'text' ? `
Font: ${el.fontFamily}, ${Math.round(el.fontSize / pxPerMm * 10 / 10)}pt<br>
Color: ${el.fill}<br>
Text: "${el.text}"<br>
Rotation: ${Math.round(el.rotation)}°
` : `
Rotation: ${Math.round(el.rotation)}°
`}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
` : ''}
${design.back && design.back.length > 0 ? `
<div class="spec-section">
<h2>Back Design</h2>
<div class="print-area-info">
<strong>Printable Area:</strong> ${printAreaWidthMm}mm × ${printAreaHeightMm}mm (${convertToCm(printAreaWidthPx)} cm × ${convertToCm(printAreaHeightPx)} cm)
</div>
${item.placementPreviewUrlBack ? `
<div class="mockup-container">
<p><em>Design on product (reference only)</em></p>
<img src="${item.placementPreviewUrlBack}" alt="Back design placement" class="mockup-image" style="max-height: 400px;">
</div>
` : ''}
<table class="measurements-table">
<thead>
<tr>
<th>Element</th>
<th>Type</th>
<th>Position (CM)</th>
<th>Size (CM)</th>
<th>Details</th>
</tr>
</thead>
<tbody>
${(design.back as any[]).map((el, idx) => `
<tr>
<td>${idx + 1}</td>
<td class="element-type">${el.type === 'text' ? '📝 Text' : '🖼️ Image'}</td>
<td>X: ${convertToCm(el.x)} | Y: ${convertToCm(el.y)}</td>
<td>${el.type === 'image' ? `${convertToCm(el.width)} × ${convertToCm(el.height)}` : 'N/A'}</td>
<td>
${el.type === 'text' ? `
Font: ${el.fontFamily}, ${Math.round(el.fontSize / pxPerMm * 10 / 10)}pt<br>
Color: ${el.fill}<br>
Text: "${el.text}"<br>
Rotation: ${Math.round(el.rotation)}°
` : `
Rotation: ${Math.round(el.rotation)}°
`}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
` : ''}
<div class="spec-section" style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #ccc; font-size: 12px; color: #666;">
<p><strong>Notes:</strong></p>
<ul>
<li>All measurements are in centimeters (CM)</li>
<li>Coordinates are measured from top-left of the printable area</li>
<li>Print the design files separately for production</li>
<li>This specification sheet is for reference and planning only</li>
</ul>
</div>
</body>
</html>
`;
return new NextResponse(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Content-Disposition': `attachment; filename="design-spec-${order.id}.html"`,
},
});
} catch (err) {
console.error('Error generating design spec:', err);
return NextResponse.json({ error: 'Failed to generate specification' }, { status: 500 });
}
}