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:
Andymick
2026-07-18 16:21:19 +01:00
co-authored by Claude Haiku 4.5
parent 10764f3a5b
commit 5c4774be84
16 changed files with 935 additions and 1 deletions
+219
View File
@@ -0,0 +1,219 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import DesignApprovalChat from '@/components/DesignApprovalChat';
import AdminDesignEditor from '@/components/AdminDesignEditor';
interface DesignApproval {
id: string;
customerEmail: string;
customerName: string | null;
productId: string;
product: {
id: string;
name: string;
};
designJson: string;
designPreviewUrl: string;
designPreviewUrlBack: string | null;
placementPreviewUrl: string | null;
placementPreviewUrlBack: string | null;
designWidthCm: number | null;
designHeightCm: number | null;
color: string;
size: string | null;
status: string;
createdAt: string;
}
export default function DesignDetailPage({ params }: { params: { id: string } }) {
const [design, setDesign] = useState<DesignApproval | null>(null);
const [loading, setLoading] = useState(true);
const [approving, setApproving] = useState(false);
const [editingDesign, setEditingDesign] = useState(false);
useEffect(() => {
fetch(`/api/designs/${params.id}`)
.then((r) => r.json())
.then(setDesign)
.finally(() => setLoading(false));
}, [params.id]);
if (loading) return <div className="p-6">Loading...</div>;
if (!design) return <div className="p-6 text-sm text-muted">Design not found.</div>;
const handleApprove = async () => {
setApproving(true);
try {
const res = await fetch(`/api/designs/${params.id}/approve`, { method: 'POST' });
if (res.ok) {
const updated = await res.json();
setDesign(updated);
}
} finally {
setApproving(false);
}
};
const handleEditorSave = (designJson: string, previewUrl: string) => {
setDesign((prev) =>
prev
? {
...prev,
designJson,
designPreviewUrl: previewUrl,
}
: null
);
setEditingDesign(false);
};
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<Link href="/admin/designs" className="text-sm text-clay hover:text-clay-dark mb-4 inline-block">
Back to designs
</Link>
<h1 className="font-display text-3xl mb-8">{design.product.name}</h1>
<div className="grid gap-8">
{/* Design Preview & Editor */}
<div className="border border-line bg-surface p-6">
<div className="flex items-center justify-between mb-4">
<p className="tag-label">Design Editor</p>
{!editingDesign && design.status === 'PENDING' && (
<button
onClick={() => setEditingDesign(true)}
className="text-xs px-3 py-1 border border-clay text-clay hover:bg-clay/10"
>
Edit design
</button>
)}
</div>
{editingDesign ? (
<AdminDesignEditor
designId={params.id}
designJson={design.designJson}
designPreviewUrl={design.designPreviewUrl}
placementPreviewUrl={design.placementPreviewUrl}
onSave={handleEditorSave}
/>
) : (
<div className="space-y-4">
<div>
<p className="text-sm text-muted mb-2">Design preview (transparent)</p>
<img
src={design.designPreviewUrl}
alt="design preview"
className="w-full max-w-md border border-line"
/>
</div>
{design.placementPreviewUrl && (
<div>
<p className="text-sm text-muted mb-2">Design on product (garment photo)</p>
<img
src={design.placementPreviewUrl}
alt="placement preview"
className="w-full max-w-md border border-line"
/>
</div>
)}
{design.designPreviewUrlBack && (
<div>
<p className="text-sm text-muted mb-2">Back design preview</p>
<img
src={design.designPreviewUrlBack}
alt="design preview back"
className="w-full max-w-md border border-line"
/>
</div>
)}
{design.placementPreviewUrlBack && (
<div>
<p className="text-sm text-muted mb-2">Back design on product</p>
<img
src={design.placementPreviewUrlBack}
alt="placement preview back"
className="w-full max-w-md border border-line"
/>
</div>
)}
</div>
)}
</div>
{/* Customer Details & Chat */}
<div className="grid gap-8 md:grid-cols-2">
<div className="border border-line bg-surface p-4">
<p className="tag-label mb-3">Customer Details</p>
<div className="space-y-2 text-sm">
<p>
<strong>Name:</strong> {design.customerName || '(not provided)'}
</p>
<p>
<strong>Email:</strong> {design.customerEmail}
</p>
<p>
<strong>Product:</strong> {design.product.name}
</p>
<p>
<strong>Color:</strong> {design.color}
</p>
{design.size && (
<p>
<strong>Size:</strong> {design.size}
</p>
)}
{design.designWidthCm && design.designHeightCm && (
<p>
<strong>Design Dimensions:</strong> {design.designWidthCm} cm × {design.designHeightCm} cm
</p>
)}
<p>
<strong>Status:</strong>{' '}
<span
className={`text-xs px-2 py-1 rounded ${
design.status === 'PENDING'
? 'bg-orange-100 text-orange-700'
: design.status === 'APPROVED'
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{design.status}
</span>
</p>
</div>
{/* Action Buttons */}
{design.status === 'PENDING' && (
<div className="mt-6 flex gap-3">
<button
onClick={handleApprove}
disabled={approving}
className="flex-1 bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay-dark disabled:opacity-60"
>
{approving ? 'Approving…' : 'Approve Design'}
</button>
</div>
)}
</div>
{/* Chat */}
<div>
<DesignApprovalChat
designId={params.id}
role="ADMIN"
otherPartyLabel={design.customerName || design.customerEmail}
/>
</div>
</div>
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
export default async function DesignsPage() {
const designs = await prisma.designApproval.findMany({
include: {
product: true,
messages: { orderBy: { createdAt: 'desc' }, take: 1 },
},
orderBy: { createdAt: 'desc' },
});
const pending = designs.filter((d) => d.status === 'PENDING');
const approved = designs.filter((d) => d.status === 'APPROVED');
const rejected = designs.filter((d) => d.status === 'REJECTED');
const DesignList = ({ items, status }: { items: typeof designs; status: string }) => (
<div className="mt-8">
<h2 className="font-display text-lg">{status}</h2>
{items.length === 0 ? (
<p className="mt-4 text-sm text-muted">No designs.</p>
) : (
<div className="mt-4 divide-y divide-line border-t border-line">
{items.map((d) => {
const last = d.messages[0];
return (
<Link
key={d.id}
href={`/admin/designs/${d.id}`}
className="flex items-center gap-4 py-4 hover:bg-surface"
>
<img
src={d.designPreviewUrl}
alt="design preview"
className="h-14 w-14 border border-line object-cover"
/>
<div className="flex-1">
<p className="font-medium">{d.customerName || d.customerEmail}</p>
<p className="truncate text-sm text-muted">
{last ? (
<>
{last.sender === 'ADMIN' ? 'You: ' : ''}
{last.body}
</>
) : (
'No messages yet'
)}
</p>
</div>
<span className="tag-label">{d.product.name}</span>
</Link>
);
})}
</div>
)}
</div>
);
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Design Approvals</h1>
<DesignList items={pending} status={`Pending (${pending.length})`} />
<DesignList items={approved} status={`Approved (${approved.length})`} />
<DesignList items={rejected} status={`Rejected (${rejected.length})`} />
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { sendDesignApprovedEmail } from '@/lib/mail';
export async function POST(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const design = await prisma.designApproval.findUnique({
where: { id: params.id },
include: { product: true },
});
if (!design) {
return NextResponse.json({ error: 'Design not found' }, { status: 404 });
}
const updated = await prisma.designApproval.update({
where: { id: params.id },
data: { status: 'APPROVED' },
include: { product: true },
});
const checkoutLink = `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/made-to-order/${design.product.slug}?design=${design.id}`;
await sendDesignApprovedEmail({
to: design.customerEmail,
customerName: design.customerName,
productName: design.product.name,
checkoutLink,
});
return NextResponse.json(updated);
} catch (err) {
console.error('Error approving design:', err);
return NextResponse.json(
{ error: 'Failed to approve design' },
{ status: 500 }
);
}
}
@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import type { DesignApprovalMessage } from '@prisma/client';
export async function GET(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const messages = await prisma.designApprovalMessage.findMany({
where: { designId: params.id },
orderBy: { createdAt: 'asc' },
});
return NextResponse.json(messages);
} catch (err) {
console.error('Error fetching messages:', err);
return NextResponse.json(
{ error: 'Failed to fetch messages' },
{ status: 500 }
);
}
}
export async function POST(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const { sender, body } = await req.json();
if (!sender || !body) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
const message = await prisma.designApprovalMessage.create({
data: {
designId: params.id,
sender,
body,
},
});
return NextResponse.json(message);
} catch (err) {
console.error('Error creating message:', err);
return NextResponse.json(
{ error: 'Failed to create message' },
{ status: 500 }
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function POST(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const {
designJson,
designPreviewUrl,
designPreviewUrlBack,
placementPreviewUrl,
placementPreviewUrlBack,
designWidthCm,
designHeightCm,
} = await req.json();
const design = await prisma.designApproval.findUnique({
where: { id: params.id },
});
if (!design) {
return NextResponse.json({ error: 'Design not found' }, { status: 404 });
}
const updated = await prisma.designApproval.update({
where: { id: params.id },
data: {
...(designJson && { designJson }),
...(designPreviewUrl && { designPreviewUrl }),
...(designPreviewUrlBack && { designPreviewUrlBack }),
...(placementPreviewUrl && { placementPreviewUrl }),
...(placementPreviewUrlBack && { placementPreviewUrlBack }),
...(designWidthCm !== undefined && { designWidthCm }),
...(designHeightCm !== undefined && { designHeightCm }),
},
});
return NextResponse.json(updated);
} catch (err) {
console.error('Error modifying design:', err);
return NextResponse.json(
{ error: 'Failed to modify design' },
{ status: 500 }
);
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const design = await prisma.designApproval.findUnique({
where: { id: params.id },
include: { product: true, messages: { orderBy: { createdAt: 'asc' } } },
});
if (!design) {
return NextResponse.json({ error: 'Design not found' }, { status: 404 });
}
return NextResponse.json(design);
} catch (err) {
console.error('Error fetching design:', err);
return NextResponse.json(
{ error: 'Failed to fetch design' },
{ status: 500 }
);
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
const designs = await prisma.designApproval.findMany({
where: { status: 'PENDING' },
include: { product: true, messages: { orderBy: { createdAt: 'desc' }, take: 1 } },
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(designs);
} catch (err) {
console.error('Error fetching pending designs:', err);
return NextResponse.json(
{ error: 'Failed to fetch pending designs' },
{ status: 500 }
);
}
}
+79
View File
@@ -0,0 +1,79 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { sendDesignSubmissionNotification } from '@/lib/mail';
import { getCurrentCustomer } from '@/lib/auth';
export async function POST(req: Request) {
try {
const customer = await getCurrentCustomer();
if (!customer) {
return NextResponse.json(
{ error: 'Must be logged in to submit design' },
{ status: 401 }
);
}
const {
productId,
designJson,
designPreviewUrl,
designPreviewUrlBack,
placementPreviewUrl,
placementPreviewUrlBack,
designWidthCm,
designHeightCm,
designImageDimensions,
color,
size,
} = await req.json();
if (!productId || !designJson) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
const product = await prisma.product.findUnique({
where: { id: productId },
});
if (!product) {
return NextResponse.json({ error: 'Product not found' }, { status: 404 });
}
const design = await prisma.designApproval.create({
data: {
customerId: customer.id,
customerEmail: customer.email,
customerName: customer.name,
productId,
designJson,
designPreviewUrl,
designPreviewUrlBack,
placementPreviewUrl,
placementPreviewUrlBack,
designWidthCm: designWidthCm || null,
designHeightCm: designHeightCm || null,
designImageDimensions: designImageDimensions && designImageDimensions.length > 0 ? JSON.stringify(designImageDimensions) : null,
color,
size,
},
});
const adminLink = `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/admin/designs/${design.id}`;
await sendDesignSubmissionNotification({
customerName,
productName: product.name,
adminLink,
});
return NextResponse.json(design);
} catch (err) {
console.error('Error submitting design:', err);
return NextResponse.json(
{ error: 'Failed to submit design' },
{ status: 500 }
);
}
}
+169
View File
@@ -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>
);
}
+126
View File
@@ -0,0 +1,126 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import type { DesignApprovalMessage } from '@prisma/client';
const POLL_MS = 4000;
function formatTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
export default function DesignApprovalChat({
designId,
role,
otherPartyLabel,
}: {
designId: string;
role: 'ADMIN' | 'CUSTOMER';
otherPartyLabel: string;
}) {
const [messages, setMessages] = useState<DesignApprovalMessage[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const res = await fetch(`/api/designs/${designId}/messages`);
if (!res.ok || cancelled) return;
const data: DesignApprovalMessage[] = await res.json();
if (!cancelled) setMessages(data);
} catch {
// silently retry on next poll
}
};
load();
const interval = setInterval(load, POLL_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [designId]);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [messages.length]);
const send = async () => {
const trimmed = input.trim();
if (!trimmed || sending) return;
setSending(true);
setInput('');
try {
const res = await fetch(`/api/designs/${designId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sender: role, body: trimmed }),
});
if (res.ok) {
const created: DesignApprovalMessage = await res.json();
setMessages((prev) => [...prev, created]);
}
} finally {
setSending(false);
}
};
return (
<div className="border border-line bg-surface">
<div className="border-b border-line px-4 py-3">
<p className="tag-label">Chat with {otherPartyLabel}</p>
</div>
<div ref={scrollRef} className="max-h-72 space-y-3 overflow-y-auto p-4">
{messages.length === 0 ? (
<p className="text-sm text-muted">No messages yet say hello.</p>
) : (
messages.map((m) => {
const mine = m.sender === role;
return (
<div key={m.id} className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[75%] ${mine ? 'bg-clay text-paper' : 'bg-paper border border-line text-ink'} px-3 py-2`}>
<p className="text-sm">{m.body}</p>
<p className={`mt-1 text-[10px] ${mine ? 'text-paper/60' : 'text-muted'}`}>
{formatTime(m.createdAt.toString())}
</p>
</div>
</div>
);
})
)}
</div>
<div className="flex gap-2 border-t border-line p-3">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
send();
}
}}
placeholder="Type a message…"
className="flex-1 border border-line px-3 py-2 text-sm"
/>
<button
onClick={send}
disabled={sending || !input.trim()}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper disabled:opacity-50"
>
Send
</button>
</div>
</div>
);
}