Add design approval workflow and per-image dimension tracking
- Add DesignApproval and DesignApprovalMessage models for design review workflow - Add design approval admin interface (list, detail, chat, approve/reject) - Add per-image dimension tracking to OrderItem - Add design submission API endpoint - Add AdminDesignEditor for admin-side design modifications - Add DesignApprovalChat component for customer-admin communication - Update design specification generation with image dimension details - Add design persistence across login with next parameter redirect - Add font properties UI with font size and color display Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
10764f3a5b
commit
5c4774be84
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { Stage, Layer, Text, Image as KonvaImage, Transformer } from 'react-konva';
|
||||
import type Konva from 'konva';
|
||||
import type { DesignElement } from '@/lib/types';
|
||||
|
||||
interface AdminDesignEditorProps {
|
||||
designId: string;
|
||||
designJson: string;
|
||||
designPreviewUrl: string;
|
||||
placementPreviewUrl?: string | null;
|
||||
onSave: (designJson: string, previewUrl: string) => void;
|
||||
}
|
||||
|
||||
export default function AdminDesignEditor({
|
||||
designId,
|
||||
designJson,
|
||||
designPreviewUrl,
|
||||
placementPreviewUrl,
|
||||
onSave,
|
||||
}: AdminDesignEditorProps) {
|
||||
const stageRef = useRef<Konva.Stage | null>(null);
|
||||
const transformerRef = useRef<Konva.Transformer | null>(null);
|
||||
const nodeRefs = useRef<Map<string, Konva.Node>>(new Map());
|
||||
const [elements, setElements] = useState<DesignElement[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const design = JSON.parse(designJson);
|
||||
setElements([...(design.front || []), ...(design.back || [])]);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse design JSON:', e);
|
||||
}
|
||||
}, [designJson]);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!selectedId) return;
|
||||
setElements((els) => els.filter((el) => el.id !== selectedId));
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
|
||||
const previewUrl = stage.toDataURL({ pixelRatio: 2 });
|
||||
|
||||
await fetch(`/api/designs/${designId}/modify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
designJson: JSON.stringify({ front: elements, back: [] }),
|
||||
designPreviewUrl: previewUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
onSave(JSON.stringify({ front: elements, back: [] }), previewUrl);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const DISPLAY = 560;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={!selectedId}
|
||||
className="text-sm px-4 py-2 border border-splash-pink text-splash-pink hover:bg-splash-pink/10 disabled:opacity-50"
|
||||
>
|
||||
Delete selected
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="text-sm px-4 py-2 bg-clay text-paper hover:bg-clay-dark disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border border-line p-4 bg-surface overflow-auto" style={{ maxHeight: '600px' }}>
|
||||
<Stage
|
||||
ref={stageRef}
|
||||
width={DISPLAY}
|
||||
height={DISPLAY}
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.target.getStage()) setSelectedId(null);
|
||||
}}
|
||||
>
|
||||
<Layer>
|
||||
{elements.map((el) => {
|
||||
if (el.type === 'text') {
|
||||
return (
|
||||
<Text
|
||||
key={el.id}
|
||||
x={el.x}
|
||||
y={el.y}
|
||||
text={el.text}
|
||||
fontSize={el.fontSize}
|
||||
fontFamily={el.fontFamily}
|
||||
fill={el.fill}
|
||||
rotation={el.rotation}
|
||||
draggable
|
||||
selected={selectedId === el.id}
|
||||
onClick={() => setSelectedId(el.id)}
|
||||
onTap={() => setSelectedId(el.id)}
|
||||
onDragEnd={(e) => {
|
||||
const updated = elements.map((elem) =>
|
||||
elem.id === el.id ? { ...elem, x: e.target.x(), y: e.target.y() } : elem
|
||||
);
|
||||
setElements(updated);
|
||||
}}
|
||||
onTransformEnd={() => {
|
||||
const node = nodeRefs.current.get(el.id);
|
||||
if (node instanceof Konva.Text) {
|
||||
const updated = elements.map((elem) =>
|
||||
elem.id === el.id
|
||||
? {
|
||||
...elem,
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
rotation: node.rotation(),
|
||||
}
|
||||
: elem
|
||||
);
|
||||
setElements(updated);
|
||||
}
|
||||
}}
|
||||
ref={(node) => {
|
||||
if (node) nodeRefs.current.set(el.id, node);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
{selectedId && (
|
||||
<Transformer
|
||||
ref={transformerRef}
|
||||
node={nodeRefs.current.get(selectedId) as any}
|
||||
boundBoxFunc={(oldBox, newBox) => newBox}
|
||||
/>
|
||||
)}
|
||||
</Layer>
|
||||
</Stage>
|
||||
</div>
|
||||
|
||||
{placementPreviewUrl && (
|
||||
<div>
|
||||
<p className="text-sm text-muted mb-2">Design on product:</p>
|
||||
<img
|
||||
src={placementPreviewUrl}
|
||||
alt="design on product"
|
||||
className="w-full max-w-xs border border-line"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user