Initial commit: Craft2Prints with Stages 1-3

This commit is contained in:
Andymick
2026-07-15 18:09:59 +01:00
commit 41937ffc15
158 changed files with 16054 additions and 0 deletions
+866
View File
@@ -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&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>
<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>
);
}