409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useMemo } from 'react';
|
|
import Link from 'next/link';
|
|
import { useRouter } from 'next/navigation';
|
|
import { Star, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
import { Product } from '../types';
|
|
import { formatCurrency, getImageUrl, parseMoney } from '../lib/utils';
|
|
import { useCartStore } from '@/store/cartStore';
|
|
import { ProductResponse, ProductVariantResponse } from '@/services/api/catalogService';
|
|
import { requireLoginForCheckout } from '@/lib/requireLoginForCheckout';
|
|
|
|
interface QuickViewModalProps {
|
|
product: Product;
|
|
products?: Product[];
|
|
onClose: () => void;
|
|
}
|
|
|
|
function toImgStr(v: unknown): string {
|
|
if (typeof v === 'string') return v;
|
|
if (v && typeof v === 'object') {
|
|
const obj = v as Record<string, unknown>;
|
|
return String(obj.image_url ?? obj.url ?? obj.src ?? '');
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function productKey(p: Product): string {
|
|
return String((p as any).product_id || p.slug || p.id || '');
|
|
}
|
|
|
|
function getProductGallery(product: Product): string[] {
|
|
const urls: string[] = [];
|
|
const push = (raw: unknown) => {
|
|
const s = toImgStr(raw);
|
|
if (s && !urls.includes(s)) urls.push(s);
|
|
};
|
|
|
|
push(product.image);
|
|
push(product.imageHover);
|
|
|
|
const extra = (product as any).images;
|
|
if (Array.isArray(extra)) {
|
|
extra.forEach((img: any) => push(typeof img === 'string' ? img : img?.image_url));
|
|
}
|
|
|
|
return urls.length > 0 ? urls : [''];
|
|
}
|
|
|
|
function buildProductResponse(product: Product): { resp: ProductResponse; variant: ProductVariantResponse } {
|
|
const realProductId = (product as any).product_id || null;
|
|
const realVariantId = (product as any).first_variant_id || null;
|
|
const rawId = realProductId ?? product.id ?? product.slug ?? `rnd-${Math.random()}`;
|
|
const idStr = String(rawId);
|
|
const productId = realProductId || (idStr.startsWith('prod-') ? idStr : `prod-${idStr}`);
|
|
const priceVal = parseMoney(product.price);
|
|
const imageUrl = toImgStr(product.image);
|
|
const variantId = realVariantId || `var-${idStr}`;
|
|
|
|
const variant: ProductVariantResponse = {
|
|
variant_id: variantId,
|
|
product_id: productId,
|
|
sku: (product as any).sku || `SKU-${idStr}`,
|
|
barcode: null,
|
|
price: priceVal,
|
|
compare_price: parseMoney(product.oldPrice) || null,
|
|
cost_price: priceVal,
|
|
low_stock_threshold: 5,
|
|
status: 'ACTIVE',
|
|
available_stock: (product as any).totalStock ?? 100,
|
|
images: [],
|
|
};
|
|
|
|
const resp: ProductResponse = {
|
|
product_id: productId,
|
|
category_id: product.category || 'uncategorized',
|
|
brand_id: (product as any).brand || null,
|
|
device_series_id: null,
|
|
device_model_id: null,
|
|
name: product.name,
|
|
slug: product.slug || idStr,
|
|
full_path: `/products/${product.slug || idStr}`,
|
|
description: product.description || null,
|
|
status: 'ACTIVE',
|
|
created_at: new Date().toISOString(),
|
|
images: [
|
|
{
|
|
image_id: `img-${idStr}`,
|
|
product_id: productId,
|
|
image_url: imageUrl,
|
|
alt_text: product.name,
|
|
sort_order: 1,
|
|
is_banner: false,
|
|
},
|
|
],
|
|
variants: [variant],
|
|
rating: product.rating,
|
|
review_count: product.reviewsCount,
|
|
badge: product.discount ? 'SALE' : null,
|
|
};
|
|
|
|
return { resp, variant };
|
|
}
|
|
|
|
const DEFAULT_FEATURES = [
|
|
'Upto 50h Playtime',
|
|
'13mm Drivers',
|
|
'1.85" Display',
|
|
];
|
|
|
|
export const QuickViewModal: React.FC<QuickViewModalProps> = ({
|
|
product: initialProduct,
|
|
products: productsProp,
|
|
onClose,
|
|
}) => {
|
|
const router = useRouter();
|
|
const addToCart = useCartStore((s) => s.addToCart);
|
|
|
|
const productList = useMemo(() => {
|
|
if (productsProp && productsProp.length > 0) return productsProp;
|
|
return [initialProduct];
|
|
}, [productsProp, initialProduct]);
|
|
|
|
const initialIndex = Math.max(
|
|
0,
|
|
productList.findIndex((p) => productKey(p) === productKey(initialProduct))
|
|
);
|
|
|
|
const [productIndex, setProductIndex] = useState(initialIndex >= 0 ? initialIndex : 0);
|
|
const [imageIndex, setImageIndex] = useState(0);
|
|
const [quantity, setQuantity] = useState(1);
|
|
const [added, setAdded] = useState(false);
|
|
|
|
const product = productList[productIndex] || initialProduct;
|
|
const canSwitchProduct = productList.length > 1;
|
|
|
|
const gallery = useMemo(() => getProductGallery(product), [product]);
|
|
const canSwitchImage = gallery.length > 1;
|
|
const currentImageUrl = getImageUrl(gallery[imageIndex] || gallery[0] || '');
|
|
|
|
const features =
|
|
product.features && product.features.length > 0
|
|
? product.features.slice(0, 4)
|
|
: DEFAULT_FEATURES;
|
|
|
|
const reviewCount = product.reviewsCount ?? 1;
|
|
const slug = product.slug || product.id;
|
|
const stock =
|
|
typeof (product as any).totalStock === 'number'
|
|
? (product as any).totalStock
|
|
: null;
|
|
|
|
useEffect(() => {
|
|
setImageIndex(0);
|
|
setQuantity(1);
|
|
setAdded(false);
|
|
}, [productIndex]);
|
|
|
|
useEffect(() => {
|
|
document.body.style.overflow = 'hidden';
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
if (e.key === 'ArrowLeft' && canSwitchProduct) {
|
|
setProductIndex((i) => (i === 0 ? productList.length - 1 : i - 1));
|
|
}
|
|
if (e.key === 'ArrowRight' && canSwitchProduct) {
|
|
setProductIndex((i) => (i === productList.length - 1 ? 0 : i + 1));
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handleKey);
|
|
return () => {
|
|
document.body.style.overflow = '';
|
|
document.removeEventListener('keydown', handleKey);
|
|
};
|
|
}, [onClose, canSwitchProduct, productList.length]);
|
|
|
|
const goPrevProduct = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (!canSwitchProduct) return;
|
|
setProductIndex((i) => (i === 0 ? productList.length - 1 : i - 1));
|
|
};
|
|
|
|
const goNextProduct = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (!canSwitchProduct) return;
|
|
setProductIndex((i) => (i === productList.length - 1 ? 0 : i + 1));
|
|
};
|
|
|
|
const goPrevImage = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (!canSwitchImage) return;
|
|
setImageIndex((i) => (i === 0 ? gallery.length - 1 : i - 1));
|
|
};
|
|
|
|
const goNextImage = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (!canSwitchImage) return;
|
|
setImageIndex((i) => (i === gallery.length - 1 ? 0 : i + 1));
|
|
};
|
|
|
|
const handleAddToCart = () => {
|
|
const { resp, variant } = buildProductResponse(product);
|
|
void addToCart(resp, variant, quantity);
|
|
setAdded(true);
|
|
setTimeout(() => setAdded(false), 1800);
|
|
};
|
|
|
|
const handleBuyNow = () => {
|
|
const { resp, variant } = buildProductResponse(product);
|
|
void addToCart(resp, variant, quantity);
|
|
onClose();
|
|
if (!requireLoginForCheckout('/checkout')) return;
|
|
router.push('/checkout');
|
|
};
|
|
|
|
return (
|
|
<div
|
|
onClick={onClose}
|
|
className="fixed inset-0 bg-black/65 flex items-center justify-center z-[200] p-3 md:p-6 font-sans"
|
|
>
|
|
{/* Outside arrows — switch products */}
|
|
{canSwitchProduct && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={goPrevProduct}
|
|
className="absolute left-3 md:left-6 top-1/2 -translate-y-1/2 z-[210] w-11 h-11 md:w-12 md:h-12 flex items-center justify-center text-white/90 hover:text-white cursor-pointer transition-colors"
|
|
aria-label="Previous product"
|
|
>
|
|
<ChevronLeft size={42} strokeWidth={1.5} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={goNextProduct}
|
|
className="absolute right-3 md:right-6 top-1/2 -translate-y-1/2 z-[210] w-11 h-11 md:w-12 md:h-12 flex items-center justify-center text-white/90 hover:text-white cursor-pointer transition-colors"
|
|
aria-label="Next product"
|
|
>
|
|
<ChevronRight size={42} strokeWidth={1.5} />
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
<div
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="bg-white w-full max-w-[1000px] md:h-[min(500px,82vh)] md:w-[min(1000px,calc(82vh*2))] overflow-hidden shadow-2xl relative flex flex-col md:flex-row text-left mx-10 md:mx-16"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="absolute top-4 right-4 text-gray-800 hover:text-black transition-colors cursor-pointer z-30"
|
|
title="Close"
|
|
aria-label="Close quick view"
|
|
>
|
|
<X size={20} strokeWidth={2} />
|
|
</button>
|
|
|
|
{/* Left: square image panel — width = height (half of 2:1 modal) */}
|
|
<div className="w-full md:w-1/2 md:h-full relative select-none bg-white border-b md:border-b-0 md:border-r border-gray-100 shrink-0 aspect-square md:aspect-auto">
|
|
<div className="relative w-full h-full min-h-[280px] md:min-h-0 flex flex-col">
|
|
<div className="relative flex-1 min-h-0 flex items-center justify-center p-8 md:p-12">
|
|
{currentImageUrl ? (
|
|
<img
|
|
key={`${productKey(product)}-${imageIndex}`}
|
|
src={currentImageUrl}
|
|
alt={product.name}
|
|
className="max-w-full max-h-full w-auto h-auto object-contain"
|
|
/>
|
|
) : (
|
|
<div className="w-3/4 aspect-square bg-gray-100 rounded flex items-center justify-center text-gray-400 text-sm">
|
|
No Image
|
|
</div>
|
|
)}
|
|
|
|
{canSwitchImage && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={goPrevImage}
|
|
className="absolute left-3 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-primary text-white flex items-center justify-center cursor-pointer shadow-md hover:brightness-95 z-10"
|
|
aria-label="Previous image"
|
|
>
|
|
<ChevronLeft size={20} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={goNextImage}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-primary text-white flex items-center justify-center cursor-pointer shadow-md hover:brightness-95 z-10"
|
|
aria-label="Next image"
|
|
>
|
|
<ChevronRight size={20} />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{canSwitchImage && (
|
|
<div className="flex justify-center gap-1.5 pb-5">
|
|
{gallery.map((_, idx) => (
|
|
<button
|
|
key={idx}
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setImageIndex(idx);
|
|
}}
|
|
className={`h-2 rounded-full cursor-pointer transition-all ${
|
|
imageIndex === idx ? 'bg-black w-2.5' : 'bg-gray-300 w-2'
|
|
}`}
|
|
aria-label={`Image ${idx + 1}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: details */}
|
|
<div className="w-full md:w-1/2 md:h-full p-6 md:p-8 flex flex-col overflow-y-auto">
|
|
<h2 className="text-[22px] md:text-[24px] font-bold text-[#111] pr-8 mb-3 leading-snug">
|
|
{product.name}
|
|
</h2>
|
|
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<div className="flex items-center gap-0.5">
|
|
{[...Array(5)].map((_, i) => (
|
|
<Star
|
|
key={i}
|
|
size={14}
|
|
className={
|
|
i < Math.floor(product.rating ?? 5)
|
|
? 'text-amber-400 fill-amber-400'
|
|
: 'text-gray-300'
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
<Link
|
|
href={`/products/${slug}#product-tabs`}
|
|
onClick={onClose}
|
|
className="text-[13px] text-primary hover:underline"
|
|
>
|
|
({reviewCount} review{reviewCount === 1 ? '' : 's'})
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="flex items-baseline gap-2.5 mb-5">
|
|
{product.oldPrice && product.oldPrice > parseMoney(product.price) && (
|
|
<span className="text-[15px] text-gray-400 line-through">
|
|
{formatCurrency(product.oldPrice)}
|
|
</span>
|
|
)}
|
|
<span className="text-[20px] font-bold text-[#111]">
|
|
{formatCurrency(parseMoney(product.price))}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-start justify-between gap-3 mb-6">
|
|
<ul className="text-[13px] text-gray-600 space-y-1.5 list-disc pl-5">
|
|
{features.map((f) => (
|
|
<li key={f}>{f}</li>
|
|
))}
|
|
</ul>
|
|
{stock != null && stock > 0 && (
|
|
<span className="shrink-0 text-[11px] font-semibold text-emerald-700 bg-emerald-50 px-2.5 py-1 rounded-full">
|
|
{stock} in stock
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={99}
|
|
value={quantity}
|
|
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
|
|
className="w-14 h-11 text-center border border-gray-300 rounded-md text-[14px] font-medium focus:outline-none focus:border-primary"
|
|
aria-label="Quantity"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleAddToCart}
|
|
className="flex-1 h-11 bg-primary hover:brightness-95 text-white text-[14px] font-semibold rounded-md cursor-pointer transition-colors"
|
|
>
|
|
{added ? 'Added!' : 'Add To Cart'}
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleBuyNow}
|
|
className="w-full h-11 bg-primary hover:brightness-95 text-white text-[14px] font-semibold rounded-md cursor-pointer mb-6"
|
|
>
|
|
Buy Now
|
|
</button>
|
|
|
|
<p className="text-[12px] text-gray-500 leading-relaxed mt-auto">
|
|
<span className="font-medium text-gray-600">Categories:</span>{' '}
|
|
{product.category
|
|
? String(product.category)
|
|
.replace(/[_-]+/g, ' ')
|
|
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
: 'Our Store, Laptop & Computers, Smart Devices'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|