Initial commit: Craft2Prints with Stages 1-3
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
'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' }: { variant?: 'front' | 'back' }) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [box, setBox] = useState<Box>(DEFAULT_BOX);
|
||||
const containerRef = useRef<HTMLDivElement>(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 handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
setPreviewUrl(null);
|
||||
return;
|
||||
}
|
||||
setPreviewUrl(URL.createObjectURL(file));
|
||||
setBox(DEFAULT_BOX);
|
||||
};
|
||||
|
||||
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>
|
||||
<input
|
||||
id={fieldName}
|
||||
name={fieldName}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="w-full border border-line bg-paper px-3 py-2 text-sm"
|
||||
/>
|
||||
{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'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>
|
||||
)}
|
||||
|
||||
{previewUrl && (
|
||||
<div className="mt-4">
|
||||
<p className="tag-label mb-2">Drag the box to mark the print area</p>
|
||||
<p className="mb-2 text-xs text-muted">
|
||||
Cropped to a square here — that'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={previewUrl}
|
||||
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={`hasCustomPrintArea${suffix}`} value={previewUrl ? '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));
|
||||
}
|
||||
Reference in New Issue
Block a user