378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
import {useCallback, type MouseEvent} from 'react';
|
|
import {Link} from 'react-router';
|
|
import {CartForm, Image, Money} from '@shopify/hydrogen';
|
|
import {motion} from 'motion/react';
|
|
import {Heart, ShoppingBag, Star} from 'lucide-react';
|
|
import {useAbiirProductWishlist} from '~/hooks/useAbiirProductWishlist';
|
|
import {useVariantUrl} from '~/lib/variants';
|
|
import type {AbiirProductListItemData} from '~/lib/featuredProducts';
|
|
|
|
const gold = '#b39763';
|
|
const badgeGold = '#b8a042';
|
|
const burgundy = '#5d0b24';
|
|
const imageBg = '#ede8e0';
|
|
const chipBg = '#f5f1e8';
|
|
const muted = '#5a5550';
|
|
const charcoal = '#1a1a18';
|
|
const cream = '#f8f4ef';
|
|
const overlayBurgundy = 'rgba(93, 11, 36, 0.95)';
|
|
|
|
function productBadges(
|
|
item: AbiirProductListItemData,
|
|
): {label: string; variant: 'burgundy' | 'gold'}[] {
|
|
if (item.hideBadge) return [];
|
|
const out: {label: string; variant: 'burgundy' | 'gold'}[] = [];
|
|
const lower = item.tags.map((t) => t.toLowerCase());
|
|
if (lower.some((t) => t.includes('bestseller'))) {
|
|
out.push({label: 'Bestseller', variant: 'burgundy'});
|
|
}
|
|
if (
|
|
item.compareAtPrice &&
|
|
parseFloat(item.compareAtPrice.amount) > parseFloat(item.price.amount)
|
|
) {
|
|
out.push({label: 'Sale', variant: 'burgundy'});
|
|
}
|
|
if (lower.some((t) => t === 'new' || t.includes('new'))) {
|
|
out.push({label: 'New', variant: 'gold'});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export type AbiirProductListItemProps = {
|
|
item: AbiirProductListItemData;
|
|
index?: number;
|
|
/** Heart on the image; default `true`. */
|
|
showWishlist?: boolean;
|
|
/** Replaces the default built-in wishlist click handler if set. */
|
|
onToggleWishlist?: (e: MouseEvent<HTMLButtonElement>) => void;
|
|
/** Image hover strip + Add to Bag */
|
|
showQuickAdd?: boolean;
|
|
onQuickAdd?: (e: MouseEvent<HTMLButtonElement>) => void;
|
|
/**
|
|
* `homepageFeatured` — homepage “Most Loved” strip only: no corner badges, review row, or benefit chips
|
|
* (CMS can carry demo ratings/tags that do not match the PDP).
|
|
*/
|
|
variant?: 'default' | 'homepageFeatured';
|
|
};
|
|
|
|
/**
|
|
* Pixel-matched product card from `React/src/app/components/FeaturedProductCard.tsx`.
|
|
* Uses Shopify `Image` + `Money`; links to `/products/:handle`.
|
|
*/
|
|
export function AbiirProductListItem({
|
|
item,
|
|
index = 0,
|
|
showWishlist = true,
|
|
onToggleWishlist: onToggleWishlistProp,
|
|
showQuickAdd = true,
|
|
onQuickAdd,
|
|
variant = 'default',
|
|
}: AbiirProductListItemProps) {
|
|
const {wishlisted, toggleWishlist} = useAbiirProductWishlist(
|
|
item.handle?.trim() ?? '',
|
|
);
|
|
const onWishlistButtonClick = useCallback(
|
|
(e: MouseEvent<HTMLButtonElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (onToggleWishlistProp) {
|
|
onToggleWishlistProp(e);
|
|
return;
|
|
}
|
|
toggleWishlist();
|
|
},
|
|
[onToggleWishlistProp, toggleWishlist],
|
|
);
|
|
const variantUrl = useVariantUrl(item.handle);
|
|
const badges = productBadges(item);
|
|
const isHomeFeatured = variant === 'homepageFeatured';
|
|
const showImageBadges = !isHomeFeatured && badges.length > 0;
|
|
const hasReviewData = item.rating > 0 && item.reviewCount > 0;
|
|
const showReviewsRow = !isHomeFeatured && hasReviewData;
|
|
const showBenefitChips = !isHomeFeatured && item.benefits.length > 0;
|
|
const starCount =
|
|
item.rating >= 4.75
|
|
? 5
|
|
: Math.max(0, Math.min(5, Math.round(item.rating)));
|
|
const averageLabel = (() => {
|
|
if (!Number.isFinite(item.rating) || item.rating <= 0) return '';
|
|
const x = Math.round(item.rating * 10) / 10;
|
|
return Number.isInteger(x) ? String(x) : x.toFixed(1);
|
|
})();
|
|
const countLabel = item.reviewCount === 1 ? '1 review' : `${item.reviewCount} reviews`;
|
|
|
|
const imageData = item.imageForCard;
|
|
const canQuickAdd = Boolean(item.firstVariantId) && item.availableForSale !== false;
|
|
|
|
const onQuickAddButtonClick = useCallback(
|
|
(e: MouseEvent<HTMLButtonElement>) => {
|
|
// Prevent the surrounding product `Link` from navigating.
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onQuickAdd?.(e);
|
|
},
|
|
[onQuickAdd],
|
|
);
|
|
|
|
return (
|
|
<motion.div
|
|
initial={false}
|
|
whileInView={{opacity: 1, y: 0}}
|
|
viewport={{once: true, margin: '-40px'}}
|
|
transition={{duration: 0.55, delay: index * 0.07}}
|
|
className="w-full"
|
|
>
|
|
<div className="group w-full">
|
|
<div
|
|
className="relative w-full overflow-hidden"
|
|
style={{
|
|
background: imageBg,
|
|
aspectRatio: '299 / 398.664',
|
|
}}
|
|
>
|
|
<Link
|
|
to={variantUrl}
|
|
prefetch="intent"
|
|
tabIndex={-1}
|
|
className="absolute inset-0 z-0 block cursor-pointer no-underline focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5d0b24] focus-visible:ring-offset-2"
|
|
aria-label={`View ${item.displayTitle}`}
|
|
>
|
|
{imageData ? (
|
|
<Image
|
|
alt=""
|
|
className="pointer-events-none absolute inset-0 size-full object-cover"
|
|
data={imageData}
|
|
loading={index < 2 ? 'eager' : 'lazy'}
|
|
sizes="(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw"
|
|
/>
|
|
) : null}
|
|
</Link>
|
|
|
|
{showImageBadges ? (
|
|
<div
|
|
className="pointer-events-none absolute z-[1] flex max-w-[calc(100%-34px)] flex-wrap items-center gap-2"
|
|
style={{left: '17px', top: '17px'}}
|
|
>
|
|
{badges.map((b) => (
|
|
<span
|
|
key={b.label}
|
|
className="font-['Raleway',sans-serif] text-[10.54px] font-semibold uppercase leading-[15.81px] tracking-[1.6864px]"
|
|
style={{
|
|
background: b.variant === 'gold' ? badgeGold : burgundy,
|
|
color: cream,
|
|
padding: '5.88px 12.75px',
|
|
}}
|
|
>
|
|
{b.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{showWishlist ? (
|
|
<button
|
|
type="button"
|
|
onClick={onWishlistButtonClick}
|
|
className="absolute top-[17px] right-[17px] z-[4] cursor-pointer p-2.5 transition-opacity duration-200 hover:opacity-80"
|
|
style={{
|
|
background: 'rgba(250, 248, 243, 0.92)',
|
|
backdropFilter: 'blur(4px)',
|
|
color: wishlisted ? burgundy : charcoal,
|
|
border: 'none',
|
|
}}
|
|
aria-label={
|
|
wishlisted ? 'Remove from wishlist' : 'Add to wishlist'
|
|
}
|
|
>
|
|
<Heart
|
|
size={15}
|
|
strokeWidth={1.5}
|
|
fill={wishlisted ? burgundy : 'none'}
|
|
/>
|
|
</button>
|
|
) : null}
|
|
|
|
{showQuickAdd ? (
|
|
<div
|
|
className="absolute inset-x-0 bottom-0 z-[2] translate-y-full transition-transform duration-300 ease-out group-hover:translate-y-0 group-focus-within:translate-y-0"
|
|
style={{
|
|
background: overlayBurgundy,
|
|
padding: '17px',
|
|
}}
|
|
>
|
|
{onQuickAdd ? (
|
|
<button
|
|
type="button"
|
|
onClick={onQuickAddButtonClick}
|
|
className="relative flex h-[48.109px] w-full cursor-pointer items-center justify-center gap-2 border-none transition-opacity duration-200 hover:opacity-92"
|
|
style={{
|
|
background: burgundy,
|
|
fontFamily: "'Raleway', sans-serif",
|
|
fontSize: '12.24px',
|
|
fontWeight: 600,
|
|
letterSpacing: '2.2032px',
|
|
color: cream,
|
|
textTransform: 'uppercase',
|
|
}}
|
|
>
|
|
<ShoppingBag size={14} strokeWidth={1.75} aria-hidden />
|
|
Add to Cart
|
|
</button>
|
|
) : item.firstVariantId ? (
|
|
<CartForm
|
|
route="/cart"
|
|
action={CartForm.ACTIONS.LinesAdd}
|
|
inputs={{
|
|
lines: [{merchandiseId: item.firstVariantId, quantity: 1}],
|
|
}}
|
|
>
|
|
{(fetcher) => (
|
|
<button
|
|
type="submit"
|
|
disabled={!canQuickAdd || fetcher.state !== 'idle'}
|
|
className="relative flex h-[48.109px] w-full cursor-pointer items-center justify-center gap-2 border-none transition-opacity duration-200 hover:opacity-92 disabled:cursor-not-allowed disabled:opacity-60"
|
|
style={{
|
|
background: burgundy,
|
|
fontFamily: "'Raleway', sans-serif",
|
|
fontSize: '12.24px',
|
|
fontWeight: 600,
|
|
letterSpacing: '2.2032px',
|
|
color: cream,
|
|
textTransform: 'uppercase',
|
|
cursor: canQuickAdd ? 'pointer' : 'not-allowed',
|
|
}}
|
|
>
|
|
<ShoppingBag size={14} strokeWidth={1.75} aria-hidden />
|
|
Add to Cart
|
|
</button>
|
|
)}
|
|
</CartForm>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
disabled
|
|
className="relative flex h-[48.109px] w-full cursor-not-allowed items-center justify-center gap-2 border-none opacity-60"
|
|
style={{
|
|
background: burgundy,
|
|
fontFamily: "'Raleway', sans-serif",
|
|
fontSize: '12.24px',
|
|
fontWeight: 600,
|
|
letterSpacing: '2.2032px',
|
|
color: cream,
|
|
textTransform: 'uppercase',
|
|
}}
|
|
>
|
|
<ShoppingBag size={14} strokeWidth={1.75} aria-hidden />
|
|
Add to Cart
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<Link
|
|
to={variantUrl}
|
|
prefetch="intent"
|
|
className="mt-0 block cursor-pointer pt-[21px] no-underline"
|
|
>
|
|
<div className="w-full">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
<p
|
|
className="m-0 font-['Raleway',sans-serif] text-[11.05px] font-medium leading-[19.89px] tracking-[1.6575px] not-italic normal-case"
|
|
style={{color: muted}}
|
|
>
|
|
{item.subtitle}
|
|
</p>
|
|
<h3
|
|
className="m-0 font-['Cormorant_Garamond',serif] text-[24px] font-medium leading-[24.48px] tracking-[-0.204px] not-italic"
|
|
style={{color: charcoal}}
|
|
>
|
|
{item.displayTitle}
|
|
</h3>
|
|
</div>
|
|
<div className="shrink-0 pt-1 text-right">
|
|
{item.compareAtPrice ? (
|
|
<div
|
|
className="m-0 font-['Raleway',sans-serif] text-[13.94px] font-light leading-[13.94px] line-through not-italic"
|
|
style={{color: muted}}
|
|
>
|
|
<Money data={item.compareAtPrice} />
|
|
</div>
|
|
) : null}
|
|
<div
|
|
className="m-0 font-['Raleway',sans-serif] text-[20px] font-medium leading-[1.2] not-italic"
|
|
style={{
|
|
color: item.compareAtPrice ? burgundy : charcoal,
|
|
marginTop: item.compareAtPrice ? '2px' : 0,
|
|
}}
|
|
>
|
|
<Money data={item.price} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{showReviewsRow ? (
|
|
<div
|
|
className="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1"
|
|
role="img"
|
|
aria-label={
|
|
averageLabel
|
|
? `Rated ${averageLabel} out of 5, ${countLabel}`
|
|
: countLabel
|
|
}
|
|
>
|
|
<div className="flex items-center gap-[2.125px]">
|
|
{Array.from({length: 5}).map((_, i) => (
|
|
<Star
|
|
key={i}
|
|
size={11}
|
|
strokeWidth={0}
|
|
fill={i < starCount ? gold : '#D4C5B0'}
|
|
aria-hidden
|
|
/>
|
|
))}
|
|
</div>
|
|
{averageLabel ? (
|
|
<span
|
|
className="font-['Raleway',sans-serif] text-[12.5px] font-medium leading-[1.1] not-italic"
|
|
style={{color: charcoal}}
|
|
aria-hidden
|
|
>
|
|
{averageLabel}
|
|
</span>
|
|
) : null}
|
|
<span
|
|
className="font-['Raleway',sans-serif] text-[12.24px] font-normal leading-[18.36px] not-italic"
|
|
style={{color: muted}}
|
|
aria-hidden
|
|
>
|
|
({countLabel})
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
|
|
{showBenefitChips ? (
|
|
<div className="mt-4 flex flex-wrap gap-[6px]">
|
|
{item.benefits.map((benefit) => (
|
|
<span
|
|
key={benefit}
|
|
className="inline-block font-['Raleway',sans-serif] text-[10.2px] font-medium uppercase leading-[15.3px] tracking-[1.02px] not-italic"
|
|
style={{
|
|
background: chipBg,
|
|
color: muted,
|
|
padding: '4.75px 10.63px',
|
|
}}
|
|
>
|
|
{benefit}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|