195 lines
7.7 KiB
TypeScript
195 lines
7.7 KiB
TypeScript
/**
|
|
* @page Wishlist Page (`app/wishlist/page.tsx`)
|
|
* @purpose TechShop-style wishlist list with row layout and View Products CTA linking to reusable PDP.
|
|
*/
|
|
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { useEffect, useState } from 'react';
|
|
import { Heart, X } from 'lucide-react';
|
|
import { AnnouncementBar } from '@/layout/AnnouncementBar';
|
|
import { Header } from '@/layout/Header';
|
|
import { Navbar } from '@/layout/Navbar';
|
|
import { Footer } from '@/layout/Footer';
|
|
import { StickyHeaderSpacer } from '@/components/StickyHeaderSpacer';
|
|
import { FloatingButtons } from '@/components/FloatingButtons';
|
|
import { useCartStore } from '@/store/cartStore';
|
|
import { ProductResponse } from '@/services/api/catalogService';
|
|
import { formatCatalogPriceLabel, getImageUrl } from '@/lib/utils';
|
|
|
|
function getProductImage(product: ProductResponse): string {
|
|
const fromProduct = product.images?.[0]?.image_url;
|
|
const fromVariant = product.variants?.[0]?.images?.[0]?.image_url;
|
|
return getImageUrl(fromProduct || fromVariant || '');
|
|
}
|
|
|
|
function getPriceLabel(product: ProductResponse): string {
|
|
return formatCatalogPriceLabel(product);
|
|
}
|
|
|
|
function getActionLabel(product: ProductResponse): string {
|
|
const variantCount = product.variants?.length || 0;
|
|
return variantCount > 1 ? 'Select Options' : 'View Products';
|
|
}
|
|
|
|
function formatAddedDate(product: ProductResponse): string {
|
|
const raw = product.created_at;
|
|
if (!raw) {
|
|
return new Date().toLocaleDateString('en-US', {
|
|
month: 'long',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
});
|
|
}
|
|
|
|
const date = new Date(raw);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return new Date().toLocaleDateString('en-US', {
|
|
month: 'long',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
});
|
|
}
|
|
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'long',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
});
|
|
}
|
|
|
|
export default function WishlistPage() {
|
|
const wishlist = useCartStore((state) => state.wishlist);
|
|
const toggleWishlist = useCartStore((state) => state.toggleWishlist);
|
|
const hasHydrated = useCartStore((state) => state._hasHydrated);
|
|
const [ready, setReady] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (hasHydrated) {
|
|
setReady(true);
|
|
return;
|
|
}
|
|
// Fallback if persist rehydrate event is slow
|
|
const t = setTimeout(() => setReady(true), 250);
|
|
return () => clearTimeout(t);
|
|
}, [hasHydrated]);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#F8F9FB] text-[#1E293B] flex flex-col font-sans">
|
|
<AnnouncementBar />
|
|
<Header />
|
|
<StickyHeaderSpacer />
|
|
<Navbar />
|
|
|
|
<main className="flex-grow pb-24 antialiased">
|
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{!ready ? (
|
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
|
{[1, 2, 3].map((i) => (
|
|
<div
|
|
key={i}
|
|
className={`flex items-center gap-5 px-5 py-5 animate-pulse ${
|
|
i % 2 === 0 ? 'bg-[#f8f9fb]' : 'bg-white'
|
|
}`}
|
|
>
|
|
<div className="w-[90px] h-[90px] rounded-md bg-gray-100 shrink-0" />
|
|
<div className="flex-1 space-y-2">
|
|
<div className="h-4 bg-gray-100 rounded w-2/3" />
|
|
<div className="h-3 bg-gray-100 rounded w-1/4" />
|
|
<div className="h-3 bg-gray-100 rounded w-1/5" />
|
|
</div>
|
|
<div className="h-10 w-32 bg-gray-100 rounded-md" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : wishlist.length === 0 ? (
|
|
<div className="bg-white border border-gray-200 rounded-lg p-12 text-center max-w-lg mx-auto shadow-xs">
|
|
<Heart className="w-14 h-14 text-gray-300 mx-auto mb-4 stroke-1" />
|
|
<h2 className="text-lg font-bold text-gray-900 font-outfit">Your Wishlist is Empty</h2>
|
|
<p className="text-sm text-gray-500 mt-2 mb-6">
|
|
Explore products and click the heart icon to save items for later.
|
|
</p>
|
|
<Link
|
|
href="/shop"
|
|
className="inline-flex bg-primary hover:bg-primary-hover text-white text-sm font-semibold px-6 py-3 rounded-md transition-colors"
|
|
>
|
|
Browse Products
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-xs">
|
|
{wishlist.map((product, index) => {
|
|
const imageUrl = getProductImage(product);
|
|
const priceLabel = getPriceLabel(product);
|
|
const actionLabel = getActionLabel(product);
|
|
const addedDate = formatAddedDate(product);
|
|
const href = `/products/${product.slug || product.product_id}`;
|
|
|
|
return (
|
|
<div
|
|
key={product.product_id}
|
|
className={`relative flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6 px-4 sm:px-6 py-5 border-b border-gray-100 last:border-b-0 ${
|
|
index % 2 === 1 ? 'bg-[#f8f9fb]' : 'bg-white'
|
|
}`}
|
|
>
|
|
{/* Remove from wishlist */}
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleWishlist(product)}
|
|
aria-label={`Remove ${product.name} from wishlist`}
|
|
className="absolute top-3 right-3 sm:static sm:order-4 p-1.5 text-gray-300 hover:text-[#e4382f] transition-colors cursor-pointer shrink-0"
|
|
>
|
|
<X size={16} strokeWidth={2.5} />
|
|
</button>
|
|
|
|
{/* Image */}
|
|
<Link
|
|
href={href}
|
|
className="w-[88px] h-[88px] sm:w-[100px] sm:h-[100px] rounded-md border border-gray-150 bg-white p-2 flex items-center justify-center shrink-0 overflow-hidden"
|
|
>
|
|
{imageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={imageUrl}
|
|
alt={product.name}
|
|
className="w-full h-full object-contain"
|
|
/>
|
|
) : (
|
|
<span className="text-[10px] text-gray-400 font-medium">No Image</span>
|
|
)}
|
|
</Link>
|
|
|
|
{/* Details */}
|
|
<div className="flex-1 min-w-0 text-left pr-8 sm:pr-0">
|
|
<Link
|
|
href={href}
|
|
className="block text-[15px] sm:text-[16px] font-semibold text-primary hover:underline leading-snug"
|
|
>
|
|
{product.name}
|
|
</Link>
|
|
<p className="mt-1.5 text-[15px] font-bold text-gray-900">{priceLabel}</p>
|
|
<p className="mt-1 text-[12px] text-gray-400 font-medium">{addedDate}</p>
|
|
</div>
|
|
|
|
{/* CTA — opens reusable product detail page */}
|
|
<div className="sm:ml-auto shrink-0">
|
|
<Link
|
|
href={href}
|
|
className="inline-flex items-center justify-center min-w-[140px] bg-primary hover:bg-primary-hover text-white text-[13px] font-semibold px-5 py-2.5 rounded-md transition-colors shadow-xs"
|
|
>
|
|
{actionLabel}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
<Footer />
|
|
<FloatingButtons />
|
|
</div>
|
|
);
|
|
}
|