Files
craft2prints/src/components/DesignCanvas.tsx
T
AndymickandClaude Haiku 4.5 9f6dcccaa8 Re-add dragging support for curved text via Group wrapper
- Wrap TextPath in Group to enable dragging while preserving text on toggle
- Group handles x/y positioning and drag events
- TextPath is non-listening so Group events take priority
- Curved text can now be selected, dragged, and unchecked without losing content

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-18 15:20:36 +01:00

1502 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useRef, useState, useEffect, useLayoutEffect, useCallback } from 'react';
import { Stage, Layer, Text, TextPath, Image as KonvaImage, Transformer, Rect, Group } from 'react-konva';
import type Konva from 'konva';
import { v4 as uuid } from 'uuid';
import ProductMockup from './ProductMockup';
import Price from './Price';
import LoginPromptModal from './LoginPromptModal';
import { useCart } from '@/lib/cartStore';
import { getEffectivePrice } from '@/lib/pricing';
import { checkCustomerAuth } from '@/app/actions';
import type { DesignElement, PrintArea, ProductDTO } from '@/lib/types';
const DISPLAY = 560; // css px size the mockup + stage are rendered at
const CAPTURE_SCALE = 2; // matches pixelRatio used for exports, for crisp downloads
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new window.Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
// Draws `img` into the canvas region like CSS `object-fit: cover` would.
function drawCover(ctx: CanvasRenderingContext2D, img: HTMLImageElement, dw: number, dh: number) {
const imgRatio = img.width / img.height;
const boxRatio = dw / dh;
let sx: number, sy: number, sw: number, sh: number;
if (imgRatio > boxRatio) {
sh = img.height;
sw = sh * boxRatio;
sx = (img.width - sw) / 2;
sy = 0;
} else {
sw = img.width;
sh = sw / boxRatio;
sx = 0;
sy = (img.height - sh) / 2;
}
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, dw, dh);
}
// Builds a reference image showing the design placed on the actual product photo
// (garment + artwork composited together) — separate from the clean, print-ready,
// transparent-background export. Returns null if there's no real photo to composite
// onto (illustration-only products skip this; there's nothing photographic to show).
async function composePlacementImage(
photoUrl: string,
recolor: { tint: boolean; color: string } | null,
stage: Konva.Stage,
box: { left: number; top: number; w: number; h: number },
): Promise<string> {
const canvas = document.createElement('canvas');
canvas.width = DISPLAY * CAPTURE_SCALE;
canvas.height = DISPLAY * CAPTURE_SCALE;
const ctx = canvas.getContext('2d');
if (!ctx) return '';
const photoImg = await loadImage(photoUrl);
if (recolor?.tint) {
ctx.filter = 'grayscale(1) brightness(2.1) contrast(1.15)';
drawCover(ctx, photoImg, canvas.width, canvas.height);
ctx.filter = 'none';
ctx.globalCompositeOperation = 'multiply';
ctx.fillStyle = recolor.color;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.globalCompositeOperation = 'source-over';
} else {
drawCover(ctx, photoImg, canvas.width, canvas.height);
}
const designCanvas = stage.toCanvas({ pixelRatio: CAPTURE_SCALE });
ctx.drawImage(
designCanvas,
box.left * CAPTURE_SCALE,
box.top * CAPTURE_SCALE,
box.w * CAPTURE_SCALE,
box.h * CAPTURE_SCALE,
);
return canvas.toDataURL('image/png');
}
const FONTS = [
'Inter',
'Fraunces',
'Caveat',
'Playfair Display',
'Oswald',
'Bebas Neue',
'Pacifico',
'Permanent Marker',
'JetBrains Mono',
'Georgia',
'Arial',
];
function UploadedImage({
el,
onSelect,
onChange,
shapeRef,
}: {
el: Extract<DesignElement, { type: 'image' }>;
onSelect: () => void;
onChange: (attrs: Partial<DesignElement>) => void;
shapeRef: (node: Konva.Image | null) => void;
}) {
const [img, setImg] = useState<HTMLImageElement | null>(null);
useEffect(() => {
const image = new window.Image();
image.src = el.src;
image.onload = () => setImg(image);
}, [el.src]);
if (!img) return null;
return (
<KonvaImage
ref={shapeRef}
image={img}
x={el.x}
y={el.y}
width={el.width}
height={el.height}
rotation={el.rotation}
draggable
listening={true}
onClick={onSelect}
onTap={onSelect}
onDragEnd={(e) => onChange({ x: e.target.x(), y: e.target.y() })}
onTransformEnd={(e) => {
const node = e.target;
onChange({
x: node.x(),
y: node.y(),
rotation: node.rotation(),
width: Math.max(10, node.width() * node.scaleX()),
height: Math.max(10, node.height() * node.scaleY()),
});
node.scaleX(1);
node.scaleY(1);
}}
/>
);
}
// One print surface (photo + the draggable design layer on top). Front and back
// each get their own instance, both always mounted (just shown/hidden with CSS)
// so their Konva stages stay exportable even when you're looking at the other side.
function PrintSurface({
photo,
printArea,
elements,
selectedId,
setSelectedId,
updateElement,
nodeRefs,
stageRef,
transformerRef,
visible,
caption,
onNodeReady,
scale,
product,
}: {
photo: React.ReactNode;
printArea: PrintArea;
elements: DesignElement[];
selectedId: string | null;
setSelectedId: (id: string | null) => void;
updateElement: (id: string, attrs: Partial<DesignElement>) => void;
nodeRefs: React.MutableRefObject<Map<string, Konva.Node>>;
stageRef: React.RefObject<Konva.Stage>;
transformerRef: React.RefObject<Konva.Transformer>;
visible: boolean;
caption: string;
onNodeReady: () => void;
scale: number;
product: ProductDTO;
}) {
const stageW = printArea.wPct * DISPLAY;
const stageH = printArea.hPct * DISPLAY;
const stageLeft = printArea.xPct * DISPLAY;
const stageTop = printArea.yPct * DISPLAY;
return (
<div className={visible ? '' : 'hidden'}>
{/* Konva's Stage stays a fixed 560x560 internally so element coordinates,
print-area math, and export resolution never change — only the visual
box shrinks via CSS transform to fit narrow viewports. */}
<div style={{ width: DISPLAY * scale, height: DISPLAY * scale }}>
<div
className="relative border border-line bg-surface"
style={{ width: DISPLAY, height: DISPLAY, transform: `scale(${scale})`, transformOrigin: 'top left' }}
>
<div className="pointer-events-none absolute inset-0" style={{ width: DISPLAY, height: DISPLAY }}>
{photo}
</div>
{/* Horizontal Ruler */}
{product.referenceWidthCm && product.referenceWidthCm > 0 && (
<div
className="absolute pointer-events-none bg-paper border-b border-line text-xs font-mono"
style={{
left: 0,
top: -20,
width: DISPLAY,
height: 18,
display: 'flex',
alignItems: 'flex-end',
paddingBottom: '2px',
}}
>
{(() => {
const marks = [];
const pxPerCm = DISPLAY / product.referenceWidthCm;
for (let cm = 0; cm <= Math.ceil(product.referenceWidthCm); cm += 2) {
const px = cm * pxPerCm;
if (px > DISPLAY) break;
marks.push(
<div
key={`h-${cm}`}
style={{
position: 'absolute',
left: `${px}px`,
fontSize: '9px',
color: '#888',
transform: 'translateX(-50%)',
whiteSpace: 'nowrap',
}}
>
{cm}
</div>
);
}
return marks;
})()}
</div>
)}
{/* Vertical Ruler */}
{product.referenceHeightCm && product.referenceHeightCm > 0 && (
<div
className="absolute pointer-events-none bg-paper border-r border-line text-xs font-mono"
style={{
left: -28,
top: 0,
width: 26,
height: DISPLAY,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
paddingRight: '4px',
}}
>
{(() => {
const marks = [];
const pxPerCm = DISPLAY / product.referenceHeightCm;
for (let cm = 0; cm <= Math.ceil(product.referenceHeightCm); cm += 2) {
const px = cm * pxPerCm;
if (px > DISPLAY) break;
marks.push(
<div
key={`v-${cm}`}
style={{
position: 'absolute',
top: `${px}px`,
fontSize: '9px',
color: '#888',
transform: 'translateY(-50%)',
whiteSpace: 'nowrap',
}}
>
{cm}
</div>
);
}
return marks;
})()}
</div>
)}
<div className="absolute" style={{ left: stageLeft, top: stageTop, width: stageW, height: stageH }}>
<Stage
ref={stageRef}
width={stageW}
height={stageH}
onMouseDown={(e) => {
if (e.target === e.target.getStage()) setSelectedId(null);
}}
>
<Layer>
<Rect
name="printGuide"
x={0}
y={0}
width={stageW}
height={stageH}
stroke="#E5227E"
dash={[10, 6]}
strokeWidth={3}
shadowColor="#E5227E"
shadowBlur={12}
shadowOpacity={0.5}
listening={false}
/>
<Rect
name="printGuide"
x={0}
y={0}
width={stageW}
height={stageH}
fill="#E5227E"
opacity={0.07}
listening={false}
/>
{elements.map((el) =>
el.type === 'text' ? (
el.curvedPath ? (
// Curved text path wrapped in draggable Group
<Group
key={el.id}
ref={(node) => {
if (node) nodeRefs.current.set(el.id, node);
}}
x={el.x}
y={el.y}
draggable
listening={true}
onClick={() => setSelectedId(el.id)}
onTap={() => setSelectedId(el.id)}
onDragEnd={(e) => updateElement(el.id, { x: e.target.x(), y: e.target.y() })}
>
<TextPath
data={generateCurvePath(el.curvedPath.type, el.curvedPath.radius, el.curvedPath.reverse)}
text={el.text}
fontFamily={el.fontFamily}
fontSize={el.fontSize}
fill={el.fill}
listening={false}
/>
</Group>
) : (
// Regular straight text
<Text
key={el.id}
ref={(node) => {
if (node) nodeRefs.current.set(el.id, node);
}}
x={el.x}
y={el.y}
text={el.text}
fontFamily={el.fontFamily}
fontSize={el.fontSize}
fill={el.fill}
rotation={el.rotation}
draggable
listening={true}
onClick={() => setSelectedId(el.id)}
onTap={() => setSelectedId(el.id)}
onDragEnd={(e) => updateElement(el.id, { x: e.target.x(), y: e.target.y() })}
onTransformEnd={(e) => {
const node = e.target;
updateElement(el.id, {
x: node.x(),
y: node.y(),
rotation: node.rotation(),
fontSize: Math.max(8, el.fontSize * node.scaleX()),
});
node.scaleX(1);
node.scaleY(1);
}}
/>
)
) : (
<UploadedImage
key={el.id}
el={el}
onSelect={() => setSelectedId(el.id)}
onChange={(attrs) => updateElement(el.id, attrs)}
shapeRef={(node) => {
if (node && nodeRefs.current.get(el.id) !== node) {
nodeRefs.current.set(el.id, node);
onNodeReady();
}
}}
/>
),
)}
<Transformer
ref={transformerRef}
rotateEnabled
flipEnabled={false}
rotateAnchorOffset={30}
anchorCornerRadius={4}
anchorSize={8}
borderStroke="#17181C"
borderStrokeWidth={2}
anchorStroke="#17181C"
anchorFill="#FFF"
boundBoxFunc={(oldBox, newBox) => (newBox.width < 8 || newBox.height < 8 ? oldBox : newBox)}
/>
</Layer>
</Stage>
</div>
</div>
</div>
<p className="tag-label mt-3">{caption}</p>
</div>
);
}
// Generate SVG path for curved text based on type and radius
function generateCurvePath(type: string, radius: number, reverse: boolean = false): string {
const cx = 280; // center x (middle of 560px canvas)
const cy = 280; // center y
const r = radius;
let path = '';
switch (type) {
case 'circle':
// Full circle arc from bottom-left to top-right
const large = 0; // arc flag
const sweep = reverse ? 0 : 1;
path = `M ${cx - r} ${cy} A ${r} ${r} 0 ${large} ${sweep} ${cx + r} ${cy}`;
break;
case 'wave':
// Wave pattern
const waveHeight = radius / 3;
const waveLength = radius * 1.5;
path = `M ${cx - waveLength} ${cy}`;
for (let x = cx - waveLength; x <= cx + waveLength; x += 10) {
const y = cy + Math.sin((x - (cx - waveLength)) / waveLength * Math.PI * 2) * waveHeight;
path += ` L ${x} ${y}`;
}
break;
case 'spiral':
// Spiral arc
const spiralTurns = 1.5;
const spiralSteps = 100;
path = `M ${cx} ${cy - radius}`;
for (let i = 1; i <= spiralSteps; i++) {
const angle = (i / spiralSteps) * Math.PI * 2 * spiralTurns;
const r = radius * (1 - i / spiralSteps);
const x = cx + r * Math.cos(angle);
const y = cy - r * Math.sin(angle);
path += ` L ${x} ${y}`;
}
break;
case 'arc':
// Simple arc
path = `M ${cx - radius} ${cy} A ${radius} ${radius} 0 0 ${reverse ? 0 : 1} ${cx + radius} ${cy}`;
break;
default:
path = `M 0 ${cy} L 560 ${cy}`; // fallback: straight line
}
return path;
}
export default function DesignCanvas({ product }: { product: ProductDTO }) {
const [color, setColor] = useState(product.colors[0]);
const [size, setSize] = useState<string | null>(product.sizes[0] ?? null);
const [designWidthCm, setDesignWidthCm] = useState<number | null>(null);
const [designHeightCm, setDesignHeightCm] = useState<number | null>(null);
const [view, setView] = useState<'front' | 'back'>('front');
const [elementsFront, setElementsFront] = useState<DesignElement[]>([]);
const [elementsBack, setElementsBack] = useState<DesignElement[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [quantity, setQuantity] = useState(1);
const [justAdded, setJustAdded] = useState(false);
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [nodeReadyTick, setNodeReadyTick] = useState(0);
const [approvalStatus, setApprovalStatus] = useState<'PENDING' | 'APPROVED' | 'REJECTED' | null>(null);
const [submittingForApproval, setSubmittingForApproval] = useState(false);
const [designApprovalId, setDesignApprovalId] = useState<string | null>(null);
const [submissionMessage, setSubmissionMessage] = useState<string | null>(null);
const [imageDimensions, setImageDimensions] = useState<Record<string, { widthCm: number | null; heightCm: number | null }>>({});
const stageFrontRef = useRef<Konva.Stage | null>(null);
const stageBackRef = useRef<Konva.Stage | null>(null);
const transformerFrontRef = useRef<Konva.Transformer | null>(null);
const transformerBackRef = useRef<Konva.Transformer | null>(null);
const nodeRefs = useRef<Map<string, Konva.Node>>(new Map());
const fileInputRef = useRef<HTMLInputElement | null>(null);
const canvasContainerRef = useRef<HTMLDivElement | null>(null);
const [canvasScale, setCanvasScale] = useState(1);
const addItem = useCart((s) => s.addItem);
// Konva's Stage keeps a fixed 560x560 internal pixel size (element x/y,
// print-area math, and export resolution all key off DISPLAY) — on narrow
// viewports we shrink it purely visually via CSS transform instead of
// touching that coordinate system. Konva reads the container's actual
// rendered (post-transform) bounding box for pointer math, so clicks/drags
// still line up correctly at any scale.
useEffect(() => {
if (typeof window === 'undefined') return;
// Load approval status
const approvalSaved = localStorage.getItem(`designApproval_${product.id}`);
if (approvalSaved) {
try {
const data = JSON.parse(approvalSaved);
setDesignApprovalId(data.id);
setApprovalStatus(data.status);
console.log('✓ Approval status restored');
} catch (e) {
console.error('Failed to load approval status:', e);
}
}
// Load design draft (saved when user tried to checkout without login)
const designDraft = localStorage.getItem(`designDraft_${product.id}`);
if (designDraft) {
try {
const data = JSON.parse(designDraft);
console.log('📋 Found design draft, restoring...', data);
if (data.elementsFront && data.elementsFront.length > 0) {
setElementsFront(data.elementsFront);
console.log('✓ Restored', data.elementsFront.length, 'front elements');
}
if (data.elementsBack && data.elementsBack.length > 0) {
setElementsBack(data.elementsBack);
console.log('✓ Restored', data.elementsBack.length, 'back elements');
}
if (data.color) setColor(data.color);
if (data.size) setSize(data.size);
if (data.designWidthCm !== null && data.designWidthCm !== undefined) setDesignWidthCm(data.designWidthCm);
if (data.designHeightCm !== null && data.designHeightCm !== undefined) setDesignHeightCm(data.designHeightCm);
if (data.imageDimensions) setImageDimensions(data.imageDimensions);
if (data.quantity) setQuantity(data.quantity);
console.log('✅ Design fully restored from draft');
// Keep the draft in localStorage until user completes checkout
// Only remove after successful add to bag
} catch (e) {
console.error('Failed to load design draft:', e);
}
}
}, [product.id]);
useLayoutEffect(() => {
const el = canvasContainerRef.current;
if (!el) return;
const update = () => setCanvasScale(Math.min(1, el.clientWidth / DISPLAY));
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, []);
const hasBack = Boolean(product.imageUrlBack);
const canPersonalize = product.showOnPersonalised;
const frontPrintArea = product.printArea;
const backPrintArea = product.printAreaBack ?? product.printArea;
const activePrintArea = view === 'front' ? frontPrintArea : backPrintArea;
const activeStageW = activePrintArea.wPct * DISPLAY;
const activeStageH = activePrintArea.hPct * DISPLAY;
// Calculate cm per pixel for display using product reference size
// Reference size maps to full canvas width/height (560px)
const cmPerPx = product.referenceWidthCm && product.referenceWidthCm > 0 ? product.referenceWidthCm / DISPLAY : 0;
const pxToCm = (px: number) => {
if (!cmPerPx || cmPerPx <= 0) return '—';
const cm = px * cmPerPx;
return isNaN(cm) ? '?' : cm.toFixed(2);
};
const setActiveElements = view === 'front' ? setElementsFront : setElementsBack;
// Keep whichever transformer actually owns the selected element in sync, and
// clear the other one — an id only ever lives in one of the two arrays.
// nodeReadyTick re-runs this once an uploaded image's Konva node actually
// mounts (it loads asynchronously, unlike text, so it isn't there yet on the
// same render where it gets selected).
useEffect(() => {
const frontNode = selectedId && elementsFront.some((e) => e.id === selectedId) ? nodeRefs.current.get(selectedId) : null;
const backNode = selectedId && elementsBack.some((e) => e.id === selectedId) ? nodeRefs.current.get(selectedId) : null;
if (transformerFrontRef.current) {
transformerFrontRef.current.nodes(frontNode ? [frontNode] : []);
transformerFrontRef.current.getLayer()?.draw();
}
if (transformerBackRef.current) {
transformerBackRef.current.nodes(backNode ? [backNode] : []);
transformerBackRef.current.getLayer()?.draw();
}
}, [selectedId, elementsFront.length, elementsBack.length, nodeReadyTick]);
// Canvas text can render with a fallback font if drawn before a web font finishes
// loading — redraw once fonts are ready so custom font choices show up correctly.
useEffect(() => {
document.fonts?.ready.then(() => {
stageFrontRef.current?.getLayers().forEach((layer) => layer.batchDraw());
stageBackRef.current?.getLayers().forEach((layer) => layer.batchDraw());
});
}, []);
const updateElement = useCallback((id: string, attrs: Partial<DesignElement>) => {
setElementsFront((prev) =>
prev.some((el) => el.id === id) ? prev.map((el) => (el.id === id ? ({ ...el, ...attrs } as DesignElement) : el)) : prev,
);
setElementsBack((prev) =>
prev.some((el) => el.id === id) ? prev.map((el) => (el.id === id ? ({ ...el, ...attrs } as DesignElement) : el)) : prev,
);
}, []);
const addText = () => {
const id = uuid();
setActiveElements((prev) => [
...prev,
{
id,
type: 'text',
x: activeStageW / 2 - 40,
y: activeStageH / 2 - 10,
rotation: 0,
text: 'Your text',
fontFamily: 'Fraunces',
fontSize: 28,
fill: '#17181C',
curvedPath: null,
},
]);
setSelectedId(id);
};
const handleUploadClick = () => fileInputRef.current?.click();
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const src = reader.result as string;
const img = new window.Image();
img.onload = () => {
const maxDim = Math.min(activeStageW, activeStageH) * 0.7;
const scale = Math.min(maxDim / img.width, maxDim / img.height, 1);
const id = uuid();
setActiveElements((prev) => [
...prev,
{
id,
type: 'image',
x: activeStageW / 2 - (img.width * scale) / 2,
y: activeStageH / 2 - (img.height * scale) / 2,
rotation: 0,
width: img.width * scale,
height: img.height * scale,
src,
},
]);
setSelectedId(id);
};
img.src = src;
};
reader.readAsDataURL(file);
e.target.value = '';
};
const deleteSelected = () => {
if (!selectedId) return;
setElementsFront((prev) => prev.filter((el) => el.id !== selectedId));
setElementsBack((prev) => prev.filter((el) => el.id !== selectedId));
setSelectedId(null);
};
const selected = elementsFront.find((el) => el.id === selectedId) ?? elementsBack.find((el) => el.id === selectedId);
// Calculate the effective price for made-to-order items (use personalisedPrice if available)
const effectivePriceForPersonalised = canPersonalize
? getEffectivePrice(
{
id: product.id,
basePrice: product.basePrice,
personalisedPrice: product.personalisedPrice,
salePrice: product.onSale ? product.effectivePrice : null,
saleStartsAt: null,
saleEndsAt: null,
},
[],
new Date(),
true,
).price
: product.effectivePrice;
const handleAddToBag = async () => {
const { isAuthenticated } = await checkCustomerAuth();
if (!isAuthenticated) {
// Save design draft before redirecting to login
if (typeof window !== 'undefined' && (elementsFront.length > 0 || elementsBack.length > 0)) {
localStorage.setItem(`designDraft_${product.id}`, JSON.stringify({
elementsFront,
elementsBack,
color,
size,
designWidthCm,
designHeightCm,
imageDimensions,
quantity,
timestamp: new Date().toISOString(),
}));
console.log('Design saved to draft');
}
setShowLoginPrompt(true);
return;
}
if (!canPersonalize) {
const flatPhoto = product.colorPhotos[color] ?? product.imageUrl ?? '';
addItem({
cartItemId: uuid(),
productId: product.id,
slug: product.slug,
name: product.name,
mockup: product.mockup,
color,
size,
quantity,
unitPrice: product.effectivePrice,
designJson: { front: [], back: [] },
designPreviewUrl: flatPhoto,
designPreviewUrlBack: null,
placementPreviewUrl: flatPhoto || null,
placementPreviewUrlBack: product.imageUrlBack ?? null,
designWidthCm: null,
designHeightCm: null,
});
// Clear design draft if any
if (typeof window !== 'undefined') {
localStorage.removeItem(`designDraft_${product.id}`);
}
setJustAdded(true);
setTimeout(() => setJustAdded(false), 2200);
return;
}
setSelectedId(null); // clear transformer handles before export
requestAnimationFrame(async () => {
const frontStage = stageFrontRef.current;
if (!frontStage) return;
// Hide the dashed guide box + tint before exporting, so the saved PNG is
// just the design itself — transparent background, no shirt, no guide
// marks — ready to hand off for printing.
const frontGuides = frontStage.find('.printGuide');
frontGuides.forEach((g) => g.hide());
const frontDataUrl = frontStage.toDataURL({ pixelRatio: 2 });
const frontBox = {
left: frontPrintArea.xPct * DISPLAY,
top: frontPrintArea.yPct * DISPLAY,
w: frontPrintArea.wPct * DISPLAY,
h: frontPrintArea.hPct * DISPLAY,
};
const frontPhotoInfo = getPhotoInfo('front');
const frontPlacementUrl = frontPhotoInfo
? await composePlacementImage(
frontPhotoInfo.url,
frontPhotoInfo.tint ? { tint: true, color } : null,
frontStage,
frontBox,
).catch(() => null)
: null;
frontGuides.forEach((g) => g.show());
let backDataUrl: string | null = null;
let backPlacementUrl: string | null = null;
if (hasBack && stageBackRef.current) {
const backStage = stageBackRef.current;
const backGuides = backStage.find('.printGuide');
backGuides.forEach((g) => g.hide());
backDataUrl = backStage.toDataURL({ pixelRatio: 2 });
const backBox = {
left: backPrintArea.xPct * DISPLAY,
top: backPrintArea.yPct * DISPLAY,
w: backPrintArea.wPct * DISPLAY,
h: backPrintArea.hPct * DISPLAY,
};
const backPhotoInfo = getPhotoInfo('back');
backPlacementUrl = backPhotoInfo
? await composePlacementImage(
backPhotoInfo.url,
backPhotoInfo.tint ? { tint: true, color } : null,
backStage,
backBox,
).catch(() => null)
: null;
backGuides.forEach((g) => g.show());
}
const cartItem = {
cartItemId: uuid(),
productId: product.id,
slug: product.slug,
name: product.name,
mockup: product.mockup,
color,
size,
quantity,
unitPrice: effectivePriceForPersonalised,
designJson: { front: elementsFront, back: elementsBack },
designPreviewUrl: frontDataUrl,
designPreviewUrlBack: backDataUrl,
placementPreviewUrl: frontPlacementUrl ?? frontDataUrl,
placementPreviewUrlBack: backPlacementUrl ?? backDataUrl,
designWidthCm,
designHeightCm,
};
console.log('Adding to cart with dimensions:', { designWidthCm, designHeightCm, cartItem });
addItem(cartItem);
// Clear design draft now that it's been added to cart
if (typeof window !== 'undefined') {
localStorage.removeItem(`designDraft_${product.id}`);
console.log('✓ Design draft cleared after adding to cart');
}
setJustAdded(true);
setTimeout(() => setJustAdded(false), 2200);
});
};
const handleSubmitForApproval = async () => {
// First, save design to localStorage (in case user needs to login)
if (typeof window !== 'undefined' && (elementsFront.length > 0 || elementsBack.length > 0)) {
localStorage.setItem(`designDraft_${product.id}`, JSON.stringify({
elementsFront,
elementsBack,
color,
size,
designWidthCm,
designHeightCm,
imageDimensions,
quantity,
timestamp: new Date().toISOString(),
}));
}
if (elementsFront.length === 0 && elementsBack.length === 0) {
return;
}
setSubmittingForApproval(true);
setSelectedId(null);
requestAnimationFrame(async () => {
const frontStage = stageFrontRef.current;
if (!frontStage) {
setSubmittingForApproval(false);
return;
}
const frontGuides = frontStage.find('.printGuide');
frontGuides.forEach((g) => g.hide());
const frontDataUrl = frontStage.toDataURL({ pixelRatio: 2 });
const frontBox = {
left: frontPrintArea.xPct * DISPLAY,
top: frontPrintArea.yPct * DISPLAY,
w: frontPrintArea.wPct * DISPLAY,
h: frontPrintArea.hPct * DISPLAY,
};
const frontPhotoInfo = getPhotoInfo('front');
const frontPlacementUrl = frontPhotoInfo
? await composePlacementImage(
frontPhotoInfo.url,
frontPhotoInfo.tint ? { tint: true, color } : null,
frontStage,
frontBox,
).catch(() => null)
: null;
frontGuides.forEach((g) => g.show());
let backDataUrl: string | null = null;
let backPlacementUrl: string | null = null;
if (hasBack && stageBackRef.current) {
const backStage = stageBackRef.current;
const backGuides = backStage.find('.printGuide');
backGuides.forEach((g) => g.hide());
backDataUrl = backStage.toDataURL({ pixelRatio: 2 });
const backBox = {
left: backPrintArea.xPct * DISPLAY,
top: backPrintArea.yPct * DISPLAY,
w: backPrintArea.wPct * DISPLAY,
h: backPrintArea.hPct * DISPLAY,
};
const backPhotoInfo = getPhotoInfo('back');
backPlacementUrl = backPhotoInfo
? await composePlacementImage(
backPhotoInfo.url,
backPhotoInfo.tint ? { tint: true, color } : null,
backStage,
backBox,
).catch(() => null)
: null;
backGuides.forEach((g) => g.show());
}
try {
const res = await fetch('/api/designs/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productId: product.id,
designJson: JSON.stringify({ front: elementsFront, back: elementsBack }),
designPreviewUrl: frontDataUrl,
designPreviewUrlBack: backDataUrl,
placementPreviewUrl: frontPlacementUrl ?? frontDataUrl,
placementPreviewUrlBack: backPlacementUrl ?? backDataUrl,
designWidthCm,
designHeightCm,
designImageDimensions: Object.entries(imageDimensions).map(([imageId, dims]) => ({
imageId,
widthCm: dims.widthCm,
heightCm: dims.heightCm,
})),
color,
size,
}),
});
if (res.ok) {
const design = await res.json();
setDesignApprovalId(design.id);
setApprovalStatus('PENDING');
setSubmissionMessage(`✓ Design submitted for approval! You'll receive an email when it's ready to add to your bag.`);
// Save to localStorage so status persists on page reload
if (typeof window !== 'undefined') {
localStorage.setItem(`designApproval_${product.id}`, JSON.stringify({
id: design.id,
status: 'PENDING',
submittedAt: new Date().toISOString(),
}));
}
console.log('Design submitted for approval:', design.id);
setTimeout(() => setSubmissionMessage(null), 5000);
} else if (res.status === 401) {
setShowLoginPrompt(true);
console.log('User not authenticated, showing login prompt');
} else {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
console.error('Failed to submit design for approval:', err.error);
setSubmissionMessage(`✗ Failed to submit design: ${err.error || 'Unknown error'}`);
}
} finally {
setSubmittingForApproval(false);
}
});
};
function renderPhoto(v: 'front' | 'back') {
const colorPhoto = v === 'front' ? product.colorPhotos[color] : product.colorPhotosBack[color];
if (colorPhoto) {
return <img src={colorPhoto} alt={`${product.name} in ${color}`} className="h-full w-full object-cover" />;
}
const activePhoto = v === 'front' ? product.imageUrl : product.imageUrlBack;
if (!activePhoto) {
return <ProductMockup mockup={product.mockup} color={color} />;
}
if (product.photoRecolorable) {
return (
<div className="relative h-full w-full">
<img
src={activePhoto}
alt={product.name}
className="h-full w-full object-cover"
style={{ filter: 'grayscale(1) brightness(2.1) contrast(1.15)' }}
/>
<div className="absolute inset-0" style={{ backgroundColor: color, mixBlendMode: 'multiply' }} />
</div>
);
}
return <img src={activePhoto} alt={product.name} className="h-full w-full object-cover" />;
}
// Same resolution logic as renderPhoto, but returns raw data instead of JSX —
// used when compositing the placement-reference image for the order record.
function getPhotoInfo(v: 'front' | 'back'): { url: string; tint: boolean } | null {
const colorPhoto = v === 'front' ? product.colorPhotos[color] : product.colorPhotosBack[color];
if (colorPhoto) return { url: colorPhoto, tint: false };
const activePhoto = v === 'front' ? product.imageUrl : product.imageUrlBack;
if (!activePhoto) return null; // illustration only — nothing photographic to composite onto
return { url: activePhoto, tint: product.photoRecolorable };
}
return (
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[560px_1fr]">
{/* Canvas */}
<div ref={canvasContainerRef} className="w-full min-w-0 max-w-[560px]">
{hasBack && (
<div className="mb-3 inline-flex border border-line">
<button
onClick={() => setView('front')}
className={`px-4 py-1.5 text-sm ${view === 'front' ? 'bg-ink text-paper' : 'hover:text-clay'}`}
>
Front
</button>
<button
onClick={() => setView('back')}
className={`px-4 py-1.5 text-sm ${view === 'back' ? 'bg-ink text-paper' : 'hover:text-clay'}`}
>
Back
</button>
</div>
)}
{canPersonalize ? (
<>
<PrintSurface
photo={renderPhoto('front')}
printArea={frontPrintArea}
elements={elementsFront}
selectedId={selectedId}
setSelectedId={setSelectedId}
updateElement={updateElement}
nodeRefs={nodeRefs}
stageRef={stageFrontRef}
transformerRef={transformerFrontRef}
visible={view === 'front'}
caption="Dashed line marks the print area"
onNodeReady={() => setNodeReadyTick((t) => t + 1)}
scale={canvasScale}
product={product}
/>
{hasBack && (
<PrintSurface
photo={renderPhoto('back')}
printArea={backPrintArea}
elements={elementsBack}
selectedId={selectedId}
setSelectedId={setSelectedId}
updateElement={updateElement}
nodeRefs={nodeRefs}
stageRef={stageBackRef}
transformerRef={transformerBackRef}
visible={view === 'back'}
caption="Dashed line marks the print area (back)"
onNodeReady={() => setNodeReadyTick((t) => t + 1)}
scale={canvasScale}
product={product}
/>
)}
</>
) : (
<div className="relative aspect-square w-full max-w-[560px] border border-line bg-surface">
{renderPhoto(view)}
</div>
)}
</div>
{/* Controls */}
<div className="flex flex-col gap-8">
<div>
<h1 className="font-display text-4xl">{product.name}</h1>
<p className="mt-2 text-muted">{product.description}</p>
<div className="mt-4">
<p className="flex items-baseline gap-2 font-mono text-lg">
{(() => {
const { onSale, price, originalPrice } = getEffectivePrice(
{
id: product.id,
basePrice: product.basePrice,
personalisedPrice: product.personalisedPrice,
salePrice: product.onSale ? product.effectivePrice : null,
saleStartsAt: null,
saleEndsAt: null,
},
[],
new Date(),
true,
);
return (
<>
{onSale && originalPrice != null && (
<Price cents={originalPrice} className="text-base text-muted line-through" />
)}
<Price cents={price} className={onSale ? 'text-splash-pink' : undefined} />
</>
);
})()}
</p>
<p className="text-xs text-muted">For personalized design</p>
</div>
</div>
<div>
<p className="tag-label mb-3">Color</p>
<div className="flex flex-wrap gap-3">
{product.colors.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
aria-label={`Choose color ${c}`}
className={`h-9 w-9 rounded-full border transition-transform ${
color === c ? 'scale-110 border-ink ring-2 ring-ink ring-offset-2 ring-offset-paper' : 'border-line'
}`}
style={{ backgroundColor: c }}
/>
))}
</div>
{product.imageUrl && !product.photoRecolorable && Object.keys(product.colorPhotos).length === 0 && (
<p className="mt-2 text-xs text-muted">
This product uses a real photo, so the picture won&apos;t change color your pick is
still saved with the order.
</p>
)}
</div>
{product.sizes.length > 0 && (
<div>
<p className="tag-label mb-3">Size</p>
<div className="flex flex-wrap gap-2">
{product.sizes.map((s) => (
<button
key={s}
onClick={() => setSize(s)}
className={`min-w-[44px] border px-3 py-2 text-sm transition-colors ${
size === s
? 'border-clay bg-clay text-paper'
: 'border-line text-ink hover:border-clay hover:text-clay'
}`}
>
{s}
</button>
))}
</div>
</div>
)}
{canPersonalize && (
<div>
<p className="tag-label mb-3">
Your design {hasBack && <span className="normal-case text-muted"> editing the {view}</span>}
</p>
{/* Image Dimension Inputs */}
{(() => {
const images = view === 'front' ? elementsFront.filter(el => el.type === 'image') : elementsBack.filter(el => el.type === 'image');
return images.length > 0 ? (
<div className="mb-4 space-y-3">
{images.map((img, idx) => (
<div key={img.id} className="border border-line bg-surface p-3">
<p className="tag-label mb-2">Image {idx + 1} Dimensions</p>
<div className="flex gap-3">
<label className="flex flex-col gap-1 flex-1">
<span className="text-xs text-muted">Width (cm)</span>
<input
type="number"
step="0.1"
min="0"
value={imageDimensions[img.id]?.widthCm ?? ''}
onChange={(e) => {
const val = e.target.value ? Number(e.target.value) : null;
setImageDimensions((prev) => ({
...prev,
[img.id]: { ...prev[img.id], widthCm: val },
}));
}}
placeholder="e.g. 10"
className="border border-line bg-paper px-3 py-2 text-sm"
/>
</label>
<label className="flex flex-col gap-1 flex-1">
<span className="text-xs text-muted">Height (cm)</span>
<input
type="number"
step="0.1"
min="0"
value={imageDimensions[img.id]?.heightCm ?? ''}
onChange={(e) => {
const val = e.target.value ? Number(e.target.value) : null;
setImageDimensions((prev) => ({
...prev,
[img.id]: { ...prev[img.id], heightCm: val },
}));
}}
placeholder="e.g. 10"
className="border border-line bg-paper px-3 py-2 text-sm"
/>
</label>
</div>
</div>
))}
</div>
) : null;
})()}
{/* Font Properties Inputs */}
{(() => {
const texts = view === 'front' ? elementsFront.filter(el => el.type === 'text') : elementsBack.filter(el => el.type === 'text');
return texts.length > 0 ? (
<div className="mb-4 space-y-3">
{texts.map((text, idx) => (
<div key={text.id} className="border border-line bg-surface p-3">
<p className="tag-label mb-3">Font {idx + 1}</p>
<div className="space-y-3">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted">Text</span>
<input
type="text"
value={text.text}
onChange={(e) => updateElement(text.id, { text: e.target.value })}
className="border border-line bg-paper px-3 py-2 text-sm"
placeholder="Text content"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted">Font Family</span>
<select
value={text.fontFamily}
onChange={(e) => updateElement(text.id, { fontFamily: e.target.value })}
className="border border-line bg-paper px-3 py-2 text-sm"
style={{ fontFamily: text.fontFamily }}
>
{FONTS.map((f) => (
<option key={f} value={f} style={{ fontFamily: f }}>
{f}
</option>
))}
</select>
</label>
<div className="flex gap-3">
<label className="flex flex-col gap-1 flex-1">
<span className="text-xs text-muted">Font Size</span>
<div className="flex gap-2 items-center">
<input
type="range"
min={10}
max={80}
value={text.fontSize}
onChange={(e) => updateElement(text.id, { fontSize: Number(e.target.value) })}
className="flex-1"
/>
<span className="w-12 text-right font-mono text-xs">{text.fontSize}pt</span>
</div>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted">Color</span>
<input
type="color"
value={text.fill}
onChange={(e) => updateElement(text.id, { fill: e.target.value })}
className="h-10 w-16 border border-line bg-paper p-1 cursor-pointer"
/>
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted">Rotation (degrees)</span>
<div className="flex gap-2 items-center">
<input
type="range"
min={0}
max={360}
value={text.rotation || 0}
onChange={(e) => updateElement(text.id, { rotation: Number(e.target.value) })}
className="flex-1"
/>
<span className="w-12 text-right font-mono text-xs">{text.rotation || 0}°</span>
</div>
</label>
{/* Curved Text Controls */}
<div className="border-t border-line pt-3">
<label className="flex items-center gap-2 text-sm text-muted mb-3">
<input
type="checkbox"
checked={text.curvedPath ? true : false}
onChange={(e) => updateElement(text.id, { curvedPath: e.target.checked ? { type: 'circle', radius: 80 } : null })}
className="cursor-pointer"
/>
Curved text
</label>
{text.curvedPath && (
<div className="space-y-3">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted">Curve type</span>
<select
value={text.curvedPath.type || 'circle'}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, type: e.target.value as any } })}
className="border border-line bg-paper px-2 py-1 text-xs"
>
<option value="circle">Circle</option>
<option value="wave">Wave</option>
<option value="spiral">Spiral</option>
<option value="arc">Arc</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted">Radius / Intensity</span>
<div className="flex gap-2 items-center">
<input
type="range"
min={20}
max={200}
value={text.curvedPath.radius || 80}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, radius: Number(e.target.value) } })}
className="flex-1"
/>
<span className="w-12 text-right font-mono text-xs">{text.curvedPath.radius || 80}</span>
</div>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={text.curvedPath.reverse ? true : false}
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, reverse: e.target.checked } })}
className="cursor-pointer"
/>
<span className="text-muted">Reverse direction</span>
</label>
</div>
)}
</div>
</div>
</div>
))}
</div>
) : null;
})()}
<div className="flex flex-wrap gap-3">
<button onClick={addText} className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper">
+ Add text
</button>
<button
onClick={handleUploadClick}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
+ Upload image
</button>
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFile} />
{selected && (
<button
onClick={deleteSelected}
className="border border-line px-4 py-2 text-sm text-muted hover:border-ink hover:text-ink"
>
Delete selected
</button>
)}
</div>
{selected?.type === 'text' && (
<div className="mt-4 flex flex-wrap items-center gap-3 border border-line bg-surface p-4">
<input
value={selected.text}
onChange={(e) => updateElement(selected.id, { text: e.target.value })}
className="border border-line bg-paper px-2 py-1 text-sm text-ink placeholder:text-muted"
placeholder="Text"
/>
<select
value={selected.fontFamily}
onChange={(e) => updateElement(selected.id, { fontFamily: e.target.value })}
className="border border-line bg-paper px-2 py-1 text-sm text-ink"
style={{ fontFamily: selected.fontFamily }}
>
{FONTS.map((f) => (
<option key={f} value={f} style={{ fontFamily: f }}>
{f}
</option>
))}
</select>
<input
type="color"
value={selected.fill}
onChange={(e) => updateElement(selected.id, { fill: e.target.value })}
className="h-8 w-8 border border-line bg-paper p-0"
/>
<label className="flex items-center gap-2 text-sm text-muted">
Font Size
<input
type="range"
min={10}
max={80}
value={selected.fontSize}
onChange={(e) => updateElement(selected.id, { fontSize: Number(e.target.value) })}
/>
<span className="w-12 text-right font-mono text-xs">{selected.fontSize}pt</span>
</label>
<label className="flex items-center gap-2 text-sm text-muted">
Angle
<input
type="range"
min={0}
max={360}
value={selected.rotation || 0}
onChange={(e) => updateElement(selected.id, { rotation: Number(e.target.value) })}
/>
<span className="w-10 text-right font-mono text-xs">{selected.rotation || 0}°</span>
</label>
</div>
)}
</div>
)}
<div className="flex flex-col gap-3">
{submissionMessage && (
<p className={`text-sm px-4 py-3 rounded border ${
submissionMessage.startsWith('✓')
? 'text-green-700 bg-green-50 border-green-200'
: 'text-splash-pink bg-red-50 border-splash-pink/30'
}`}>
{submissionMessage}
</p>
)}
{approvalStatus === 'PENDING' && !submissionMessage && (
<p className="text-sm text-orange-600 bg-orange-50 border border-orange-200 px-4 py-3 rounded">
Your design is pending admin approval. You'll receive an email when it's ready to add to your bag.
</p>
)}
{approvalStatus === 'APPROVED' && (
<p className="text-sm text-pine bg-green-50 border border-green-200 px-4 py-3 rounded">
Your design has been approved! You can now add it to your bag.
</p>
)}
{approvalStatus === 'REJECTED' && (
<p className="text-sm text-splash-pink bg-red-50 border border-splash-pink/30 px-4 py-3 rounded">
Your design needs changes. Check your messages for feedback.
</p>
)}
<div className="flex items-center gap-4">
<div className="flex items-center border border-ink">
<button
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
className="px-3 py-2 text-sm"
aria-label="Decrease quantity"
>
</button>
<span className="w-10 text-center font-mono text-sm">{quantity}</span>
<button
onClick={() => setQuantity((q) => q + 1)}
className="px-3 py-2 text-sm"
aria-label="Increase quantity"
>
+
</button>
</div>
{approvalStatus === 'APPROVED' ? (
<button
onClick={handleAddToBag}
disabled={elementsFront.length === 0 && elementsBack.length === 0}
className={`flex-1 px-6 py-3 text-sm font-medium transition-colors ${
elementsFront.length === 0 && elementsBack.length === 0
? 'bg-muted text-muted/50 cursor-not-allowed'
: 'bg-clay text-paper hover:bg-clay-dark'
}`}
>
Add to bag <Price cents={((elementsFront.length > 0 || elementsBack.length > 0) ? effectivePriceForPersonalised : product.effectivePrice) * quantity} />
</button>
) : (
<button
onClick={handleSubmitForApproval}
disabled={elementsFront.length === 0 && elementsBack.length === 0 || submittingForApproval || approvalStatus === 'PENDING'}
className={`flex-1 px-6 py-3 text-sm font-medium transition-colors ${
elementsFront.length === 0 && elementsBack.length === 0 || submittingForApproval || approvalStatus === 'PENDING'
? 'bg-muted text-muted/50 cursor-not-allowed'
: 'bg-clay text-paper hover:bg-clay-dark'
}`}
>
{submittingForApproval ? 'Submitting…' : 'Submit for Approval'}
</button>
)}
</div>
{elementsFront.length === 0 && elementsBack.length === 0 && !approvalStatus && (
<p className="text-xs text-splash-pink">Add text or images to personalize your design before submitting</p>
)}
{justAdded && <p className="text-sm text-pine">Added to your bag.</p>}
</div>
</div>
<LoginPromptModal isOpen={showLoginPrompt} onClose={() => setShowLoginPrompt(false)} />
</div>
);
}