AbiirSkincare/Hydrogen/web/app/components/AbiirProductListItem.tsx

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