- 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>
202 lines
7.7 KiB
TypeScript
202 lines
7.7 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { v4 as uuid } from 'uuid';
|
||
import ProductMockup from './ProductMockup';
|
||
import Price from './Price';
|
||
import LoginPromptModal from './LoginPromptModal';
|
||
import AddToCartModal from './AddToCartModal';
|
||
import { useCart } from '@/lib/cartStore';
|
||
import { getColorName } from '@/lib/colorNames';
|
||
import { checkCustomerAuth } from '@/app/actions';
|
||
import type { ProductDTO } from '@/lib/types';
|
||
|
||
export default function StandardProductView({ product }: { product: ProductDTO }) {
|
||
const [color, setColor] = useState(product.colors[0]);
|
||
const [size, setSize] = useState<string | null>(product.sizes[0] ?? null);
|
||
const [view, setView] = useState<'front' | 'back'>('front');
|
||
const [quantity, setQuantity] = useState(1);
|
||
const [justAdded, setJustAdded] = useState(false);
|
||
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
||
const [showAddToCartModal, setShowAddToCartModal] = useState(false);
|
||
const addItem = useCart((s) => s.addItem);
|
||
|
||
const hasBack = Boolean(product.imageUrlBack);
|
||
|
||
function renderPhoto(v: 'front' | 'back') {
|
||
const colorPhoto = v === 'front' ? product.colorPhotos[color] : product.colorPhotosBack[color];
|
||
if (colorPhoto) {
|
||
return <img src={colorPhoto} alt={`${product.name} in ${color}`} className="h-full w-full object-cover" />;
|
||
}
|
||
|
||
const activePhoto = v === 'front' ? product.imageUrl : product.imageUrlBack;
|
||
if (!activePhoto) {
|
||
return <ProductMockup mockup={product.mockup} color={color} />;
|
||
}
|
||
if (product.photoRecolorable) {
|
||
return (
|
||
<div className="relative h-full w-full">
|
||
<img
|
||
src={activePhoto}
|
||
alt={product.name}
|
||
className="h-full w-full object-cover"
|
||
style={{ filter: 'grayscale(1) brightness(2.1) contrast(1.15)' }}
|
||
/>
|
||
<div className="absolute inset-0" style={{ backgroundColor: color, mixBlendMode: 'multiply' }} />
|
||
</div>
|
||
);
|
||
}
|
||
return <img src={activePhoto} alt={product.name} className="h-full w-full object-cover" />;
|
||
}
|
||
|
||
const handleAddToBag = async () => {
|
||
addItem({
|
||
cartItemId: uuid(),
|
||
productId: product.id,
|
||
slug: product.slug,
|
||
name: product.name,
|
||
mockup: product.mockup,
|
||
color,
|
||
size,
|
||
quantity,
|
||
unitPrice: product.effectivePrice,
|
||
designJson: { front: [], back: [] },
|
||
designPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? '',
|
||
designPreviewUrlBack: product.colorPhotosBack[color] ?? product.imageUrlBack ?? null,
|
||
placementPreviewUrl: product.colorPhotos[color] ?? product.imageUrl ?? null,
|
||
placementPreviewUrlBack: product.colorPhotosBack[color] ?? product.imageUrlBack ?? null,
|
||
});
|
||
setShowAddToCartModal(true);
|
||
};
|
||
|
||
return (
|
||
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[560px_1fr]">
|
||
{/* Photo */}
|
||
<div className="w-full min-w-0 max-w-[560px]">
|
||
{hasBack && (
|
||
<div className="mb-3 inline-flex border border-line">
|
||
<button
|
||
onClick={() => setView('front')}
|
||
className={`px-4 py-1.5 text-sm ${view === 'front' ? 'bg-ink text-paper' : 'hover:text-clay'}`}
|
||
>
|
||
Front
|
||
</button>
|
||
<button
|
||
onClick={() => setView('back')}
|
||
className={`px-4 py-1.5 text-sm ${view === 'back' ? 'bg-ink text-paper' : 'hover:text-clay'}`}
|
||
>
|
||
Back
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="relative aspect-square w-full border border-line bg-surface">
|
||
{renderPhoto(view)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Controls */}
|
||
<div className="flex flex-col gap-8">
|
||
<div>
|
||
<h1 className="font-display text-4xl">{product.name}</h1>
|
||
<p className="mt-2 text-muted">{product.description}</p>
|
||
<p className="mt-4 flex items-baseline gap-2 font-mono text-lg">
|
||
{product.onSale && product.originalPrice != null && (
|
||
<Price cents={product.originalPrice} className="text-base text-muted line-through" />
|
||
)}
|
||
<Price cents={product.effectivePrice} className={product.onSale ? 'text-splash-pink' : undefined} />
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="tag-label mb-3">Color</p>
|
||
<div className="space-y-2">
|
||
<div className="flex flex-wrap gap-3">
|
||
{product.colors.map((c) => (
|
||
<button
|
||
key={c}
|
||
onClick={() => setColor(c)}
|
||
aria-label={`Choose ${getColorName(c)} color`}
|
||
className={`h-9 w-9 rounded-full border transition-transform ${
|
||
color === c ? 'scale-110 border-ink ring-2 ring-ink ring-offset-2 ring-offset-paper' : 'border-line'
|
||
}`}
|
||
style={{ backgroundColor: c }}
|
||
title={getColorName(c)}
|
||
/>
|
||
))}
|
||
</div>
|
||
{color && (
|
||
<p className="text-sm text-muted font-medium">
|
||
Selected: <span className="text-ink">{getColorName(color)}</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
{product.imageUrl && !product.photoRecolorable && Object.keys(product.colorPhotos).length === 0 && (
|
||
<p className="mt-2 text-xs text-muted">
|
||
This product uses a real photo, so the picture won't change color — your pick is
|
||
still saved with the order.
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{product.sizes.length > 0 && (
|
||
<div>
|
||
<p className="tag-label mb-3">Size</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{product.sizes.map((s) => (
|
||
<button
|
||
key={s}
|
||
onClick={() => setSize(s)}
|
||
className={`min-w-[44px] border px-3 py-2 text-sm transition-colors ${
|
||
size === s
|
||
? 'border-clay bg-clay text-paper'
|
||
: 'border-line text-ink hover:border-clay hover:text-clay'
|
||
}`}
|
||
>
|
||
{s}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-4">
|
||
<div className="flex items-center border border-ink">
|
||
<button
|
||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||
className="px-3 py-2 text-sm"
|
||
aria-label="Decrease quantity"
|
||
>
|
||
−
|
||
</button>
|
||
<span className="w-10 text-center font-mono text-sm">{quantity}</span>
|
||
<button
|
||
onClick={() => setQuantity((q) => q + 1)}
|
||
className="px-3 py-2 text-sm"
|
||
aria-label="Increase quantity"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
<button
|
||
onClick={handleAddToBag}
|
||
className="flex-1 bg-clay px-6 py-3 text-sm font-medium text-paper transition-colors hover:bg-clay-dark"
|
||
>
|
||
Add to bag — <Price cents={product.effectivePrice * quantity} />
|
||
</button>
|
||
</div>
|
||
|
||
<p className="text-xs text-muted border-t border-line pt-4">
|
||
💡 <strong>Color note:</strong> There may be slight color differences based on lighting and screen settings for personalized, plain, and ready-made items. Your actual item may vary slightly from the preview shown.
|
||
</p>
|
||
</div>
|
||
|
||
<LoginPromptModal isOpen={showLoginPrompt} onClose={() => setShowLoginPrompt(false)} />
|
||
<AddToCartModal
|
||
isOpen={showAddToCartModal}
|
||
onClose={() => setShowAddToCartModal(false)}
|
||
productName={product.name}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|