Files
craft2prints/src/components/DesignCanvas.tsx
T

880 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useRef, useState, useEffect, useLayoutEffect, useCallback } from 'react';
import { Stage, Layer, Text, 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>
<div className="mt-4">
<p className="flex items-baseline gap-2 font-mono text-lg">
{(() => {
const { onSale, price, originalPrice } = getEffectivePrice(
{
id: product.id,
basePrice: product.basePrice,
personalisedPrice: product.personalisedPrice,
salePrice: product.onSale ? product.effectivePrice : null,
saleStartsAt: null,
saleEndsAt: null,
},
[],
new Date(),
true,
);
return (
<>
{onSale && originalPrice != null && (
<Price cents={originalPrice} className="text-base text-muted line-through" />
)}
<Price cents={price} className={onSale ? 'text-splash-pink' : undefined} />
</>
);
})()}
</p>
<p className="text-xs text-muted">For personalized design</p>
</div>
</div>
<div>
<p className="tag-label mb-3">Color</p>
<div className="flex flex-wrap gap-3">
{product.colors.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
aria-label={`Choose color ${c}`}
className={`h-9 w-9 rounded-full border transition-transform ${
color === c ? 'scale-110 border-ink ring-2 ring-ink ring-offset-2 ring-offset-paper' : 'border-line'
}`}
style={{ backgroundColor: c }}
/>
))}
</div>
{product.imageUrl && !product.photoRecolorable && Object.keys(product.colorPhotos).length === 0 && (
<p className="mt-2 text-xs text-muted">
This product uses a real photo, so the picture won&apos;t change color your pick is
still saved with the order.
</p>
)}
</div>
{product.sizes.length > 0 && (
<div>
<p className="tag-label mb-3">Size</p>
<div className="flex flex-wrap gap-2">
{product.sizes.map((s) => (
<button
key={s}
onClick={() => setSize(s)}
className={`min-w-[44px] border px-3 py-2 text-sm transition-colors ${
size === s
? 'border-clay bg-clay text-paper'
: 'border-line text-ink hover:border-clay hover:text-clay'
}`}
>
{s}
</button>
))}
</div>
</div>
)}
{canPersonalize && (
<div>
<p className="tag-label mb-3">
Your design {hasBack && <span className="normal-case text-muted"> editing the {view}</span>}
</p>
<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={(elements.length > 0 ? effectivePriceForPersonalised : 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>
);
}