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
+30 -1
View File
@@ -17,7 +17,36 @@
"Bash(git push *)",
"Bash(rm -rf .next)",
"Bash(npm run *)",
"Bash(git commit -m 'Wire Stage 3 reports to real data *)"
"Bash(git commit -m 'Wire Stage 3 reports to real data *)",
"Bash(git config *)",
"Bash(Get-ChildItem \"D:\\\\Craft2Prints\" -Directory)",
"Bash(Select-Object Name)",
"Bash(Get-ChildItem \"D:\\\\\" -Directory -Filter \"*craft*\" -Force | Select-Object Name)",
"Bash(npm install *)",
"PowerShell(Stop-Process -Id 22068 -Force -ErrorAction SilentlyContinue)",
"Bash(netstat -ano)",
"Bash(findstr :3000)",
"Bash(Start-Sleep -Seconds 3)",
"PowerShell(\\(Get-Content \"D:\\\\Craft2Prints\\\\src\\\\app\\\\api\\\\checkout\\\\route.ts\" | Measure-Object -Line\\).Lines)",
"Bash(git commit *)",
"PowerShell(Get-Item \"D:\\\\Craft2Prints\\\\dev.db\" -ErrorAction SilentlyContinue | Select-Object FullName, @{Name=\"SizeKB\";Expression={$_.Length/1KB}}, LastWriteTime)",
"Bash(sqlite3 -json \"SELECT designJson FROM order_items LIMIT 1;\")",
"Bash(node -e ' *)",
"Bash(git diff *)",
"Bash(xargs grep -l \"ProductDTO\")",
"Bash(npx tsx -e ' *)",
"Bash(psql -U postgres -d craft2prints -c \"SELECT id, name, \\\\\"referenceWidthCm\\\\\", \\\\\"referenceHeightCm\\\\\" FROM \\\\\"Product\\\\\" LIMIT 1;\")",
"Bash(npx ts-node *)",
"Bash(node \"C:\\\\Users\\\\Lyons\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\D--Craft2Prints\\\\7f0f7e09-1d03-4e4a-b5dc-529896c9a855\\\\scratchpad\\\\query_product.js\")",
"Bash(sqlite3 \"D:\\\\Craft2Prints\\\\prisma\\\\dev.db\" \"SELECT id, name, referenceWidthCm, referenceHeightCm FROM Product WHERE slug = 'classic-t-shirt';\")",
"Bash(xargs grep -l \"prisma.order.create\")",
"Bash(node -e \"const fs = require\\('fs'\\); const content = fs.readFileSync\\('src/components/DesignCanvas.tsx', 'utf8'\\); console.log\\('File read successfully, length:', content.length\\)\")",
"Bash(python3)",
"Bash(pkill -f \"node.*next\")",
"Bash(rm -rf .next node_modules/.cache)",
"Bash(git commit -m 'Fix JSX syntax errors in DesignCanvas - close bracket issues *)",
"Bash(rm -rf craft2prints)",
"Bash(git commit -m 'Add design approval workflow and per-image dimension tracking *)"
]
}
}
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "referenceHeightCm" INTEGER,
ADD COLUMN "referenceWidthCm" INTEGER;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "OrderItem" ADD COLUMN "designHeightCm" DOUBLE PRECISION,
ADD COLUMN "designWidthCm" DOUBLE PRECISION;
@@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "DesignApproval" (
"id" TEXT NOT NULL,
"customerId" TEXT,
"customerEmail" TEXT NOT NULL,
"customerName" TEXT,
"productId" TEXT NOT NULL,
"designJson" TEXT NOT NULL,
"designPreviewUrl" TEXT NOT NULL,
"designPreviewUrlBack" TEXT,
"placementPreviewUrl" TEXT,
"placementPreviewUrlBack" TEXT,
"designWidthCm" DOUBLE PRECISION,
"designHeightCm" DOUBLE PRECISION,
"color" TEXT NOT NULL,
"size" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DesignApproval_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DesignApprovalMessage" (
"id" TEXT NOT NULL,
"designId" TEXT NOT NULL,
"sender" TEXT NOT NULL,
"body" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DesignApprovalMessage_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "DesignApproval" ADD CONSTRAINT "DesignApproval_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DesignApproval" ADD CONSTRAINT "DesignApproval_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DesignApprovalMessage" ADD CONSTRAINT "DesignApprovalMessage_designId_fkey" FOREIGN KEY ("designId") REFERENCES "DesignApproval"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "DesignApproval" ADD COLUMN "designImageDimensions" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "OrderItem" ADD COLUMN "designImageDimensions" TEXT;
+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>
);
}