Fix design persistence and enhance font/image property controls

- Fix: Redirect users back to product page after login by passing 'next' parameter
  in LoginPromptModal. Previously users were redirected to /account and lost
  design context. Now they return to where they started (e.g., /made-to-order/
  classic-t-shirt) where design drafts are restored from localStorage.

- Fix: Clear design draft from localStorage only AFTER successful cart addition,
  not immediately. This ensures draft persists if user needs to login mid-design.

- Feature: Add Font Properties boxes (Font 1, Font 2, etc.) similar to Image
  Dimensions. Each text element now displays with editable controls for:
  • Text content
  • Font family (dropdown with all fonts)
  • Font size (slider 10-80pt with display)
  • Text color (color picker)
  • Rotation (slider 0-360 degrees)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-18 15:09:45 +01:00
co-authored by Claude Haiku 4.5
parent 0e14fc7981
commit 859956b9d8
2 changed files with 420 additions and 65 deletions
+407 -63
View File
@@ -394,6 +394,8 @@ function PrintSurface({
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[]>([]);
@@ -402,6 +404,11 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
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);
@@ -419,6 +426,53 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
// 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;
@@ -565,7 +619,23 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
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) {
@@ -585,7 +655,15 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
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;
@@ -646,7 +724,7 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
backGuides.forEach((g) => g.show());
}
addItem({
const cartItem = {
cartItemId: uuid(),
productId: product.id,
slug: product.slug,
@@ -661,12 +739,151 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
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) {
@@ -850,11 +1067,145 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
</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>
</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
@@ -876,34 +1227,6 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
)}
</div>
{selected && (
<div className="mt-4 border border-line bg-surface p-4">
<p className="mb-3 font-medium text-sm">Position & Size (cm)</p>
<div className="space-y-2 text-xs font-mono">
<div className="flex justify-between">
<span className="text-muted">X:</span>
<span>{pxToCm(selected.x)} cm</span>
</div>
<div className="flex justify-between">
<span className="text-muted">Y:</span>
<span>{pxToCm(selected.y)} cm</span>
</div>
{selected.type === 'image' && (
<>
<div className="flex justify-between">
<span className="text-muted">Width:</span>
<span>{pxToCm(selected.width)} cm</span>
</div>
<div className="flex justify-between">
<span className="text-muted">Height:</span>
<span>{pxToCm(selected.height)} cm</span>
</div>
</>
)}
</div>
</div>
)}
{selected?.type === 'text' && (
<div className="mt-4 flex flex-wrap items-center gap-3 border border-line bg-surface p-4">
<input
@@ -931,7 +1254,7 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
className="h-8 w-8 border border-line bg-paper p-0"
/>
<label className="flex items-center gap-2 text-sm text-muted">
Size
Font Size
<input
type="range"
min={10}
@@ -939,6 +1262,7 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
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
@@ -954,29 +1278,35 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
</div>
)}
{selected?.type === 'image' && (
<div className="mt-4 flex flex-wrap items-center gap-3 border border-line bg-surface p-4">
<div className="flex items-center gap-3 text-sm">
<span className="font-medium">Size:</span>
<span className="font-mono text-xs">{Math.round(selected.width)} × {Math.round(selected.height)} px</span>
</div>
<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
@@ -995,20 +1325,34 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
+
</button>
</div>
<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>
{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 && (
<p className="text-xs text-splash-pink">Add text or images to personalize your design before adding to bag</p>
{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>
+13 -2
View File
@@ -1,6 +1,7 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
interface LoginPromptModalProps {
isOpen: boolean;
@@ -8,8 +9,18 @@ interface LoginPromptModalProps {
}
export default function LoginPromptModal({ isOpen, onClose }: LoginPromptModalProps) {
const [currentUrl, setCurrentUrl] = useState('');
useEffect(() => {
if (typeof window !== 'undefined') {
setCurrentUrl(window.location.pathname + window.location.search);
}
}, [isOpen]);
if (!isOpen) return null;
const nextParam = currentUrl ? `?next=${encodeURIComponent(currentUrl)}` : '';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="mx-4 w-full max-w-sm rounded-lg border border-line bg-paper p-6 shadow-lg">
@@ -20,14 +31,14 @@ export default function LoginPromptModal({ isOpen, onClose }: LoginPromptModalPr
<div className="mt-6 space-y-3">
<Link
href="/account/register"
href={`/account/register${nextParam}`}
onClick={onClose}
className="block w-full rounded bg-clay px-4 py-3 text-center text-sm font-medium text-paper transition-colors hover:bg-clay-dark"
>
Create account
</Link>
<Link
href="/account/login"
href={`/account/login${nextParam}`}
onClick={onClose}
className="block w-full rounded border border-ink px-4 py-3 text-center text-sm font-medium text-ink transition-colors hover:border-clay hover:bg-clay hover:text-paper"
>