- Implemented CartContext for managing cart state, including adding, removing, and updating items. - Added CartDrawer component to display cart items. - Updated AppHeader to include cart item count. - Enhanced welcome page with new layout, improved product image handling, and updated call-to-action buttons. - Refactored homepage data types to accommodate new features and removed unused properties.
261 lines
13 KiB
TypeScript
261 lines
13 KiB
TypeScript
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useCart } from '@/contexts/cart-context';
|
|
import { formatRupiah } from '@/lib/rupiah';
|
|
import { formatWhatsAppPhone } from '@/lib/format';
|
|
import type { Product, ProductVariant } from '@/types/homepage';
|
|
import { Minus, Plus, ShoppingBag, ShoppingCart, X } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
|
|
type ProductModalProps = {
|
|
product: Product | null;
|
|
onClose: () => void;
|
|
contactPhone?: string | null;
|
|
};
|
|
|
|
const FALLBACK_IMAGE = 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=1000&auto=format&fit=crop&q=80';
|
|
|
|
function getVariantImage(variant: ProductVariant): string {
|
|
if (variant.photo_urls && variant.photo_urls.length > 0) {
|
|
return variant.photo_urls[0];
|
|
}
|
|
return FALLBACK_IMAGE;
|
|
}
|
|
|
|
function getRetailPrice(variant: ProductVariant): number {
|
|
const retail = variant.product_prices.find((p) => p.type === 'retail');
|
|
return retail?.price ?? 0;
|
|
}
|
|
|
|
function getWholesalePrice(variant: ProductVariant): number {
|
|
const wholesale = variant.product_prices.find((p) => p.type === 'wholesale');
|
|
return wholesale?.price ?? 0;
|
|
}
|
|
|
|
export default function ProductModal({ product, onClose, contactPhone }: ProductModalProps) {
|
|
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
|
|
const [selectedPriceType, setSelectedPriceType] = useState<'retail' | 'wholesale'>('retail');
|
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
|
const [quantity, setQuantity] = useState(1);
|
|
const { addItem } = useCart();
|
|
|
|
if (!product) return null;
|
|
|
|
const variants = product.product_variants;
|
|
const activeVariant = selectedVariant ?? variants[0];
|
|
const variantImages: string[] = activeVariant?.photo_urls?.length
|
|
? activeVariant.photo_urls
|
|
: [FALLBACK_IMAGE];
|
|
const currentImage = selectedImage ?? variantImages[0];
|
|
const retailPrice = activeVariant ? getRetailPrice(activeVariant) : 0;
|
|
const wholesalePrice = activeVariant ? getWholesalePrice(activeVariant) : 0;
|
|
const currentPrice = selectedPriceType === 'retail' ? retailPrice : wholesalePrice;
|
|
const maxStock = activeVariant?.stock ?? 0;
|
|
|
|
const handleVariantChange = (variant: ProductVariant) => {
|
|
setSelectedVariant(variant);
|
|
setSelectedImage(null);
|
|
setQuantity(1);
|
|
};
|
|
|
|
const handleAddToCart = () => {
|
|
if (!activeVariant) return;
|
|
|
|
addItem({
|
|
productId: product.id,
|
|
productName: product.name,
|
|
variantId: activeVariant.id,
|
|
variantName: activeVariant.name,
|
|
priceType: selectedPriceType,
|
|
price: currentPrice,
|
|
imageUrl: getVariantImage(activeVariant),
|
|
quantity,
|
|
});
|
|
toast.success(`${product.name} ditambahkan ke keranjang`);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="bg-[#FDFDFC] rounded-3xl max-w-4xl w-full max-h-[90vh] overflow-y-auto shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex justify-between items-center p-6 border-b border-amber-100/30">
|
|
<h3 className="text-lg font-serif text-slate-800">Detail Produk</h3>
|
|
<Button variant="ghost" size="icon" onClick={onClose} className="rounded-full hover:bg-slate-100">
|
|
<X className="w-5 h-5 text-slate-500" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-6">
|
|
{/* Image */}
|
|
<div className="space-y-3">
|
|
<div className="relative aspect-3/4 rounded-2xl overflow-hidden">
|
|
<img
|
|
key={currentImage}
|
|
src={currentImage}
|
|
alt={product.name}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
|
|
{variantImages.length > 1 && (
|
|
<div className="flex gap-2 overflow-x-auto pb-1">
|
|
{variantImages.map((url, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => setSelectedImage(url)}
|
|
className={`relative shrink-0 w-16 h-20 rounded-lg overflow-hidden border-2 transition-all ${currentImage === url
|
|
? 'border-amber-500 ring-2 ring-amber-200'
|
|
: 'border-slate-200 hover:border-amber-300 opacity-70 hover:opacity-100'
|
|
}`}
|
|
>
|
|
<img
|
|
src={url}
|
|
alt={`${product.name} ${i + 1}`}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Details */}
|
|
<div className="space-y-4">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
{product.categories.slice(0, 2).map((cat) => (
|
|
<Badge key={cat.id} variant="secondary" className="bg-amber-50 text-amber-600 uppercase tracking-wider font-semibold px-2 py-0.5">
|
|
{cat.name}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
<h2 className="text-2xl font-serif text-slate-800">{product.name}</h2>
|
|
</div>
|
|
|
|
<p className="text-sm text-slate-500 leading-relaxed font-light">
|
|
{product.description || 'Produk fashion berkualitas dengan desain elegan dan bahan nyaman.'}
|
|
</p>
|
|
|
|
{/* Price */}
|
|
<div className="bg-amber-50/50 p-4 rounded-xl">
|
|
<p className="text-xs text-slate-500 mb-1">Harga</p>
|
|
<p className="text-2xl font-bold text-amber-600">
|
|
Rp {formatRupiah(currentPrice)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Variant Selection */}
|
|
{variants.length > 0 && (
|
|
<div>
|
|
<p className="text-xs font-semibold text-slate-700 mb-2">Varian</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{variants.map((variant) => (
|
|
<Button
|
|
key={variant.id}
|
|
variant={activeVariant?.id === variant.id ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => handleVariantChange(variant)}
|
|
className={activeVariant?.id === variant.id
|
|
? 'bg-amber-600 text-white border-transparent'
|
|
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
|
|
}
|
|
>
|
|
{variant.name}
|
|
<span className="ml-1 text-[10px] opacity-70">
|
|
({variant.stock})
|
|
</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Price Type */}
|
|
<div>
|
|
<p className="text-xs font-semibold text-slate-700 mb-2">Jenis Harga</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant={selectedPriceType === 'retail' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setSelectedPriceType('retail')}
|
|
className={selectedPriceType === 'retail'
|
|
? 'bg-amber-600 text-white border-transparent'
|
|
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
|
|
}
|
|
>
|
|
Retail
|
|
</Button>
|
|
<Button
|
|
variant={selectedPriceType === 'wholesale' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setSelectedPriceType('wholesale')}
|
|
className={selectedPriceType === 'wholesale'
|
|
? 'bg-amber-600 text-white border-transparent'
|
|
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
|
|
}
|
|
>
|
|
Grosir
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quantity */}
|
|
<div>
|
|
<p className="text-xs font-semibold text-slate-700 mb-2">Jumlah</p>
|
|
<div className="flex items-center gap-3 border border-slate-200 rounded-xl px-3 py-2 w-fit">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
|
>
|
|
<Minus className="w-4 h-4" />
|
|
</Button>
|
|
<span className="text-sm font-semibold w-8 text-center">{quantity}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={() => setQuantity(Math.min(maxStock, quantity + 1))}
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
<p className="text-[10px] text-slate-400 mt-1">Stok tersedia: {maxStock}</p>
|
|
</div>
|
|
|
|
{/* Total */}
|
|
<div className="bg-slate-50 p-4 rounded-xl">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-slate-500">Subtotal</span>
|
|
<span className="font-bold text-amber-600">
|
|
Rp {formatRupiah(currentPrice * quantity)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-3">
|
|
<Button onClick={handleAddToCart} variant="outline" className="flex-1 border-amber-200 text-amber-700 hover:bg-amber-50 py-3.5 text-xs font-bold uppercase tracking-widest rounded-xl transition-all">
|
|
<ShoppingBag className="w-4 h-4 mr-2" />
|
|
Keranjang
|
|
</Button>
|
|
<Button asChild className="flex-1 bg-amber-600 text-white hover:bg-amber-500 py-3.5 text-xs font-bold uppercase tracking-widest rounded-xl transition-all shadow-md">
|
|
<a href={contactPhone ? `https://wa.me/${formatWhatsAppPhone(contactPhone)}?text=${encodeURIComponent(`Halo min, saya mau order, berikut pesanannya.\n\n*${product.name}*\n* Varian: ${activeVariant?.name ?? '-'}\n* Harga: Rp ${formatRupiah(currentPrice)}\n* QTY : ${quantity}\n* Subtotal : Rp ${formatRupiah(currentPrice * quantity)}\n\nTotal: Rp ${formatRupiah(currentPrice * quantity)}\n\nMohon diinformasikan detail pembayaran dan proses selanjutnya ya, Kak.\n\nTerimakasih`)}` : '#'} target="_blank" rel="noreferrer">
|
|
<ShoppingCart className="w-4 h-4 mr-2" />
|
|
Beli Sekarang
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|