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:
co-authored by
Claude Haiku 4.5
parent
a1f1cc3fa1
commit
16f1f64fd0
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -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>`}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user