Add design approval workflow and per-image dimension tracking
- Add DesignApproval and DesignApprovalMessage models for design review workflow - Add design approval admin interface (list, detail, chat, approve/reject) - Add per-image dimension tracking to OrderItem - Add design submission API endpoint - Add AdminDesignEditor for admin-side design modifications - Add DesignApprovalChat component for customer-admin communication - Update design specification generation with image dimension details - Add design persistence across login with next parameter redirect - Add font properties UI with font size and color display Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
10764f3a5b
commit
5c4774be84
@@ -0,0 +1,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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user