ifixkart-storefront/components/QuickViewModal.tsx

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>
);
};