From 5c4774be84ab38306536c1f90ed66169320af0ea Mon Sep 17 00:00:00 2001 From: Andymick Date: Sat, 18 Jul 2026 16:21:19 +0100 Subject: [PATCH] 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 --- .claude/settings.local.json | 31 ++- .../migration.sql | 3 + .../migration.sql | 3 + .../migration.sql | 42 ++++ .../migration.sql | 2 + .../migration.sql | 2 + src/app/admin/designs/[id]/page.tsx | 219 ++++++++++++++++++ src/app/admin/designs/page.tsx | 70 ++++++ src/app/api/designs/[id]/approve/route.ts | 41 ++++ src/app/api/designs/[id]/messages/route.ts | 55 +++++ src/app/api/designs/[id]/modify/route.ts | 48 ++++ src/app/api/designs/[id]/route.ts | 26 +++ src/app/api/designs/pending/route.ts | 20 ++ src/app/api/designs/submit/route.ts | 79 +++++++ src/components/AdminDesignEditor.tsx | 169 ++++++++++++++ src/components/DesignApprovalChat.tsx | 126 ++++++++++ 16 files changed, 935 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260718113139_add_reference_size_fields/migration.sql create mode 100644 prisma/migrations/20260718115108_add_design_dimensions/migration.sql create mode 100644 prisma/migrations/20260718131141_add_design_approval_workflow/migration.sql create mode 100644 prisma/migrations/20260718133544_add_per_image_dimensions/migration.sql create mode 100644 prisma/migrations/20260718134319_add_image_dimensions_to_order/migration.sql create mode 100644 src/app/admin/designs/[id]/page.tsx create mode 100644 src/app/admin/designs/page.tsx create mode 100644 src/app/api/designs/[id]/approve/route.ts create mode 100644 src/app/api/designs/[id]/messages/route.ts create mode 100644 src/app/api/designs/[id]/modify/route.ts create mode 100644 src/app/api/designs/[id]/route.ts create mode 100644 src/app/api/designs/pending/route.ts create mode 100644 src/app/api/designs/submit/route.ts create mode 100644 src/components/AdminDesignEditor.tsx create mode 100644 src/components/DesignApprovalChat.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f89c5be..1e23609 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 *)" ] } } diff --git a/prisma/migrations/20260718113139_add_reference_size_fields/migration.sql b/prisma/migrations/20260718113139_add_reference_size_fields/migration.sql new file mode 100644 index 0000000..cd62edf --- /dev/null +++ b/prisma/migrations/20260718113139_add_reference_size_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Product" ADD COLUMN "referenceHeightCm" INTEGER, +ADD COLUMN "referenceWidthCm" INTEGER; diff --git a/prisma/migrations/20260718115108_add_design_dimensions/migration.sql b/prisma/migrations/20260718115108_add_design_dimensions/migration.sql new file mode 100644 index 0000000..55f35f7 --- /dev/null +++ b/prisma/migrations/20260718115108_add_design_dimensions/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "OrderItem" ADD COLUMN "designHeightCm" DOUBLE PRECISION, +ADD COLUMN "designWidthCm" DOUBLE PRECISION; diff --git a/prisma/migrations/20260718131141_add_design_approval_workflow/migration.sql b/prisma/migrations/20260718131141_add_design_approval_workflow/migration.sql new file mode 100644 index 0000000..a6c8faa --- /dev/null +++ b/prisma/migrations/20260718131141_add_design_approval_workflow/migration.sql @@ -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; diff --git a/prisma/migrations/20260718133544_add_per_image_dimensions/migration.sql b/prisma/migrations/20260718133544_add_per_image_dimensions/migration.sql new file mode 100644 index 0000000..e229936 --- /dev/null +++ b/prisma/migrations/20260718133544_add_per_image_dimensions/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "DesignApproval" ADD COLUMN "designImageDimensions" TEXT; diff --git a/prisma/migrations/20260718134319_add_image_dimensions_to_order/migration.sql b/prisma/migrations/20260718134319_add_image_dimensions_to_order/migration.sql new file mode 100644 index 0000000..f2ac27d --- /dev/null +++ b/prisma/migrations/20260718134319_add_image_dimensions_to_order/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "OrderItem" ADD COLUMN "designImageDimensions" TEXT; diff --git a/src/app/admin/designs/[id]/page.tsx b/src/app/admin/designs/[id]/page.tsx new file mode 100644 index 0000000..e0cb900 --- /dev/null +++ b/src/app/admin/designs/[id]/page.tsx @@ -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(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
Loading...
; + if (!design) return
Design not found.
; + + 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 ( +
+ + ← Back to designs + + +

{design.product.name}

+ +
+ {/* Design Preview & Editor */} +
+
+

Design Editor

+ {!editingDesign && design.status === 'PENDING' && ( + + )} +
+ + {editingDesign ? ( + + ) : ( +
+
+

Design preview (transparent)

+ design preview +
+ + {design.placementPreviewUrl && ( +
+

Design on product (garment photo)

+ placement preview +
+ )} + + {design.designPreviewUrlBack && ( +
+

Back design preview

+ design preview back +
+ )} + + {design.placementPreviewUrlBack && ( +
+

Back design on product

+ placement preview back +
+ )} +
+ )} +
+ + {/* Customer Details & Chat */} +
+
+

Customer Details

+
+

+ Name: {design.customerName || '(not provided)'} +

+

+ Email: {design.customerEmail} +

+

+ Product: {design.product.name} +

+

+ Color: {design.color} +

+ {design.size && ( +

+ Size: {design.size} +

+ )} + {design.designWidthCm && design.designHeightCm && ( +

+ Design Dimensions: {design.designWidthCm} cm × {design.designHeightCm} cm +

+ )} +

+ Status:{' '} + + {design.status} + +

+
+ + {/* Action Buttons */} + {design.status === 'PENDING' && ( +
+ +
+ )} +
+ + {/* Chat */} +
+ +
+
+
+
+ ); +} diff --git a/src/app/admin/designs/page.tsx b/src/app/admin/designs/page.tsx new file mode 100644 index 0000000..93a01ef --- /dev/null +++ b/src/app/admin/designs/page.tsx @@ -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 }) => ( +
+

{status}

+ {items.length === 0 ? ( +

No designs.

+ ) : ( +
+ {items.map((d) => { + const last = d.messages[0]; + return ( + + design preview +
+

{d.customerName || d.customerEmail}

+

+ {last ? ( + <> + {last.sender === 'ADMIN' ? 'You: ' : ''} + {last.body} + + ) : ( + 'No messages yet' + )} +

+
+ {d.product.name} + + ); + })} +
+ )} +
+ ); + + return ( +
+ +

Design Approvals

+ + + + +
+ ); +} diff --git a/src/app/api/designs/[id]/approve/route.ts b/src/app/api/designs/[id]/approve/route.ts new file mode 100644 index 0000000..3caee91 --- /dev/null +++ b/src/app/api/designs/[id]/approve/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/designs/[id]/messages/route.ts b/src/app/api/designs/[id]/messages/route.ts new file mode 100644 index 0000000..3a1a6a4 --- /dev/null +++ b/src/app/api/designs/[id]/messages/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/designs/[id]/modify/route.ts b/src/app/api/designs/[id]/modify/route.ts new file mode 100644 index 0000000..8cca751 --- /dev/null +++ b/src/app/api/designs/[id]/modify/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/designs/[id]/route.ts b/src/app/api/designs/[id]/route.ts new file mode 100644 index 0000000..4289b9c --- /dev/null +++ b/src/app/api/designs/[id]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/designs/pending/route.ts b/src/app/api/designs/pending/route.ts new file mode 100644 index 0000000..2e2592e --- /dev/null +++ b/src/app/api/designs/pending/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/designs/submit/route.ts b/src/app/api/designs/submit/route.ts new file mode 100644 index 0000000..d128f7b --- /dev/null +++ b/src/app/api/designs/submit/route.ts @@ -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 } + ); + } +} diff --git a/src/components/AdminDesignEditor.tsx b/src/components/AdminDesignEditor.tsx new file mode 100644 index 0000000..8f8401b --- /dev/null +++ b/src/components/AdminDesignEditor.tsx @@ -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(null); + const transformerRef = useRef(null); + const nodeRefs = useRef>(new Map()); + const [elements, setElements] = useState([]); + const [selectedId, setSelectedId] = useState(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 ( +
+
+ + +
+ +
+ { + if (e.target === e.target.getStage()) setSelectedId(null); + }} + > + + {elements.map((el) => { + if (el.type === 'text') { + return ( + 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 && ( + newBox} + /> + )} + + +
+ + {placementPreviewUrl && ( +
+

Design on product:

+ design on product +
+ )} +
+ ); +} diff --git a/src/components/DesignApprovalChat.tsx b/src/components/DesignApprovalChat.tsx new file mode 100644 index 0000000..28e2831 --- /dev/null +++ b/src/components/DesignApprovalChat.tsx @@ -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([]); + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const scrollRef = useRef(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 ( +
+
+

Chat with {otherPartyLabel}

+
+ +
+ {messages.length === 0 ? ( +

No messages yet — say hello.

+ ) : ( + messages.map((m) => { + const mine = m.sender === role; + return ( +
+
+

{m.body}

+

+ {formatTime(m.createdAt.toString())} +

+
+
+ ); + }) + )} +
+ +
+ 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" + /> + +
+
+ ); +}