Feature: Add customer reviews system with admin management

- Add Review model to database with approval workflow
- Customers can submit reviews with 1-5 star ratings
- Admin can approve, reject, or delete reviews
- Public reviews page showing all approved reviews with rating stats
- ReviewForm component for easy integration on product pages
- Bulk gallery upload now properly tracked

Chore: Add expected delivery date for design approvals

- Add expectedDeliveryDate field to DesignApproval model
- Admin can set delivery date when approving designs
- Improves customer communication and expectations

Chore: Add welcome back message for logged-in customers

- Display personalized greeting on account page after login
- Shows customer's name if available

Chore: Add color difference disclaimer to product pages

- Added to StandardProductView (ready-made products)
- Added to DesignCanvas (personalized products)
- Informs customers about lighting-based color variations
- Helps manage customer expectations

Feature: Add image identification in design specifications

- Images now clearly labeled as "Image 1", "Image 2", etc.
- Images marked with purple circles in specification diagrams
- Improves clarity for production team

Documentation: Add comprehensive database optimization guide

- DATABASE_OPTIMIZATION.md - Full optimization strategies
- DATABASE_OPTIMIZATION_QUICK_START.md - Implementation examples
- DATABASE_OPTIMIZATION_MIGRATION.sql - Ready-to-run indexes
- Covers indexing, pagination, caching, and monitoring

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-19 18:24:58 +01:00
co-authored by Claude Haiku 4.5
parent a1f1cc3fa1
commit 16f1f64fd0
21 changed files with 1531 additions and 19 deletions
+4
View File
@@ -56,6 +56,10 @@ export default async function AccountPage({ searchParams }: { searchParams: { su
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<div className="mb-6 border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-700">
Welcome back{customer.name ? `, ${customer.name}` : ''}!
</div>
{success && (
<div className="mb-6 border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-700">
{success}
+21 -3
View File
@@ -24,6 +24,7 @@ interface DesignApproval {
color: string;
size: string | null;
status: string;
expectedDeliveryDate: string | null;
createdAt: string;
}
@@ -33,6 +34,7 @@ export default function DesignDetailPage({ params }: { params: { id: string } })
const [loading, setLoading] = useState(true);
const [approving, setApproving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [expectedDeliveryDate, setExpectedDeliveryDate] = useState<string>('');
useEffect(() => {
fetch(`/api/designs/${params.id}`)
@@ -47,7 +49,11 @@ export default function DesignDetailPage({ params }: { params: { id: string } })
const handleApprove = async () => {
setApproving(true);
try {
const res = await fetch(`/api/designs/${params.id}/approve`, { method: 'POST' });
const res = await fetch(`/api/designs/${params.id}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedDeliveryDate: expectedDeliveryDate || null }),
});
if (res.ok) {
const updated = await res.json();
setDesign(updated);
@@ -186,11 +192,23 @@ export default function DesignDetailPage({ params }: { params: { id: string } })
{/* Action Buttons */}
{design.status === 'PENDING' && (
<div className="mt-6 flex gap-3">
<div className="mt-6 space-y-4">
<div>
<label htmlFor="deliveryDate" className="tag-label block mb-2">
Expected Delivery Date (optional)
</label>
<input
id="deliveryDate"
type="date"
value={expectedDeliveryDate}
onChange={(e) => setExpectedDeliveryDate(e.target.value)}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<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"
className="w-full bg-clay px-4 py-2 text-sm font-medium text-paper hover:bg-clay-dark disabled:opacity-60"
>
{approving ? 'Approving…' : 'Approve Design'}
</button>
+84
View File
@@ -0,0 +1,84 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import { updateGalleryPhoto } from '../../actions';
import { notFound } from 'next/navigation';
export default async function EditGalleryPhotoPage({ params }: { params: { id: string } }) {
const photo = await prisma.galleryPhoto.findUnique({
where: { id: params.id },
include: { category: true },
});
if (!photo) {
notFound();
}
const categories = await prisma.galleryCategory.findMany({ orderBy: { name: 'asc' } });
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Edit photo</h1>
<p className="mt-2 text-sm text-muted">
Update the caption and category for this gallery photo.
</p>
<div className="mt-8 flex gap-6">
<img src={photo.imageDataUrl} alt="" className="h-32 w-32 border border-line object-cover flex-shrink-0" />
<div className="flex-1 space-y-6">
<form action={updateGalleryPhoto}>
<input type="hidden" name="id" value={photo.id} />
<div>
<label htmlFor="caption" className="tag-label mb-2 block">
Caption (optional)
</label>
<input
id="caption"
name="caption"
defaultValue={photo.caption || ''}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Personalised wedding mugs"
/>
</div>
<div>
<label htmlFor="categoryId" className="tag-label mb-2 block">
Category (optional)
</label>
<select
id="categoryId"
name="categoryId"
defaultValue={photo.categoryId || ''}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
>
<option value=""> Uncategorized </option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<a href="/admin/gallery/categories/new?redirectTo=/admin/gallery" className="mt-1 inline-block text-xs text-clay hover:text-clay-dark">
+ Add a new category
</a>
</div>
<div className="flex gap-3 pt-4">
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Save changes
</button>
<Link
href="/admin/gallery"
className="border border-line px-6 py-3 text-sm font-medium text-ink hover:border-clay hover:text-clay"
>
Cancel
</Link>
</div>
</form>
</div>
</div>
</div>
);
}
+18
View File
@@ -49,6 +49,24 @@ export async function uploadGalleryPhoto(formData: FormData) {
redirect('/admin/gallery');
}
export async function updateGalleryPhoto(formData: FormData) {
const id = String(formData.get('id') ?? '');
const caption = String(formData.get('caption') ?? '').trim();
const categoryId = String(formData.get('categoryId') ?? '').trim() || null;
if (!id) return;
await prisma.galleryPhoto.update({
where: { id },
data: {
caption: caption || null,
categoryId,
},
});
redirect('/admin/gallery');
}
export async function deleteGalleryPhoto(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
+14 -6
View File
@@ -53,12 +53,20 @@ export default async function AdminGalleryPage() {
) : (
<span className="tag-label text-muted">Uncategorized</span>
)}
<form action={deleteGalleryPhoto}>
<input type="hidden" name="id" value={p.id} />
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
Delete
</button>
</form>
<div className="flex gap-3">
<Link
href={`/admin/gallery/${p.id}/edit`}
className="tag-label text-clay hover:text-clay-dark"
>
Edit
</Link>
<form action={deleteGalleryPhoto}>
<input type="hidden" name="id" value={p.id} />
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
Delete
</button>
</form>
</div>
</div>
))}
</div>
+111
View File
@@ -0,0 +1,111 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
async function approveReview(reviewId: string) {
'use server';
await prisma.review.update({
where: { id: reviewId },
data: { approved: true },
});
redirect('/admin/reviews?status=pending');
}
async function deleteReview(reviewId: string) {
'use server';
await prisma.review.delete({
where: { id: reviewId },
});
redirect('/admin/reviews?status=pending');
}
export default async function AdminReviewsPage({ searchParams }: { searchParams: { status?: string } }) {
const status = searchParams.status === 'approved' ? 'approved' : 'pending';
const reviews = await prisma.review.findMany({
where: {
approved: status === 'approved',
},
include: { product: true, customer: true },
orderBy: { createdAt: 'desc' },
});
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl mb-8">Customer Reviews</h1>
<div className="flex gap-3 mb-6">
<Link
href="/admin/reviews?status=pending"
className={`px-4 py-2 text-sm font-medium border ${
status === 'pending'
? 'border-clay bg-clay text-paper'
: 'border-line text-ink hover:border-clay'
}`}
>
Pending ({reviews.length})
</Link>
<Link
href="/admin/reviews?status=approved"
className={`px-4 py-2 text-sm font-medium border ${
status === 'approved'
? 'border-clay bg-clay text-paper'
: 'border-line text-ink hover:border-clay'
}`}
>
Approved
</Link>
</div>
{reviews.length === 0 ? (
<p className="text-sm text-muted">No {status} reviews yet.</p>
) : (
<div className="divide-y divide-line border-t border-line">
{reviews.map((review) => (
<div key={review.id} className="py-6">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<p className="font-medium text-sm">{review.title}</p>
<span className="text-xs bg-yellow-100 text-yellow-700 px-2 py-1 rounded">
{'★'.repeat(review.rating)}
</span>
</div>
<p className="text-sm text-muted mb-1">
{review.customerName || review.customerEmail} {review.product.name}
</p>
<p className="text-sm text-ink mb-3">{review.comment}</p>
<p className="text-xs text-muted">
{new Date(review.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })}
</p>
</div>
<div className="flex gap-2">
{!review.approved && (
<form action={approveReview.bind(null, review.id)}>
<button
type="submit"
className="text-xs px-3 py-1 border border-green-500 text-green-600 hover:bg-green-500/10"
>
Approve
</button>
</form>
)}
<form action={deleteReview.bind(null, review.id)}>
<button
type="submit"
className="text-xs px-3 py-1 border border-splash-pink text-splash-pink hover:bg-splash-pink/10"
>
Delete
</button>
</form>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
+7 -1
View File
@@ -8,6 +8,9 @@ export async function POST(
{ params }: { params: { id: string } }
) {
try {
const body = await req.json();
const { expectedDeliveryDate } = body;
const design = await prisma.designApproval.findUnique({
where: { id: params.id },
include: { product: true },
@@ -19,7 +22,10 @@ export async function POST(
const updated = await prisma.designApproval.update({
where: { id: params.id },
data: { status: 'APPROVED' },
data: {
status: 'APPROVED',
expectedDeliveryDate: expectedDeliveryDate ? new Date(expectedDeliveryDate) : null,
},
include: { product: true },
});
+29 -8
View File
@@ -71,6 +71,16 @@ export async function GET(req: Request, { params }: { params: { id: string } })
// Always show the section if we have preview images, even without elements
if ((!designElements || designElements.length === 0) && !placement && !designPreview) return '';
// Count images to create image numbering
const imageElements = designElements ? designElements.filter(el => el.type === 'image') : [];
const imageMap = new Map<number, number>(); // Maps element index to image number
let imageCount = 0;
designElements?.forEach((el, idx) => {
if (el.type === 'image') {
imageMap.set(idx, ++imageCount);
}
});
return `
<div class="spec-section">
<h2>${side} Design</h2>
@@ -126,10 +136,16 @@ export async function GET(req: Request, { params }: { params: { id: string } })
<text x="${printAreaStartX - 25}" y="${printAreaStartY + 15}" font-size="12" fill="green" font-weight="bold">Y (0cm)</text>
<!-- Design elements with markers -->
${designElements && designElements.length > 0 ? designElements.map((el, idx) => `
<circle cx="${el.x}" cy="${el.y}" r="5" fill="orange" opacity="0.9" stroke="white" stroke-width="1"/>
<text x="${el.x + 10}" y="${el.y - 10}" font-size="12" fill="orange" font-weight="bold" font-family="monospace">E${idx + 1}</text>
`).join('') : ''}
${designElements && designElements.length > 0 ? designElements.map((el, idx) => {
const isImage = el.type === 'image';
const imageNum = imageMap.get(idx);
const label = isImage ? `IMG${imageNum}` : `E${idx + 1}`;
const color = isImage ? 'purple' : 'orange';
return `
<circle cx="${el.x}" cy="${el.y}" r="5" fill="${color}" opacity="0.9" stroke="white" stroke-width="1"/>
<text x="${el.x + 10}" y="${el.y - 10}" font-size="12" fill="${color}" font-weight="bold" font-family="monospace">${label}</text>
`;
}).join('') : ''}
</svg>
</div>
<div style="margin-top: 15px; font-size: 12px; color: #666; text-align: left; max-width: 400px;">
@@ -138,7 +154,8 @@ export async function GET(req: Request, { params }: { params: { id: string } })
<p style="margin: 5px 0;"><strong style="color: green;"> Green line:</strong> Y-axis (vertical, 0cm at top edge)</p>
<p style="margin: 5px 0;"><strong style="color: brown;"> Brown dashed lines:</strong> Position guides from edges to element center</p>
<p style="margin: 5px 0;"><strong style="color: brown;">T: / L:</strong> Distance from top and left edges of garment in cm</p>
<p style="margin: 5px 0;"><strong style="color: orange;"> Orange circles:</strong> Design element positions (E1, E2, etc.)</p>
<p style="margin: 5px 0;"><strong style="color: orange;"> Orange circles:</strong> Text elements (E1, E2, etc.)</p>
<p style="margin: 5px 0;"><strong style="color: purple;"> Purple circles:</strong> Image elements (IMG1, IMG2, etc.)</p>
</div>
</div>
` : ''}
@@ -157,9 +174,13 @@ export async function GET(req: Request, { params }: { params: { id: string } })
</tr>
</thead>
<tbody>
${designElements.map((el, idx) => `
${designElements.map((el, idx) => {
const isImage = el.type === 'image';
const imageNum = imageMap.get(idx);
const label = isImage ? `Image ${imageNum}` : `E${idx + 1}`;
return `
<tr>
<td>E${idx + 1}</td>
<td>${label}</td>
<td class="element-type">${el.type === 'text' ? '📝 Text' : '🖼️ Image'}</td>
<td>X: ${convertPositionToCm(el.x, printAreaStartX)} | Y: ${convertPositionToCm(el.y, printAreaStartY)}</td>
<td>X: ${Math.round(el.x - printAreaStartX)} | Y: ${Math.round(el.y - printAreaStartY)}</td>
@@ -176,7 +197,7 @@ export async function GET(req: Request, { params }: { params: { id: string } })
`}
</td>
</tr>
`).join('')}
`}).join('')}
</tbody>
</table>
` : `<p style="color: #666; font-style: italic;">No individual design elements to measure.</p>`}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function DELETE(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const review = await prisma.review.delete({
where: { id: params.id },
});
return NextResponse.json(review);
} catch (err) {
console.error('Error deleting review:', err);
return NextResponse.json(
{ error: 'Failed to delete review' },
{ status: 500 }
);
}
}
export async function PATCH(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const body = await req.json();
const { approved } = body;
const review = await prisma.review.update({
where: { id: params.id },
data: { approved },
});
return NextResponse.json(review);
} catch (err) {
console.error('Error updating review:', err);
return NextResponse.json(
{ error: 'Failed to update review' },
{ status: 500 }
);
}
}
+65
View File
@@ -0,0 +1,65 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getCurrentCustomer } from '@/lib/auth';
export async function POST(req: Request) {
try {
const customer = await getCurrentCustomer();
const body = await req.json();
const { productId, rating, title, comment } = body;
if (!productId || !rating || !title || !comment) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (rating < 1 || rating > 5) {
return NextResponse.json({ error: 'Rating must be between 1 and 5' }, { status: 400 });
}
const review = await prisma.review.create({
data: {
customerId: customer?.id || null,
customerEmail: customer?.email || body.customerEmail || 'guest@example.com',
customerName: customer?.name || body.customerName || null,
productId,
rating,
title,
comment,
approved: false,
},
});
return NextResponse.json(review);
} catch (err) {
console.error('Error creating review:', err);
return NextResponse.json(
{ error: 'Failed to create review' },
{ status: 500 }
);
}
}
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url);
const productId = searchParams.get('productId');
const approved = searchParams.get('approved') === 'true';
const reviews = await prisma.review.findMany({
where: {
...(productId && { productId }),
approved,
},
include: { product: true },
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(reviews);
} catch (err) {
console.error('Error fetching reviews:', err);
return NextResponse.json(
{ error: 'Failed to fetch reviews' },
{ status: 500 }
);
}
}
+100
View File
@@ -0,0 +1,100 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import ReviewForm from '@/components/ReviewForm';
import { notFound } from 'next/navigation';
export default async function ProductReviewsPage({ params }: { params: { slug: string } }) {
const product = await prisma.product.findUnique({
where: { slug: params.slug },
include: {
reviews: {
where: { approved: true },
orderBy: { createdAt: 'desc' },
},
},
});
if (!product) {
notFound();
}
const avgRating = product.reviews.length > 0
? (product.reviews.reduce((sum, r) => sum + r.rating, 0) / product.reviews.length).toFixed(1)
: null;
const ratingCounts = [5, 4, 3, 2, 1].map(rating => ({
rating,
count: product.reviews.filter(r => r.rating === rating).length,
}));
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<Link href={`/products/${product.slug}`} className="text-sm text-clay hover:text-clay-dark mb-4 inline-block">
Back to product
</Link>
<h1 className="font-display text-3xl mb-2">{product.name} Reviews</h1>
{/* Rating Summary */}
{product.reviews.length > 0 && (
<div className="mb-8 p-4 border border-line bg-surface">
<div className="flex items-center gap-4">
<div>
<p className="text-3xl font-bold">{avgRating}</p>
<p className="text-sm text-muted">{product.reviews.length} review{product.reviews.length !== 1 ? 's' : ''}</p>
</div>
<div className="flex-1 space-y-2">
{ratingCounts.map(({ rating, count }) => (
<div key={rating} className="flex items-center gap-2">
<span className="text-xs w-6">{rating}</span>
<div className="flex-1 h-2 bg-gray-200 rounded">
<div
className="h-full bg-yellow-400 rounded"
style={{
width: `${product.reviews.length > 0 ? (count / product.reviews.length) * 100 : 0}%`,
}}
/>
</div>
<span className="text-xs text-muted w-8 text-right">{count}</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Leave a Review */}
<div className="mb-12">
<h2 className="font-display text-xl mb-4">Leave a review</h2>
<ReviewForm productId={product.id} productName={product.name} />
</div>
{/* Reviews List */}
<div>
<h2 className="font-display text-xl mb-4">Customer reviews</h2>
{product.reviews.length === 0 ? (
<p className="text-sm text-muted">No reviews yet. Be the first to review this product!</p>
) : (
<div className="divide-y divide-line border-t border-line">
{product.reviews.map((review) => (
<div key={review.id} className="py-6">
<div className="flex items-start justify-between mb-2">
<div>
<p className="font-medium text-sm">{review.title}</p>
<p className="text-xs text-muted">
{review.customerName || 'Anonymous'} {'★'.repeat(review.rating)}
</p>
</div>
</div>
<p className="text-sm text-ink mb-2">{review.comment}</p>
<p className="text-xs text-muted">
{new Date(review.createdAt).toLocaleDateString(undefined, { dateStyle: 'medium' })}
</p>
</div>
))}
</div>
)}
</div>
</div>
);
}