Files
craft2prints/src/components/PhotoPrintAreaField.tsx
T
AndymickandClaude Haiku 4.5 9ce0930a54 Fix: Image upload now properly persists through form submission
Changed PhotoPrintAreaField to hide the file input with CSS (display: none)
instead of conditionally removing it from the DOM. This ensures the file
input element remains in the document, preserving the selected file data
when the form is submitted.

Previous issue: When the print area editor opened, React would remove the
file input element from the DOM, causing the file data to be lost before
form submission. This resulted in images not being saved (imageUrl: null).

Result: Images now save correctly to the database as base64 data URLs.
Print area masks are also preserved alongside the images.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-20 20:31:32 +01:00

223 lines
8.1 KiB
TypeScript

'use client';
import { useRef, useState } from 'react';
type Box = { xPct: number; yPct: number; wPct: number; hPct: number }; // 0-100
const DEFAULT_BOX: Box = { xPct: 30, yPct: 25, wPct: 40, hPct: 35 };
const MIN_SIZE = 6; // percent
export default function PhotoPrintAreaField({
variant = 'front',
existingImageUrl,
}: {
variant?: 'front' | 'back';
existingImageUrl?: string;
}) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [box, setBox] = useState<Box>(DEFAULT_BOX);
const [showPrintAreaEditor, setShowPrintAreaEditor] = useState(false);
const [isRemoved, setIsRemoved] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragState = useRef<{
mode: 'move' | 'resize';
startX: number;
startY: number;
startBox: Box;
} | null>(null);
const fieldName = variant === 'front' ? 'image' : 'imageBack';
const suffix = variant === 'front' ? '' : 'Back';
const removeFieldName = variant === 'front' ? 'removeImage' : 'removeImageBack';
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
setPreviewUrl(null);
setShowPrintAreaEditor(false);
setIsRemoved(false);
return;
}
setPreviewUrl(URL.createObjectURL(file));
setBox(DEFAULT_BOX);
setShowPrintAreaEditor(true);
setIsRemoved(false);
};
const handleClearFile = () => {
setPreviewUrl(null);
setShowPrintAreaEditor(false);
setIsRemoved(true);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleBackToFile = () => {
setShowPrintAreaEditor(false);
};
const currentImageUrl = previewUrl || (isRemoved ? null : existingImageUrl);
const onBoxMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
dragState.current = { mode: 'move', startX: e.clientX, startY: e.clientY, startBox: box };
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
const onResizeMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
dragState.current = { mode: 'resize', startX: e.clientX, startY: e.clientY, startBox: box };
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
const state = dragState.current;
const rect = containerRef.current?.getBoundingClientRect();
if (!state || !rect) return;
const dxPct = ((e.clientX - state.startX) / rect.width) * 100;
const dyPct = ((e.clientY - state.startY) / rect.height) * 100;
if (state.mode === 'move') {
const xPct = clamp(state.startBox.xPct + dxPct, 0, 100 - state.startBox.wPct);
const yPct = clamp(state.startBox.yPct + dyPct, 0, 100 - state.startBox.hPct);
setBox({ ...state.startBox, xPct, yPct });
} else {
const wPct = clamp(state.startBox.wPct + dxPct, MIN_SIZE, 100 - state.startBox.xPct);
const hPct = clamp(state.startBox.hPct + dyPct, MIN_SIZE, 100 - state.startBox.yPct);
setBox({ ...state.startBox, wPct, hPct });
}
};
const onMouseUp = () => {
dragState.current = null;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
return (
<div>
{currentImageUrl && !showPrintAreaEditor && (
<div className="mb-3 flex items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={currentImageUrl} alt="Product preview" className="h-20 w-20 border border-line object-contain" />
<p className="text-xs text-muted">Current photo. Upload a new one below to replace it.</p>
</div>
)}
<input
ref={fileInputRef}
id={fieldName}
name={fieldName}
type="file"
accept="image/*"
onChange={handleFileChange}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
style={{ display: showPrintAreaEditor && currentImageUrl ? 'none' : 'block' }}
/>
{!currentImageUrl || !showPrintAreaEditor ? (
<>
{variant === 'front' ? (
<p className="mt-1 text-xs text-muted">
Uploading a real photo replaces the illustration everywhere this product is shown. One
tradeoff: the color swatches below can no longer recolor a photo the way they recolor the
illustration they&apos;ll still be saved with each order, but the picture itself stays
fixed. Leave this blank to keep using the illustration (which does recolor live).
</p>
) : (
<p className="mt-1 text-xs text-muted">
If set, customers get a Front/Back toggle and can add a separate design to the back
drag a print-area box below just like the front.
</p>
)}
{currentImageUrl && !showPrintAreaEditor && (
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => setShowPrintAreaEditor(true)}
className="text-sm text-clay hover:text-clay-dark font-medium"
>
Modify print area
</button>
<button
type="button"
onClick={handleClearFile}
className="text-sm text-splash-pink hover:text-splash-pink-dark font-medium"
>
Remove
</button>
</div>
)}
</>
) : null}
{currentImageUrl && showPrintAreaEditor && (
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<p className="tag-label">Drag the box to mark the print area</p>
<button
type="button"
onClick={handleBackToFile}
className="text-sm text-clay hover:text-clay-dark font-medium"
>
Back to file
</button>
</div>
<p className="mb-2 text-xs text-muted">
Cropped to a square here that&apos;s the same crop the design tool uses, so the box
lines up correctly.
</p>
<div
ref={containerRef}
className="relative aspect-square w-full max-w-sm select-none border border-line"
style={{ touchAction: 'none' }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={currentImageUrl}
alt="Product preview"
className="absolute inset-0 h-full w-full object-cover"
draggable={false}
/>
<div
onMouseDown={onBoxMouseDown}
className="absolute cursor-move border-2 border-dashed bg-splash-pink/10"
style={{
left: `${box.xPct}%`,
top: `${box.yPct}%`,
width: `${box.wPct}%`,
height: `${box.hPct}%`,
borderColor: '#E5227E',
}}
>
<div
onMouseDown={onResizeMouseDown}
className="absolute -bottom-1.5 -right-1.5 h-4 w-4 cursor-se-resize rounded-full border border-paper bg-splash-pink"
/>
</div>
</div>
<p className="mt-1 text-xs text-muted">Drag the pink handle to resize.</p>
</div>
)}
<input type="hidden" name={removeFieldName} value={isRemoved ? '1' : ''} />
<input type="hidden" name={`hasCustomPrintArea${suffix}`} value={currentImageUrl ? '1' : ''} />
<input type="hidden" name={`printX${suffix}`} value={box.xPct / 100} />
<input type="hidden" name={`printY${suffix}`} value={box.yPct / 100} />
<input type="hidden" name={`printW${suffix}`} value={box.wPct / 100} />
<input type="hidden" name={`printH${suffix}`} value={box.hPct / 100} />
</div>
);
}
function clamp(n: number, min: number, max: number) {
return Math.min(Math.max(n, min), Math.max(min, max));
}