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
@@ -17,7 +17,36 @@
|
|||||||
"Bash(git push *)",
|
"Bash(git push *)",
|
||||||
"Bash(rm -rf .next)",
|
"Bash(rm -rf .next)",
|
||||||
"Bash(npm run *)",
|
"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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user