Files
craft2prints/src/components/StandardProductView.tsx
T
AndymickandClaude Haiku 4.5 16f1f64fd0 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>
2026-07-19 18:24:58 +01:00

202 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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&apos;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>
);
}