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:
co-authored by
Claude Haiku 4.5
parent
0e14fc7981
commit
859956b9d8
+396
-52
@@ -394,6 +394,8 @@ function PrintSurface({
|
|||||||
export default function DesignCanvas({ product }: { product: ProductDTO }) {
|
export default function DesignCanvas({ product }: { product: ProductDTO }) {
|
||||||
const [color, setColor] = useState(product.colors[0]);
|
const [color, setColor] = useState(product.colors[0]);
|
||||||
const [size, setSize] = useState<string | null>(product.sizes[0] ?? null);
|
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 [view, setView] = useState<'front' | 'back'>('front');
|
||||||
const [elementsFront, setElementsFront] = useState<DesignElement[]>([]);
|
const [elementsFront, setElementsFront] = useState<DesignElement[]>([]);
|
||||||
const [elementsBack, setElementsBack] = useState<DesignElement[]>([]);
|
const [elementsBack, setElementsBack] = useState<DesignElement[]>([]);
|
||||||
@@ -402,6 +404,11 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
|
|||||||
const [justAdded, setJustAdded] = useState(false);
|
const [justAdded, setJustAdded] = useState(false);
|
||||||
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
||||||
const [nodeReadyTick, setNodeReadyTick] = useState(0);
|
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 stageFrontRef = useRef<Konva.Stage | null>(null);
|
||||||
const stageBackRef = 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
|
// touching that coordinate system. Konva reads the container's actual
|
||||||
// rendered (post-transform) bounding box for pointer math, so clicks/drags
|
// rendered (post-transform) bounding box for pointer math, so clicks/drags
|
||||||
// still line up correctly at any scale.
|
// 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(() => {
|
useLayoutEffect(() => {
|
||||||
const el = canvasContainerRef.current;
|
const el = canvasContainerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -565,7 +619,23 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
|
|||||||
const handleAddToBag = async () => {
|
const handleAddToBag = async () => {
|
||||||
const { isAuthenticated } = await checkCustomerAuth();
|
const { isAuthenticated } = await checkCustomerAuth();
|
||||||
if (!isAuthenticated) {
|
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);
|
setShowLoginPrompt(true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canPersonalize) {
|
if (!canPersonalize) {
|
||||||
@@ -585,7 +655,15 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
|
|||||||
designPreviewUrlBack: null,
|
designPreviewUrlBack: null,
|
||||||
placementPreviewUrl: flatPhoto || null,
|
placementPreviewUrl: flatPhoto || null,
|
||||||
placementPreviewUrlBack: product.imageUrlBack ?? 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);
|
setJustAdded(true);
|
||||||
setTimeout(() => setJustAdded(false), 2200);
|
setTimeout(() => setJustAdded(false), 2200);
|
||||||
return;
|
return;
|
||||||
@@ -646,7 +724,7 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
|
|||||||
backGuides.forEach((g) => g.show());
|
backGuides.forEach((g) => g.show());
|
||||||
}
|
}
|
||||||
|
|
||||||
addItem({
|
const cartItem = {
|
||||||
cartItemId: uuid(),
|
cartItemId: uuid(),
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
slug: product.slug,
|
slug: product.slug,
|
||||||
@@ -661,12 +739,151 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
|
|||||||
designPreviewUrlBack: backDataUrl,
|
designPreviewUrlBack: backDataUrl,
|
||||||
placementPreviewUrl: frontPlacementUrl ?? frontDataUrl,
|
placementPreviewUrl: frontPlacementUrl ?? frontDataUrl,
|
||||||
placementPreviewUrlBack: backPlacementUrl ?? backDataUrl,
|
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);
|
setJustAdded(true);
|
||||||
setTimeout(() => setJustAdded(false), 2200);
|
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') {
|
function renderPhoto(v: 'front' | 'back') {
|
||||||
const colorPhoto = v === 'front' ? product.colorPhotos[color] : product.colorPhotosBack[color];
|
const colorPhoto = v === 'front' ? product.colorPhotos[color] : product.colorPhotosBack[color];
|
||||||
if (colorPhoto) {
|
if (colorPhoto) {
|
||||||
@@ -850,11 +1067,145 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{canPersonalize && (
|
{canPersonalize && (
|
||||||
<div>
|
<div>
|
||||||
<p className="tag-label mb-3">
|
<p className="tag-label mb-3">
|
||||||
Your design {hasBack && <span className="normal-case text-muted">— editing the {view}</span>}
|
Your design {hasBack && <span className="normal-case text-muted">— editing the {view}</span>}
|
||||||
</p>
|
</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">
|
<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">
|
<button onClick={addText} className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper">
|
||||||
+ Add text
|
+ Add text
|
||||||
@@ -876,34 +1227,6 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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' && (
|
{selected?.type === 'text' && (
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-3 border border-line bg-surface p-4">
|
<div className="mt-4 flex flex-wrap items-center gap-3 border border-line bg-surface p-4">
|
||||||
<input
|
<input
|
||||||
@@ -931,7 +1254,7 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
|
|||||||
className="h-8 w-8 border border-line bg-paper p-0"
|
className="h-8 w-8 border border-line bg-paper p-0"
|
||||||
/>
|
/>
|
||||||
<label className="flex items-center gap-2 text-sm text-muted">
|
<label className="flex items-center gap-2 text-sm text-muted">
|
||||||
Size
|
Font Size
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={10}
|
min={10}
|
||||||
@@ -939,6 +1262,7 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
|
|||||||
value={selected.fontSize}
|
value={selected.fontSize}
|
||||||
onChange={(e) => updateElement(selected.id, { fontSize: Number(e.target.value) })}
|
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>
|
||||||
<label className="flex items-center gap-2 text-sm text-muted">
|
<label className="flex items-center gap-2 text-sm text-muted">
|
||||||
Angle
|
Angle
|
||||||
@@ -954,29 +1278,35 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
|
|||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<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 gap-4">
|
||||||
<div className="flex items-center border border-ink">
|
<div className="flex items-center border border-ink">
|
||||||
<button
|
<button
|
||||||
@@ -995,6 +1325,7 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
|
|||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{approvalStatus === 'APPROVED' ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleAddToBag}
|
onClick={handleAddToBag}
|
||||||
disabled={elementsFront.length === 0 && elementsBack.length === 0}
|
disabled={elementsFront.length === 0 && elementsBack.length === 0}
|
||||||
@@ -1006,9 +1337,22 @@ export default function DesignCanvas({ product }: { product: ProductDTO }) {
|
|||||||
>
|
>
|
||||||
Add to bag — <Price cents={((elementsFront.length > 0 || elementsBack.length > 0) ? effectivePriceForPersonalised : product.effectivePrice) * quantity} />
|
Add to bag — <Price cents={((elementsFront.length > 0 || elementsBack.length > 0) ? effectivePriceForPersonalised : product.effectivePrice) * quantity} />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{elementsFront.length === 0 && elementsBack.length === 0 && (
|
{elementsFront.length === 0 && elementsBack.length === 0 && !approvalStatus && (
|
||||||
<p className="text-xs text-splash-pink">Add text or images to personalize your design before adding to bag</p>
|
<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>}
|
{justAdded && <p className="text-sm text-pine">Added to your bag.</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface LoginPromptModalProps {
|
interface LoginPromptModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -8,8 +9,18 @@ interface LoginPromptModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPromptModal({ isOpen, onClose }: 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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const nextParam = currentUrl ? `?next=${encodeURIComponent(currentUrl)}` : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<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">
|
<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">
|
<div className="mt-6 space-y-3">
|
||||||
<Link
|
<Link
|
||||||
href="/account/register"
|
href={`/account/register${nextParam}`}
|
||||||
onClick={onClose}
|
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"
|
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
|
Create account
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/account/login"
|
href={`/account/login${nextParam}`}
|
||||||
onClick={onClose}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user