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:
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Product" ADD COLUMN "printAreaHeightMm" INTEGER NOT NULL DEFAULT 280,
|
||||||
|
ADD COLUMN "printAreaWidthMm" INTEGER NOT NULL DEFAULT 200;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user