'use client'; import { useRef, useState, useEffect, useLayoutEffect, useCallback } from 'react'; import { Stage, Layer, Text, Image as KonvaImage, Transformer, Rect } 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 { 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 { 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; onSelect: () => void; onChange: (attrs: Partial) => void; shapeRef: (node: Konva.Image | null) => void; }) { const [img, setImg] = useState(null); useEffect(() => { const image = new window.Image(); image.src = el.src; image.onload = () => setImg(image); }, [el.src]); if (!img) return null; return ( 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, }: { photo: React.ReactNode; printArea: PrintArea; elements: DesignElement[]; selectedId: string | null; setSelectedId: (id: string | null) => void; updateElement: (id: string, attrs: Partial) => void; nodeRefs: React.MutableRefObject>; stageRef: React.RefObject; transformerRef: React.RefObject; visible: boolean; caption: string; onNodeReady: () => void; scale: number; }) { const stageW = printArea.wPct * DISPLAY; const stageH = printArea.hPct * DISPLAY; const stageLeft = printArea.xPct * DISPLAY; const stageTop = printArea.yPct * DISPLAY; return (
{/* 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. */}
{photo}
{ if (e.target === e.target.getStage()) setSelectedId(null); }} > {elements.map((el) => el.type === 'text' ? ( { 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); }} /> ) : ( 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(); } }} /> ), )} (newBox.width < 8 || newBox.height < 8 ? oldBox : newBox)} />

{caption}

); } export default function DesignCanvas({ product }: { product: ProductDTO }) { const [color, setColor] = useState(product.colors[0]); const [size, setSize] = useState(product.sizes[0] ?? null); const [view, setView] = useState<'front' | 'back'>('front'); const [elementsFront, setElementsFront] = useState([]); const [elementsBack, setElementsBack] = useState([]); const [selectedId, setSelectedId] = useState(null); const [quantity, setQuantity] = useState(1); const [justAdded, setJustAdded] = useState(false); const [showLoginPrompt, setShowLoginPrompt] = useState(false); const [nodeReadyTick, setNodeReadyTick] = useState(0); const stageFrontRef = useRef(null); const stageBackRef = useRef(null); const transformerFrontRef = useRef(null); const transformerBackRef = useRef(null); const nodeRefs = useRef>(new Map()); const fileInputRef = useRef(null); const canvasContainerRef = useRef(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. 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; 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) => { 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', }, ]); setSelectedId(id); }; const handleUploadClick = () => fileInputRef.current?.click(); const handleFile = (e: React.ChangeEvent) => { 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) { setShowLoginPrompt(true); } 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, }); 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()); } addItem({ 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, }); setJustAdded(true); setTimeout(() => setJustAdded(false), 2200); }); }; function renderPhoto(v: 'front' | 'back') { const colorPhoto = v === 'front' ? product.colorPhotos[color] : product.colorPhotosBack[color]; if (colorPhoto) { return {`${product.name}; } const activePhoto = v === 'front' ? product.imageUrl : product.imageUrlBack; if (!activePhoto) { return ; } if (product.photoRecolorable) { return (
{product.name}
); } return {product.name}; } // 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 (
{/* Canvas */}
{hasBack && (
)} {canPersonalize ? ( <> setNodeReadyTick((t) => t + 1)} scale={canvasScale} /> {hasBack && ( setNodeReadyTick((t) => t + 1)} scale={canvasScale} /> )} ) : (
{renderPhoto(view)}
)}
{/* Controls */}

{product.name}

{product.description}

{(() => { 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 && ( )} ); })()}

For personalized design

Color

{product.colors.map((c) => (
{product.imageUrl && !product.photoRecolorable && Object.keys(product.colorPhotos).length === 0 && (

This product uses a real photo, so the picture won't change color — your pick is still saved with the order.

)}
{product.sizes.length > 0 && (

Size

{product.sizes.map((s) => ( ))}
)} {canPersonalize && (

Your design {hasBack && — editing the {view}}

{selected && ( )}
{selected?.type === 'text' && (
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" /> updateElement(selected.id, { fill: e.target.value })} className="h-8 w-8 border border-line bg-paper p-0" />
)} {selected?.type === 'image' && (
)}
)}
{quantity}
{justAdded &&

Added to your bag.

}
setShowLoginPrompt(false)} />
); }