- Create LoginPromptModal component for authentication encouragement - Check customer authentication before adding items to cart - Show modal for non-logged-in users with options to create account, log in, or continue as guest - Allow guests to add items (stored in IndexedDB) while prompting to sign up - Implemented in both StandardProductView (ready-made products) and DesignCanvas (personalized items) - Add server action checkCustomerAuth() for authentication checks Addresses user request: prompt guests to create account or log in when adding items to cart. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
877 lines
31 KiB
TypeScript
877 lines
31 KiB
TypeScript
'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<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 [showLoginPrompt, setShowLoginPrompt] = 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 = 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 <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>
|
||
|
||
<LoginPromptModal isOpen={showLoginPrompt} onClose={() => setShowLoginPrompt(false)} />
|
||
</div>
|
||
);
|
||
}
|