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

261 lines
7.9 KiB
TypeScript

import {useCallback, useEffect, useMemo, useState} from 'react';
import {Link} from 'react-router';
import {motion} from 'motion/react';
import {AbiirProductListItem} from '~/components/AbiirProductListItem';
import type {
AbiirProductListItemData,
FeaturedProductsCms,
} from '~/lib/featuredProducts';
import {
MOCK_FEATURED_PRODUCT_ROWS,
MOCK_FEATURED_PRODUCTS_CMS,
} from '~/lib/featuredProducts';
import {PAGE_TITLE_TYPOGRAPHY_CLASS} from '~/lib/pageTitle';
import {useSwipeNavigate} from '~/lib/useSwipeNavigate';
/** Desktop featured grid matches `ABIIR_PRODUCT_LIST_GRID_CLASS` at `lg:` only (`abiirProductListGrid.ts`). */
const FEATURED_DESKTOP_GRID_CLASS =
'hidden w-full max-w-full lg:grid lg:grid-cols-4 lg:gap-x-[34px] lg:gap-y-10';
/**
* GROQ: `home` singleton → featured products (Most Loved) module.
* Use with `context.sanity.loadQuery(FEATURED_PRODUCTS_GROQ)` or `useQuery` in the browser.
*/
export const FEATURED_PRODUCTS_GROQ = `*[_type == "home" && _id == "home"][0].featuredProducts{
eyebrow,
title,
filterPills[]{label, href},
viewAllLabel,
viewAllHref,
items[]{
productHandle,
hideBadge,
cardTitle,
subtitle,
benefitChips,
rating,
reviewCount
}
}` as const;
export type {FeaturedProductsCms, AbiirProductListItemData};
export {MOCK_FEATURED_PRODUCTS_CMS, MOCK_FEATURED_PRODUCT_ROWS};
const sectionBg = '#f8f4ef';
const burgundy = '#5d0b24';
const charcoal = '#1a1a18';
/** Mobile / tablet featured strip: auto-advance interval (desktop keeps grid). */
const FEATURED_MOBILE_SLIDE_MS = 4000;
function FeaturedProductsMobileSlider({rows}: {rows: AbiirProductListItemData[]}) {
const [active, setActive] = useState(0);
const rowsKey = useMemo(
() => rows.map((r) => r.handle).join('\0'),
[rows],
);
useEffect(() => {
setActive(0);
}, [rowsKey]);
useEffect(() => {
if (rows.length <= 1) return;
if (typeof window === 'undefined') return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const id = window.setInterval(() => {
setActive((i) => (i + 1) % rows.length);
}, FEATURED_MOBILE_SLIDE_MS);
return () => window.clearInterval(id);
}, [rows.length]);
const goPrev = useCallback(() => {
if (rows.length <= 1) return;
setActive((i) => (i - 1 + rows.length) % rows.length);
}, [rows.length]);
const goNext = useCallback(() => {
if (rows.length <= 1) return;
setActive((i) => (i + 1) % rows.length);
}, [rows.length]);
const swipe = useSwipeNavigate(goPrev, goNext);
return (
<div
className="w-full overflow-hidden lg:hidden"
aria-roledescription="carousel"
aria-label="Featured products"
{...(rows.length > 1 ? swipe : {})}
>
<div
className="flex transition-transform duration-500 ease-out motion-reduce:transition-none"
style={{transform: `translateX(-${active * 100}%)`}}
>
{rows.map((item, index) => (
<div
key={`${item.handle}-${index}`}
className={`w-full shrink-0 basis-full px-0.5 ${
index !== active ? 'pointer-events-none' : ''
}`}
aria-hidden={index !== active}
>
<AbiirProductListItem
index={index}
item={item}
variant="homepageFeatured"
/>
</div>
))}
</div>
<div
className="mt-6 flex justify-center gap-1.5"
aria-hidden
>
{rows.map((_, i) => (
<span
key={i}
role="presentation"
className="h-1.5 rounded-full transition-all duration-300"
style={{
width: i === active ? 22 : 6,
background: i === active ? burgundy : 'rgba(26,26,24,0.22)',
}}
/>
))}
</div>
</div>
);
}
function isExternalHref(href: string) {
return /^https?:\/\//i.test(href);
}
function FilterLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const className =
'inline-flex min-h-[44.07px] cursor-pointer items-center justify-center border border-[rgba(26,26,24,0.2)] px-[26.5px] py-[13.75px] font-["Jost",sans-serif] text-[12px] font-medium uppercase leading-[16.575px] tracking-[1.326px] text-[#1a1a18] no-underline transition-opacity duration-200 box-border hover:opacity-70';
if (isExternalHref(href)) {
return (
<a href={href} className={className}>
{children}
</a>
);
}
return (
<Link to={href} className={className}>
{children}
</Link>
);
}
function ViewAllLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const className =
'inline-flex min-h-[51.844px] cursor-pointer items-center justify-center bg-[#b39763] px-[34px] py-[17px] font-["Raleway",sans-serif] text-[14px] font-bold uppercase leading-[19.89px] tracking-[2.3868px] text-white no-underline transition-opacity duration-200 box-border hover:opacity-90';
if (isExternalHref(href)) {
return (
<a href={href} className={className}>
{children}
</a>
);
}
return (
<Link to={href} className={className}>
{children}
</Link>
);
}
export type FeaturedProductsProps = {
cms: FeaturedProductsCms;
/** Merged Shopify + Sanity rows (loader) or mock rows for static preview. */
rows: AbiirProductListItemData[];
};
/**
* Homepage “Most Loved” strip — `React/src/app/components/FeaturedProducts.tsx` (Tailwind).
* Placed below `BrandValues` on the index route.
*/
export function FeaturedProducts({cms, rows}: FeaturedProductsProps) {
const pills =
cms.filterPills.length > 0
? cms.filterPills
: MOCK_FEATURED_PRODUCTS_CMS.filterPills;
return (
<section className="relative w-full" style={{background: sectionBg}}>
<div className="mx-auto max-w-[1710px] px-6 pt-16 pb-20 sm:px-10 sm:pt-20 lg:px-16 lg:pb-28 lg:pt-[119px] xl:px-24 2xl:px-[206px]">
<div className="mb-5 flex flex-col gap-4 lg:mb-[68px] lg:flex-row lg:items-end lg:justify-between lg:gap-6">
<div className="max-w-[286px]">
<motion.p
initial={false}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5}}
className="m-0 mb-2 font-['Jost',sans-serif] text-[12px] font-medium uppercase leading-[16.575px] tracking-[2.7625px] not-italic"
style={{color: burgundy}}
>
{cms.eyebrow}
</motion.p>
<motion.h2
initial={false}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.6, delay: 0.05}}
className={`m-0 ${PAGE_TITLE_TYPOGRAPHY_CLASS}`}
style={{color: charcoal}}
>
{cms.title}
</motion.h2>
</div>
<nav
className="flex flex-wrap items-center justify-start gap-[13px] lg:justify-end"
aria-label="Product filters"
>
{pills.map((pill, i) => (
<FilterLink key={`${pill.href}-${i}`} href={pill.href}>
{pill.label}
</FilterLink>
))}
</nav>
</div>
<FeaturedProductsMobileSlider rows={rows} />
<div className={FEATURED_DESKTOP_GRID_CLASS}>
{rows.map((item, index) => (
<AbiirProductListItem
key={item.handle + String(index)}
index={index}
item={item}
variant="homepageFeatured"
/>
))}
</div>
<motion.div
initial={false}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.55, delay: 0.15}}
className="mt-14 flex justify-center lg:mt-20"
>
<ViewAllLink href={cms.viewAllHref}>{cms.viewAllLabel}</ViewAllLink>
</motion.div>
</div>
</section>
);
}