- TextPath positioning is handled by the path data, not x/y coordinates
- Removed x={el.x} and y={el.y} from TextPath to prevent coordinate corruption
- Removes drag handlers from TextPath since curves are centered on canvas
- Text now properly persists when toggling between curved and straight
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1494 lines
57 KiB
TypeScript
1494 lines
57 KiB
TypeScript
'use client';
|
||
|
||
import { useRef, useState, useEffect, useLayoutEffect, useCallback } from 'react';
|
||
import { Stage, Layer, Text, TextPath, 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,
|
||
product,
|
||
}: {
|
||
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;
|
||
product: ProductDTO;
|
||
}) {
|
||
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>
|
||
|
||
{/* Horizontal Ruler */}
|
||
{product.referenceWidthCm && product.referenceWidthCm > 0 && (
|
||
<div
|
||
className="absolute pointer-events-none bg-paper border-b border-line text-xs font-mono"
|
||
style={{
|
||
left: 0,
|
||
top: -20,
|
||
width: DISPLAY,
|
||
height: 18,
|
||
display: 'flex',
|
||
alignItems: 'flex-end',
|
||
paddingBottom: '2px',
|
||
}}
|
||
>
|
||
{(() => {
|
||
const marks = [];
|
||
const pxPerCm = DISPLAY / product.referenceWidthCm;
|
||
|
||
for (let cm = 0; cm <= Math.ceil(product.referenceWidthCm); cm += 2) {
|
||
const px = cm * pxPerCm;
|
||
if (px > DISPLAY) break;
|
||
marks.push(
|
||
<div
|
||
key={`h-${cm}`}
|
||
style={{
|
||
position: 'absolute',
|
||
left: `${px}px`,
|
||
fontSize: '9px',
|
||
color: '#888',
|
||
transform: 'translateX(-50%)',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{cm}
|
||
</div>
|
||
);
|
||
}
|
||
return marks;
|
||
})()}
|
||
</div>
|
||
)}
|
||
|
||
{/* Vertical Ruler */}
|
||
{product.referenceHeightCm && product.referenceHeightCm > 0 && (
|
||
<div
|
||
className="absolute pointer-events-none bg-paper border-r border-line text-xs font-mono"
|
||
style={{
|
||
left: -28,
|
||
top: 0,
|
||
width: 26,
|
||
height: DISPLAY,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'flex-end',
|
||
paddingRight: '4px',
|
||
}}
|
||
>
|
||
{(() => {
|
||
const marks = [];
|
||
const pxPerCm = DISPLAY / product.referenceHeightCm;
|
||
|
||
for (let cm = 0; cm <= Math.ceil(product.referenceHeightCm); cm += 2) {
|
||
const px = cm * pxPerCm;
|
||
if (px > DISPLAY) break;
|
||
marks.push(
|
||
<div
|
||
key={`v-${cm}`}
|
||
style={{
|
||
position: 'absolute',
|
||
top: `${px}px`,
|
||
fontSize: '9px',
|
||
color: '#888',
|
||
transform: 'translateY(-50%)',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{cm}
|
||
</div>
|
||
);
|
||
}
|
||
return marks;
|
||
})()}
|
||
</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' ? (
|
||
el.curvedPath ? (
|
||
// Curved text path - centered on canvas
|
||
<TextPath
|
||
key={el.id}
|
||
ref={(node) => {
|
||
if (node) nodeRefs.current.set(el.id, node);
|
||
}}
|
||
data={generateCurvePath(el.curvedPath.type, el.curvedPath.radius, el.curvedPath.reverse)}
|
||
text={el.text}
|
||
fontFamily={el.fontFamily}
|
||
fontSize={el.fontSize}
|
||
fill={el.fill}
|
||
listening={true}
|
||
onClick={() => setSelectedId(el.id)}
|
||
onTap={() => setSelectedId(el.id)}
|
||
/>
|
||
) : (
|
||
// Regular straight 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>
|
||
);
|
||
}
|
||
|
||
// Generate SVG path for curved text based on type and radius
|
||
function generateCurvePath(type: string, radius: number, reverse: boolean = false): string {
|
||
const cx = 280; // center x (middle of 560px canvas)
|
||
const cy = 280; // center y
|
||
const r = radius;
|
||
|
||
let path = '';
|
||
|
||
switch (type) {
|
||
case 'circle':
|
||
// Full circle arc from bottom-left to top-right
|
||
const large = 0; // arc flag
|
||
const sweep = reverse ? 0 : 1;
|
||
path = `M ${cx - r} ${cy} A ${r} ${r} 0 ${large} ${sweep} ${cx + r} ${cy}`;
|
||
break;
|
||
|
||
case 'wave':
|
||
// Wave pattern
|
||
const waveHeight = radius / 3;
|
||
const waveLength = radius * 1.5;
|
||
path = `M ${cx - waveLength} ${cy}`;
|
||
for (let x = cx - waveLength; x <= cx + waveLength; x += 10) {
|
||
const y = cy + Math.sin((x - (cx - waveLength)) / waveLength * Math.PI * 2) * waveHeight;
|
||
path += ` L ${x} ${y}`;
|
||
}
|
||
break;
|
||
|
||
case 'spiral':
|
||
// Spiral arc
|
||
const spiralTurns = 1.5;
|
||
const spiralSteps = 100;
|
||
path = `M ${cx} ${cy - radius}`;
|
||
for (let i = 1; i <= spiralSteps; i++) {
|
||
const angle = (i / spiralSteps) * Math.PI * 2 * spiralTurns;
|
||
const r = radius * (1 - i / spiralSteps);
|
||
const x = cx + r * Math.cos(angle);
|
||
const y = cy - r * Math.sin(angle);
|
||
path += ` L ${x} ${y}`;
|
||
}
|
||
break;
|
||
|
||
case 'arc':
|
||
// Simple arc
|
||
path = `M ${cx - radius} ${cy} A ${radius} ${radius} 0 0 ${reverse ? 0 : 1} ${cx + radius} ${cy}`;
|
||
break;
|
||
|
||
default:
|
||
path = `M 0 ${cy} L 560 ${cy}`; // fallback: straight line
|
||
}
|
||
|
||
return path;
|
||
}
|
||
|
||
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 [designWidthCm, setDesignWidthCm] = useState<number | null>(null);
|
||
const [designHeightCm, setDesignHeightCm] = useState<number | null>(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 [approvalStatus, setApprovalStatus] = useState<'PENDING' | 'APPROVED' | 'REJECTED' | null>(null);
|
||
const [submittingForApproval, setSubmittingForApproval] = useState(false);
|
||
const [designApprovalId, setDesignApprovalId] = useState<string | null>(null);
|
||
const [submissionMessage, setSubmissionMessage] = useState<string | null>(null);
|
||
const [imageDimensions, setImageDimensions] = useState<Record<string, { widthCm: number | null; heightCm: number | null }>>({});
|
||
|
||
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.
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return;
|
||
|
||
// Load approval status
|
||
const approvalSaved = localStorage.getItem(`designApproval_${product.id}`);
|
||
if (approvalSaved) {
|
||
try {
|
||
const data = JSON.parse(approvalSaved);
|
||
setDesignApprovalId(data.id);
|
||
setApprovalStatus(data.status);
|
||
console.log('✓ Approval status restored');
|
||
} catch (e) {
|
||
console.error('Failed to load approval status:', e);
|
||
}
|
||
}
|
||
|
||
// Load design draft (saved when user tried to checkout without login)
|
||
const designDraft = localStorage.getItem(`designDraft_${product.id}`);
|
||
if (designDraft) {
|
||
try {
|
||
const data = JSON.parse(designDraft);
|
||
console.log('📋 Found design draft, restoring...', data);
|
||
|
||
if (data.elementsFront && data.elementsFront.length > 0) {
|
||
setElementsFront(data.elementsFront);
|
||
console.log('✓ Restored', data.elementsFront.length, 'front elements');
|
||
}
|
||
if (data.elementsBack && data.elementsBack.length > 0) {
|
||
setElementsBack(data.elementsBack);
|
||
console.log('✓ Restored', data.elementsBack.length, 'back elements');
|
||
}
|
||
if (data.color) setColor(data.color);
|
||
if (data.size) setSize(data.size);
|
||
if (data.designWidthCm !== null && data.designWidthCm !== undefined) setDesignWidthCm(data.designWidthCm);
|
||
if (data.designHeightCm !== null && data.designHeightCm !== undefined) setDesignHeightCm(data.designHeightCm);
|
||
if (data.imageDimensions) setImageDimensions(data.imageDimensions);
|
||
if (data.quantity) setQuantity(data.quantity);
|
||
|
||
console.log('✅ Design fully restored from draft');
|
||
// Keep the draft in localStorage until user completes checkout
|
||
// Only remove after successful add to bag
|
||
} catch (e) {
|
||
console.error('Failed to load design draft:', e);
|
||
}
|
||
}
|
||
}, [product.id]);
|
||
|
||
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;
|
||
|
||
// Calculate cm per pixel for display using product reference size
|
||
// Reference size maps to full canvas width/height (560px)
|
||
const cmPerPx = product.referenceWidthCm && product.referenceWidthCm > 0 ? product.referenceWidthCm / DISPLAY : 0;
|
||
const pxToCm = (px: number) => {
|
||
if (!cmPerPx || cmPerPx <= 0) return '—';
|
||
const cm = px * cmPerPx;
|
||
return isNaN(cm) ? '?' : cm.toFixed(2);
|
||
};
|
||
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',
|
||
curvedPath: null,
|
||
},
|
||
]);
|
||
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) {
|
||
// Save design draft before redirecting to login
|
||
if (typeof window !== 'undefined' && (elementsFront.length > 0 || elementsBack.length > 0)) {
|
||
localStorage.setItem(`designDraft_${product.id}`, JSON.stringify({
|
||
elementsFront,
|
||
elementsBack,
|
||
color,
|
||
size,
|
||
designWidthCm,
|
||
designHeightCm,
|
||
imageDimensions,
|
||
quantity,
|
||
timestamp: new Date().toISOString(),
|
||
}));
|
||
console.log('Design saved to draft');
|
||
}
|
||
setShowLoginPrompt(true);
|
||
return;
|
||
}
|
||
|
||
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,
|
||
designWidthCm: null,
|
||
designHeightCm: null,
|
||
});
|
||
|
||
// Clear design draft if any
|
||
if (typeof window !== 'undefined') {
|
||
localStorage.removeItem(`designDraft_${product.id}`);
|
||
}
|
||
|
||
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());
|
||
}
|
||
|
||
const cartItem = {
|
||
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,
|
||
designWidthCm,
|
||
designHeightCm,
|
||
};
|
||
console.log('Adding to cart with dimensions:', { designWidthCm, designHeightCm, cartItem });
|
||
addItem(cartItem);
|
||
|
||
// Clear design draft now that it's been added to cart
|
||
if (typeof window !== 'undefined') {
|
||
localStorage.removeItem(`designDraft_${product.id}`);
|
||
console.log('✓ Design draft cleared after adding to cart');
|
||
}
|
||
|
||
setJustAdded(true);
|
||
setTimeout(() => setJustAdded(false), 2200);
|
||
});
|
||
};
|
||
|
||
const handleSubmitForApproval = async () => {
|
||
// First, save design to localStorage (in case user needs to login)
|
||
if (typeof window !== 'undefined' && (elementsFront.length > 0 || elementsBack.length > 0)) {
|
||
localStorage.setItem(`designDraft_${product.id}`, JSON.stringify({
|
||
elementsFront,
|
||
elementsBack,
|
||
color,
|
||
size,
|
||
designWidthCm,
|
||
designHeightCm,
|
||
imageDimensions,
|
||
quantity,
|
||
timestamp: new Date().toISOString(),
|
||
}));
|
||
}
|
||
|
||
if (elementsFront.length === 0 && elementsBack.length === 0) {
|
||
return;
|
||
}
|
||
|
||
setSubmittingForApproval(true);
|
||
setSelectedId(null);
|
||
requestAnimationFrame(async () => {
|
||
const frontStage = stageFrontRef.current;
|
||
if (!frontStage) {
|
||
setSubmittingForApproval(false);
|
||
return;
|
||
}
|
||
|
||
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());
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/api/designs/submit', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
productId: product.id,
|
||
designJson: JSON.stringify({ front: elementsFront, back: elementsBack }),
|
||
designPreviewUrl: frontDataUrl,
|
||
designPreviewUrlBack: backDataUrl,
|
||
placementPreviewUrl: frontPlacementUrl ?? frontDataUrl,
|
||
placementPreviewUrlBack: backPlacementUrl ?? backDataUrl,
|
||
designWidthCm,
|
||
designHeightCm,
|
||
designImageDimensions: Object.entries(imageDimensions).map(([imageId, dims]) => ({
|
||
imageId,
|
||
widthCm: dims.widthCm,
|
||
heightCm: dims.heightCm,
|
||
})),
|
||
color,
|
||
size,
|
||
}),
|
||
});
|
||
|
||
if (res.ok) {
|
||
const design = await res.json();
|
||
setDesignApprovalId(design.id);
|
||
setApprovalStatus('PENDING');
|
||
setSubmissionMessage(`✓ Design submitted for approval! You'll receive an email when it's ready to add to your bag.`);
|
||
// Save to localStorage so status persists on page reload
|
||
if (typeof window !== 'undefined') {
|
||
localStorage.setItem(`designApproval_${product.id}`, JSON.stringify({
|
||
id: design.id,
|
||
status: 'PENDING',
|
||
submittedAt: new Date().toISOString(),
|
||
}));
|
||
}
|
||
console.log('Design submitted for approval:', design.id);
|
||
setTimeout(() => setSubmissionMessage(null), 5000);
|
||
} else if (res.status === 401) {
|
||
setShowLoginPrompt(true);
|
||
console.log('User not authenticated, showing login prompt');
|
||
} else {
|
||
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
|
||
console.error('Failed to submit design for approval:', err.error);
|
||
setSubmissionMessage(`✗ Failed to submit design: ${err.error || 'Unknown error'}`);
|
||
}
|
||
} finally {
|
||
setSubmittingForApproval(false);
|
||
}
|
||
});
|
||
};
|
||
|
||
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}
|
||
product={product}
|
||
/>
|
||
|
||
{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}
|
||
product={product}
|
||
/>
|
||
)}
|
||
</>
|
||
) : (
|
||
<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'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>
|
||
|
||
{/* Image Dimension Inputs */}
|
||
{(() => {
|
||
const images = view === 'front' ? elementsFront.filter(el => el.type === 'image') : elementsBack.filter(el => el.type === 'image');
|
||
return images.length > 0 ? (
|
||
<div className="mb-4 space-y-3">
|
||
{images.map((img, idx) => (
|
||
<div key={img.id} className="border border-line bg-surface p-3">
|
||
<p className="tag-label mb-2">Image {idx + 1} Dimensions</p>
|
||
<div className="flex gap-3">
|
||
<label className="flex flex-col gap-1 flex-1">
|
||
<span className="text-xs text-muted">Width (cm)</span>
|
||
<input
|
||
type="number"
|
||
step="0.1"
|
||
min="0"
|
||
value={imageDimensions[img.id]?.widthCm ?? ''}
|
||
onChange={(e) => {
|
||
const val = e.target.value ? Number(e.target.value) : null;
|
||
setImageDimensions((prev) => ({
|
||
...prev,
|
||
[img.id]: { ...prev[img.id], widthCm: val },
|
||
}));
|
||
}}
|
||
placeholder="e.g. 10"
|
||
className="border border-line bg-paper px-3 py-2 text-sm"
|
||
/>
|
||
</label>
|
||
<label className="flex flex-col gap-1 flex-1">
|
||
<span className="text-xs text-muted">Height (cm)</span>
|
||
<input
|
||
type="number"
|
||
step="0.1"
|
||
min="0"
|
||
value={imageDimensions[img.id]?.heightCm ?? ''}
|
||
onChange={(e) => {
|
||
const val = e.target.value ? Number(e.target.value) : null;
|
||
setImageDimensions((prev) => ({
|
||
...prev,
|
||
[img.id]: { ...prev[img.id], heightCm: val },
|
||
}));
|
||
}}
|
||
placeholder="e.g. 10"
|
||
className="border border-line bg-paper px-3 py-2 text-sm"
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null;
|
||
})()}
|
||
|
||
{/* Font Properties Inputs */}
|
||
{(() => {
|
||
const texts = view === 'front' ? elementsFront.filter(el => el.type === 'text') : elementsBack.filter(el => el.type === 'text');
|
||
return texts.length > 0 ? (
|
||
<div className="mb-4 space-y-3">
|
||
{texts.map((text, idx) => (
|
||
<div key={text.id} className="border border-line bg-surface p-3">
|
||
<p className="tag-label mb-3">Font {idx + 1}</p>
|
||
<div className="space-y-3">
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-muted">Text</span>
|
||
<input
|
||
type="text"
|
||
value={text.text}
|
||
onChange={(e) => updateElement(text.id, { text: e.target.value })}
|
||
className="border border-line bg-paper px-3 py-2 text-sm"
|
||
placeholder="Text content"
|
||
/>
|
||
</label>
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-muted">Font Family</span>
|
||
<select
|
||
value={text.fontFamily}
|
||
onChange={(e) => updateElement(text.id, { fontFamily: e.target.value })}
|
||
className="border border-line bg-paper px-3 py-2 text-sm"
|
||
style={{ fontFamily: text.fontFamily }}
|
||
>
|
||
{FONTS.map((f) => (
|
||
<option key={f} value={f} style={{ fontFamily: f }}>
|
||
{f}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<div className="flex gap-3">
|
||
<label className="flex flex-col gap-1 flex-1">
|
||
<span className="text-xs text-muted">Font Size</span>
|
||
<div className="flex gap-2 items-center">
|
||
<input
|
||
type="range"
|
||
min={10}
|
||
max={80}
|
||
value={text.fontSize}
|
||
onChange={(e) => updateElement(text.id, { fontSize: Number(e.target.value) })}
|
||
className="flex-1"
|
||
/>
|
||
<span className="w-12 text-right font-mono text-xs">{text.fontSize}pt</span>
|
||
</div>
|
||
</label>
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-muted">Color</span>
|
||
<input
|
||
type="color"
|
||
value={text.fill}
|
||
onChange={(e) => updateElement(text.id, { fill: e.target.value })}
|
||
className="h-10 w-16 border border-line bg-paper p-1 cursor-pointer"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-muted">Rotation (degrees)</span>
|
||
<div className="flex gap-2 items-center">
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={360}
|
||
value={text.rotation || 0}
|
||
onChange={(e) => updateElement(text.id, { rotation: Number(e.target.value) })}
|
||
className="flex-1"
|
||
/>
|
||
<span className="w-12 text-right font-mono text-xs">{text.rotation || 0}°</span>
|
||
</div>
|
||
</label>
|
||
|
||
{/* Curved Text Controls */}
|
||
<div className="border-t border-line pt-3">
|
||
<label className="flex items-center gap-2 text-sm text-muted mb-3">
|
||
<input
|
||
type="checkbox"
|
||
checked={text.curvedPath ? true : false}
|
||
onChange={(e) => updateElement(text.id, { curvedPath: e.target.checked ? { type: 'circle', radius: 80 } : null })}
|
||
className="cursor-pointer"
|
||
/>
|
||
Curved text
|
||
</label>
|
||
|
||
{text.curvedPath && (
|
||
<div className="space-y-3">
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-muted">Curve type</span>
|
||
<select
|
||
value={text.curvedPath.type || 'circle'}
|
||
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, type: e.target.value as any } })}
|
||
className="border border-line bg-paper px-2 py-1 text-xs"
|
||
>
|
||
<option value="circle">Circle</option>
|
||
<option value="wave">Wave</option>
|
||
<option value="spiral">Spiral</option>
|
||
<option value="arc">Arc</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-muted">Radius / Intensity</span>
|
||
<div className="flex gap-2 items-center">
|
||
<input
|
||
type="range"
|
||
min={20}
|
||
max={200}
|
||
value={text.curvedPath.radius || 80}
|
||
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, radius: Number(e.target.value) } })}
|
||
className="flex-1"
|
||
/>
|
||
<span className="w-12 text-right font-mono text-xs">{text.curvedPath.radius || 80}</span>
|
||
</div>
|
||
</label>
|
||
|
||
<label className="flex items-center gap-2 text-xs">
|
||
<input
|
||
type="checkbox"
|
||
checked={text.curvedPath.reverse ? true : false}
|
||
onChange={(e) => updateElement(text.id, { curvedPath: { ...text.curvedPath, reverse: e.target.checked } })}
|
||
className="cursor-pointer"
|
||
/>
|
||
<span className="text-muted">Reverse direction</span>
|
||
</label>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null;
|
||
})()}
|
||
|
||
<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">
|
||
Font Size
|
||
<input
|
||
type="range"
|
||
min={10}
|
||
max={80}
|
||
value={selected.fontSize}
|
||
onChange={(e) => updateElement(selected.id, { fontSize: Number(e.target.value) })}
|
||
/>
|
||
<span className="w-12 text-right font-mono text-xs">{selected.fontSize}pt</span>
|
||
</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>
|
||
)}
|
||
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-col gap-3">
|
||
{submissionMessage && (
|
||
<p className={`text-sm px-4 py-3 rounded border ${
|
||
submissionMessage.startsWith('✓')
|
||
? 'text-green-700 bg-green-50 border-green-200'
|
||
: 'text-splash-pink bg-red-50 border-splash-pink/30'
|
||
}`}>
|
||
{submissionMessage}
|
||
</p>
|
||
)}
|
||
{approvalStatus === 'PENDING' && !submissionMessage && (
|
||
<p className="text-sm text-orange-600 bg-orange-50 border border-orange-200 px-4 py-3 rounded">
|
||
⏳ Your design is pending admin approval. You'll receive an email when it's ready to add to your bag.
|
||
</p>
|
||
)}
|
||
{approvalStatus === 'APPROVED' && (
|
||
<p className="text-sm text-pine bg-green-50 border border-green-200 px-4 py-3 rounded">
|
||
✓ Your design has been approved! You can now add it to your bag.
|
||
</p>
|
||
)}
|
||
{approvalStatus === 'REJECTED' && (
|
||
<p className="text-sm text-splash-pink bg-red-50 border border-splash-pink/30 px-4 py-3 rounded">
|
||
Your design needs changes. Check your messages for feedback.
|
||
</p>
|
||
)}
|
||
|
||
<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>
|
||
{approvalStatus === 'APPROVED' ? (
|
||
<button
|
||
onClick={handleAddToBag}
|
||
disabled={elementsFront.length === 0 && elementsBack.length === 0}
|
||
className={`flex-1 px-6 py-3 text-sm font-medium transition-colors ${
|
||
elementsFront.length === 0 && elementsBack.length === 0
|
||
? 'bg-muted text-muted/50 cursor-not-allowed'
|
||
: 'bg-clay text-paper hover:bg-clay-dark'
|
||
}`}
|
||
>
|
||
Add to bag — <Price cents={((elementsFront.length > 0 || elementsBack.length > 0) ? effectivePriceForPersonalised : product.effectivePrice) * quantity} />
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={handleSubmitForApproval}
|
||
disabled={elementsFront.length === 0 && elementsBack.length === 0 || submittingForApproval || approvalStatus === 'PENDING'}
|
||
className={`flex-1 px-6 py-3 text-sm font-medium transition-colors ${
|
||
elementsFront.length === 0 && elementsBack.length === 0 || submittingForApproval || approvalStatus === 'PENDING'
|
||
? 'bg-muted text-muted/50 cursor-not-allowed'
|
||
: 'bg-clay text-paper hover:bg-clay-dark'
|
||
}`}
|
||
>
|
||
{submittingForApproval ? 'Submitting…' : 'Submit for Approval'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{elementsFront.length === 0 && elementsBack.length === 0 && !approvalStatus && (
|
||
<p className="text-xs text-splash-pink">Add text or images to personalize your design before submitting</p>
|
||
)}
|
||
{justAdded && <p className="text-sm text-pine">Added to your bag.</p>}
|
||
</div>
|
||
</div>
|
||
|
||
<LoginPromptModal isOpen={showLoginPrompt} onClose={() => setShowLoginPrompt(false)} />
|
||
</div>
|
||
);
|
||
}
|