Initial commit: Craft2Prints with Stages 1-3
This commit is contained in:
@@ -0,0 +1,866 @@
|
||||
'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 { useCart } from '@/lib/cartStore';
|
||||
import { getEffectivePrice } from '@/lib/pricing';
|
||||
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,
|
||||
}: {
|
||||
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;
|
||||
}) {
|
||||
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>
|
||||
<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' ? (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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 [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 [nodeReadyTick, setNodeReadyTick] = useState(0);
|
||||
|
||||
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.
|
||||
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<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',
|
||||
},
|
||||
]);
|
||||
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 = () => {
|
||||
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 <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}
|
||||
/>
|
||||
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
<p className="mt-4 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>
|
||||
</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'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>
|
||||
<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">
|
||||
Size
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={selected.fontSize}
|
||||
onChange={(e) => updateElement(selected.id, { fontSize: Number(e.target.value) })}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{selected?.type === 'image' && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 border border-line bg-surface p-4">
|
||||
<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 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>
|
||||
<button
|
||||
onClick={handleAddToBag}
|
||||
className="flex-1 bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark"
|
||||
>
|
||||
Add to bag — <Price cents={product.effectivePrice * quantity} />
|
||||
</button>
|
||||
</div>
|
||||
{justAdded && <p className="text-sm text-pine">Added to your bag.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user