152 lines
5.4 KiB
TypeScript
152 lines
5.4 KiB
TypeScript
'use client';
|
||
|
||
import Link from 'next/link';
|
||
import Image from 'next/image';
|
||
import { useEffect, useState } from 'react';
|
||
import BlurHashImage from '@/components/ui/BlurHashImage';
|
||
import { ProductResponse } from '@/services/api/catalogService';
|
||
import { formatCurrency, getCatalogPriceInfo, getImageUrl } from '@/lib/utils';
|
||
|
||
interface YouMayAlsoLikeProps {
|
||
products: ProductResponse[];
|
||
}
|
||
|
||
function priceLabel(product: ProductResponse): { current: string; compare?: string; range: boolean } {
|
||
const { price, priceMax, oldPrice } = getCatalogPriceInfo(product);
|
||
|
||
if (!price) return { current: '—', range: false };
|
||
|
||
if (priceMax && priceMax > price) {
|
||
return {
|
||
current: `${formatCurrency(price)} – ${formatCurrency(priceMax)}`,
|
||
range: true,
|
||
};
|
||
}
|
||
|
||
return {
|
||
current: formatCurrency(price),
|
||
compare: oldPrice && oldPrice > price ? formatCurrency(oldPrice) : undefined,
|
||
range: false,
|
||
};
|
||
}
|
||
|
||
function discountPct(product: ProductResponse): number {
|
||
const { discount } = getCatalogPriceInfo(product);
|
||
return discount || 0;
|
||
}
|
||
|
||
function stableSaleSeconds(productId: string): number {
|
||
let hash = 0;
|
||
for (let i = 0; i < productId.length; i++) hash = (hash * 31 + productId.charCodeAt(i)) >>> 0;
|
||
return 36 * 3600 + (hash % (48 * 3600));
|
||
}
|
||
|
||
function SaleCountdown({ productId }: { productId: string }) {
|
||
const [remaining, setRemaining] = useState(() => stableSaleSeconds(productId));
|
||
|
||
useEffect(() => {
|
||
const t = setInterval(() => setRemaining((s) => (s > 0 ? s - 1 : 0)), 1000);
|
||
return () => clearInterval(t);
|
||
}, []);
|
||
|
||
const days = Math.floor(remaining / 86400);
|
||
const hrs = Math.floor((remaining % 86400) / 3600);
|
||
const min = Math.floor((remaining % 3600) / 60);
|
||
const sec = remaining % 60;
|
||
const pad = (n: number) => String(n).padStart(2, '0');
|
||
|
||
const units = [
|
||
{ val: pad(days), label: 'DAYS' },
|
||
{ val: pad(hrs), label: 'HRS' },
|
||
{ val: pad(min), label: 'MIN' },
|
||
{ val: pad(sec), label: 'SEC' },
|
||
];
|
||
|
||
return (
|
||
<div className="flex items-center justify-center gap-1 my-2">
|
||
{units.map((u) => (
|
||
<div key={u.label} className="flex flex-col items-center">
|
||
<span className="min-w-[28px] h-7 px-1 bg-white border border-gray-200 rounded-sm text-[12px] font-bold text-[#e53935] flex items-center justify-center">
|
||
{u.val}
|
||
</span>
|
||
<span className="text-[8px] font-semibold text-[#f48fb1] uppercase tracking-wide mt-0.5">{u.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Stars({ rating }: { rating: number }) {
|
||
const filled = Math.round(rating || 5);
|
||
return (
|
||
<div className="flex justify-center gap-0.5 my-1.5" aria-label={`${filled} out of 5 stars`}>
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<span key={i} className={i < filled ? 'text-orange-400' : 'text-gray-300'}>
|
||
★
|
||
</span>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function YouMayAlsoLike({ products }: YouMayAlsoLikeProps) {
|
||
if (!products.length) return null;
|
||
|
||
return (
|
||
<section className="mt-4 mb-10">
|
||
<h3 className="text-[20px] font-semibold text-gray-800 mb-4 tracking-tight">You may also like...</h3>
|
||
<div className="overflow-x-auto rounded-md border border-gray-200 bg-white">
|
||
<div
|
||
className="grid min-w-[860px]"
|
||
style={{ gridTemplateColumns: `repeat(${Math.min(products.length, 5)}, minmax(168px, 1fr))` }}
|
||
>
|
||
{products.slice(0, 5).map((prod, index) => {
|
||
const img = getImageUrl(prod.images?.[0]?.image_url || prod.variants?.[0]?.images?.[0]?.image_url);
|
||
const off = discountPct(prod);
|
||
const pricing = priceLabel(prod);
|
||
const onSale = off > 0;
|
||
|
||
return (
|
||
<Link
|
||
key={prod.product_id}
|
||
href={`/products/${prod.slug}`}
|
||
className={`relative p-5 text-center hover:bg-[#fafafa] transition-colors ${
|
||
index > 0 ? 'border-l border-gray-200' : ''
|
||
}`}
|
||
>
|
||
{off > 0 && (
|
||
<span className="absolute top-3 right-3 bg-primary text-white text-[10px] font-bold px-1.5 py-0.5 rounded-sm">
|
||
-{off}%
|
||
</span>
|
||
)}
|
||
|
||
<div className="h-[140px] flex items-center justify-center mb-1">
|
||
<BlurHashImage
|
||
src={img}
|
||
alt={prod.name}
|
||
width={140}
|
||
height={140}
|
||
className="max-h-full max-w-full object-contain"
|
||
/>
|
||
</div>
|
||
|
||
{onSale ? <SaleCountdown productId={prod.product_id} /> : <div className="h-[42px]" />}
|
||
|
||
<p className="text-[13px] font-medium text-gray-800 leading-snug line-clamp-2 min-h-[36px]">
|
||
{prod.name}
|
||
</p>
|
||
<Stars rating={prod.rating || 5} />
|
||
<div className="text-[13px]">
|
||
{pricing.compare && (
|
||
<span className="text-gray-400 line-through mr-1.5 font-normal">{pricing.compare}</span>
|
||
)}
|
||
<span className="font-bold text-gray-900">{pricing.current}</span>
|
||
</div>
|
||
</Link>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|