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

1440 lines
52 KiB
TypeScript

import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import {
type FetcherWithComponents,
Link,
useFetcher,
useNavigate,
useRevalidator,
} from 'react-router';
import {Image} from '@shopify/hydrogen';
import {CartForm, type OptimisticCartLineInput} from '@shopify/hydrogen';
import {type MappedProductOptions} from '@shopify/hydrogen';
import {AnimatePresence, motion} from 'motion/react';
import {
ArrowLeft,
Check,
ChevronDown,
ChevronUp,
Heart,
Minus,
Plus,
Share2,
ShoppingBag,
Sparkles,
Star,
} from 'lucide-react';
import {useAside} from '~/components/Aside';
import {AbiirProductListItem} from '~/components/AbiirProductListItem';
import {useAbiirProductWishlist} from '~/hooks/useAbiirProductWishlist';
import {PDP_DEFAULT_SHIPPING_RETURNS, type AbiirPdpCms} from '~/lib/abiirProductDetail';
import {MOCK_ABIIR_PRODUCT_DETAIL_CMS} from '~/lib/abiirPdpDefaults';
import pdpImageFallback from '~/assets/spring-glow-bundle.png';
import {ABIIR_PRODUCT_LIST_GRID_CLASS} from '~/lib/abiirProductListGrid';
import {useSwipeNavigate} from '~/lib/useSwipeNavigate';
import type {AbiirProductListItemData} from '~/lib/featuredProducts';
import type {Image as StorefrontImageType} from '@shopify/hydrogen/storefront-api-types';
import type {ProductFragment} from 'storefrontapi.generated';
import type {
PdpProductReview,
PdpProductReviewStats,
} from '~/lib/productReviewTypes';
type PdpMediaImage = {
__typename?: 'MediaImage';
id?: string | null;
image?: Pick<StorefrontImageType, 'id' | 'url' | 'altText' | 'width' | 'height'> | null;
};
type PdpVideo = {
__typename?: 'Video';
id?: string | null;
previewImage?: Pick<StorefrontImageType, 'id' | 'url' | 'altText' | 'width' | 'height'> | null;
sources?: Array<{
url?: string | null;
mimeType?: string | null;
format?: string | null;
height?: number | null;
width?: number | null;
} | null> | null;
};
type PdpMediaNode = PdpMediaImage | PdpVideo | {__typename?: string; id?: string | null};
type PdpGalleryItem =
| {
kind: 'image';
id: string;
image: Pick<StorefrontImageType, 'id' | 'url' | 'altText' | 'width' | 'height'>;
}
| {
kind: 'video';
id: string;
previewImage: Pick<StorefrontImageType, 'id' | 'url' | 'altText' | 'width' | 'height'> | null;
sources: Array<{url: string; mimeType?: string | null}>;
};
/**
* Storefront `Video.sources` often lists HLS (.m3u8) first. A plain `<video>` tag does not
* decode HLS in Chrome/Firefox (duration stays 0:00); Safari is fine. Prefer MP4/WebM.
*/
function inferVideoMimeTypeFromUrl(url: string): string | undefined {
const u = url.toLowerCase();
if (u.includes('.m3u8')) return 'application/vnd.apple.mpegurl';
if (u.includes('.mp4')) return 'video/mp4';
if (u.includes('.webm')) return 'video/webm';
return undefined;
}
function prioritizeHtml5VideoSources(
raw: PdpVideo['sources'],
): Array<{url: string; mimeType?: string | null}> {
const filtered =
(raw ?? [])
.filter((s): s is NonNullable<typeof s> => Boolean(s?.url))
.map((s) => {
const url = String(s.url);
const mimeType = s.mimeType?.trim() || inferVideoMimeTypeFromUrl(url);
return {url, mimeType: mimeType || undefined};
}) ?? [];
const rank = (s: {url: string; mimeType?: string | null | undefined}) => {
const m = (s.mimeType ?? '').toLowerCase();
const u = s.url.toLowerCase();
if (m.includes('mp4') || u.includes('.mp4')) return 0;
if (m.includes('webm') || u.includes('.webm')) return 1;
if (
m.includes('mpegurl') ||
m.includes('m3u8') ||
u.includes('.m3u8') ||
m === 'application/vnd.apple.mpegurl'
) {
return 100;
}
return 50;
};
return [...filtered].sort((a, b) => rank(a) - rank(b));
}
type PdpGalleryVideoItem = Extract<PdpGalleryItem, {kind: 'video'}>;
function PdpGalleryAutoplayVideo({item}: {item: PdpGalleryVideoItem}) {
const rootRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const sourcesKey = item.sources.map((s) => s.url).join('|');
useEffect(() => {
const root = rootRef.current;
const video = videoRef.current;
if (!root || !video) return;
const tryPlayInView = () => {
if (
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
return;
}
video.muted = true;
const p = video.play();
if (p !== undefined) {
void p.catch(() => {});
}
};
const io = new IntersectionObserver(
(entries) => {
const e = entries[0];
if (!e) return;
if (e.isIntersecting) tryPlayInView();
else video.pause();
},
{root: null, rootMargin: '0px', threshold: 0.28},
);
io.observe(root);
return () => {
io.disconnect();
video.pause();
};
}, [sourcesKey]);
return (
<div ref={rootRef} className="absolute inset-0 h-full w-full">
<video
ref={videoRef}
className="pointer-events-auto relative z-[2] h-full min-h-0 w-full touch-manipulation object-cover"
controls
loop
muted
playsInline
preload="auto"
poster={item.previewImage?.url ?? undefined}
>
{item.sources.map((s) => (
<source
key={s.url}
src={s.url}
type={
s.mimeType && s.mimeType.trim().length > 0
? s.mimeType
: inferVideoMimeTypeFromUrl(s.url)
}
/>
))}
</video>
</div>
);
}
// ─── Sanity: GROQ (`product` document by Shopify handle) ──────────────────────
/**
* GROQ projection for the PDP editorial slice. Use with
* `context.sanity.query(buildProductDetailGroq(handle))` (via `loadProductDetailFromSanity`).
*/
export const PRODUCT_DETAIL_GROQ_PROJECTION = /* groq */ `{
abiirPdp {
subtitle,
shortDescription,
benefitChips,
keyIngredients[]{name, benefit},
howToUse,
ingredients,
shippingReturns,
rating,
reviewCount,
relatedProductHandles
}
}` as const;
export function buildProductDetailGroq(handle: string): string {
const safe = JSON.stringify(handle);
return `*[_type == "product" && store.slug.current == ${safe}][0]${PRODUCT_DETAIL_GROQ_PROJECTION}`;
}
export {MOCK_ABIIR_PRODUCT_DETAIL_CMS};
const creamBg = '#f8f4ef';
const charcoal = '#1a1a18';
const mutedMid = '#7a7068';
const accent = '#c17d5c';
const burgundy = '#5d0b24';
const burgundyCta = '#7a2340';
const stockGreen = '#8a9e85';
const chipBg = '#ede8e0';
const shopPay = '#5a31f5';
const starGold = '#b39763';
const badgeNew = '#b8a042';
function formatPrice(n: number): string {
if (Number.isInteger(n)) return String(n);
const s = n.toFixed(2);
return s.replace(/\.?0+$/, '');
}
function parseAmount(amount: string | undefined): number {
if (!amount) return 0;
const n = parseFloat(amount);
return Number.isFinite(n) ? n : 0;
}
export type ProductWithPdp = ProductFragment & {
tags: string[];
images: {
nodes: Array<
Pick<StorefrontImageType, 'id' | 'url' | 'altText' | 'width' | 'height'>
>;
};
featuredImage?: StorefrontImageType | null;
media?: {nodes?: Array<PdpMediaNode | null> | null} | null;
};
export type AbiirProductDetailPageProps = {
product: ProductWithPdp;
cms: Partial<AbiirPdpCms> | null;
relatedItems: AbiirProductListItemData[];
mockRelatedFallback?: boolean;
selectedVariant: ProductFragment['selectedOrFirstAvailableVariant'];
productOptions: MappedProductOptions[];
/** UGC from Sanity; when present with count &gt; 0, overrides CMS mock star line */
productReviews?: PdpProductReview[];
productReviewStats?: PdpProductReviewStats | null;
isLoggedIn?: boolean;
};
/** Shopify's placeholder option for single-variant products — hide from PDP UI. */
function isDefaultTitlePlaceholder(
optionName: string | undefined,
optionValue: string | undefined,
): boolean {
return (
(optionName ?? '').trim().toLowerCase() === 'title' &&
(optionValue ?? '').trim().toLowerCase() === 'default title'
);
}
function pdpImageBadges(
tags: string[],
hasCompare: boolean,
): {label: string; bg: string}[] {
const lower = tags.map((t) => t.toLowerCase());
const out: {label: string; bg: string}[] = [];
if (lower.some((t) => t.includes('bestseller'))) {
out.push({label: 'Bestseller', bg: charcoal});
}
if (hasCompare) {
out.push({label: 'Sale', bg: burgundy});
}
if (lower.some((t) => t === 'new' || t.includes('new'))) {
out.push({label: 'New', bg: badgeNew});
}
return out;
}
function mergePdp(
cms: Partial<AbiirPdpCms> | null,
mock: AbiirPdpCms,
): AbiirPdpCms {
if (!cms || Object.keys(cms).length === 0) return mock;
return {
...mock,
...cms,
benefitChips: cms.benefitChips?.length ? cms.benefitChips : mock.benefitChips,
keyIngredients: cms.keyIngredients?.length
? cms.keyIngredients
: mock.keyIngredients,
ingredients: cms.ingredients?.length ? cms.ingredients : mock.ingredients,
relatedProductHandles: cms.relatedProductHandles?.length
? cms.relatedProductHandles
: mock.relatedProductHandles,
};
}
function PdpOptionControls({
productOptions,
}: {
productOptions: MappedProductOptions[];
}) {
const navigate = useNavigate();
return (
<>
{productOptions.map((option) => {
if (option.optionValues.length <= 1) return null;
if (
option.name.trim().toLowerCase() === 'title' &&
option.optionValues.every(
(v) => v.name.trim().toLowerCase() === 'default title',
)
) {
return null;
}
return (
<div
className="flex flex-col gap-3"
key={option.name}
>
<p className="m-0 font-['Raleway',sans-serif] text-[11.05px] font-semibold uppercase leading-[16.575px] tracking-[1.326px] text-[#1a1a18]">
{option.name}
</p>
<div className="flex flex-wrap gap-2">
{option.optionValues.map((value) => {
const {name, handle, variantUriQuery, selected, available, exists, isDifferentProduct, swatch} = value;
const click = () => {
if (!selected && exists) {
void navigate(`?${variantUriQuery}`, {
replace: true,
preventScrollReset: true,
});
}
};
const baseClass =
'min-h-[40px] min-w-[40px] border border-[rgba(26,26,24,0.15)] bg-transparent px-3 font-["Raleway",sans-serif] text-[13.94px] font-medium text-[#1a1a18] transition-opacity';
if (isDifferentProduct) {
return (
<Link
className={`${baseClass} inline-flex items-center justify-center no-underline ${selected ? 'ring-2 ring-[#c17d5c] ring-offset-1' : ''} ${available ? 'opacity-100' : 'opacity-30'}`}
key={option.name + name}
prefetch="intent"
preventScrollReset
replace
to={`/products/${handle}?${variantUriQuery}`}
>
{swatch?.color || swatch?.image?.previewImage?.url ? (
<span
aria-label={name}
className="h-5 w-5 rounded-full"
style={{
background:
swatch?.color || 'transparent',
backgroundImage: swatch?.image?.previewImage?.url
? `url(${swatch.image.previewImage.url})`
: undefined,
backgroundSize: 'cover',
}}
/>
) : (
name
)}
</Link>
);
}
return (
<button
className={`${baseClass} ${selected ? 'ring-2 ring-[#c17d5c] ring-offset-1' : ''} ${available ? 'cursor-pointer opacity-100' : 'cursor-not-allowed opacity-30'}`}
disabled={!exists}
key={option.name + name}
onClick={click}
type="button"
>
{name}
</button>
);
})}
</div>
</div>
);
})}
</>
);
}
type ViewProps = Omit<AbiirProductDetailPageProps, 'cms'> & {
pdp: AbiirPdpCms;
wishlisted: boolean;
toggleWishlist: () => void;
onOpenCart: () => void;
quantity: number;
setQuantity: (n: number | ((p: number) => number)) => void;
activeImage: number;
setActiveImage: Dispatch<SetStateAction<number>>;
openAccordion: string | null;
setOpenAccordion: (s: string | null) => void;
addedToCart: boolean;
setAddedToCart: (v: boolean) => void;
displayRating: number | null;
displayReviewCount: number | null;
};
/**
* Product detail (PDP) — 1:1 with `React/src/app/pages/ProductDetail.tsx` (Figma 123).
* Pass `selectedVariant` / `productOptions` from the route (`useOptimisticVariant`, `getProductOptions`).
*/
export function AbiirProductDetailPage({
product,
cms,
relatedItems,
mockRelatedFallback = false,
selectedVariant,
productOptions,
productReviews = [],
productReviewStats: _productReviewStats = null,
isLoggedIn = false,
}: AbiirProductDetailPageProps) {
const pdp = mergePdp(cms, MOCK_ABIIR_PRODUCT_DETAIL_CMS);
void _productReviewStats;
const displayRating =
typeof pdp.rating === 'number' && pdp.rating > 0 ? pdp.rating : null;
const displayReviewCount =
typeof pdp.reviewCount === 'number' && pdp.reviewCount > 0
? pdp.reviewCount
: null;
const {open: openCartAside} = useAside();
const {wishlisted, toggleWishlist} = useAbiirProductWishlist(
product.handle ?? '',
);
const [qty, setQty] = useState(1);
const [activeImage, setActiveImage] = useState(0);
const [openAccordion, setOpenAccordion] = useState<string | null>(null);
const [addedToCart, setAddedToCart] = useState(false);
return (
<AbiirProductDetailView
addedToCart={addedToCart}
activeImage={activeImage}
displayRating={displayRating}
displayReviewCount={displayReviewCount}
isLoggedIn={isLoggedIn}
mockRelatedFallback={mockRelatedFallback}
onOpenCart={() => openCartAside('cart')}
openAccordion={openAccordion}
pdp={pdp}
product={product}
productOptions={productOptions}
productReviews={productReviews}
quantity={qty}
relatedItems={relatedItems}
selectedVariant={selectedVariant}
setActiveImage={setActiveImage}
setAddedToCart={setAddedToCart}
setOpenAccordion={setOpenAccordion}
setQuantity={setQty}
toggleWishlist={toggleWishlist}
wishlisted={wishlisted}
/>
);
}
export function AbiirProductDetailView(
props: ViewProps,
) {
const {
product,
pdp,
relatedItems,
mockRelatedFallback = false,
selectedVariant,
productOptions,
productReviews = [],
isLoggedIn = false,
wishlisted,
toggleWishlist,
onOpenCart,
quantity,
setQuantity,
activeImage,
setActiveImage,
openAccordion,
setOpenAccordion,
addedToCart,
setAddedToCart,
displayRating,
displayReviewCount,
} = props;
const priceAmount = parseAmount(selectedVariant?.price?.amount);
const compareAmount = parseAmount(selectedVariant?.compareAtPrice?.amount);
const hasCompare = compareAmount > priceAmount;
const showSalePrice = hasCompare;
const badges = pdpImageBadges(product.tags, hasCompare);
const mediaNodes = (product.media?.nodes ?? []).filter(Boolean) as PdpMediaNode[];
const mappedMedia: PdpGalleryItem[] = [];
for (const n of mediaNodes) {
if (n.__typename === 'MediaImage') {
const img = (n as PdpMediaImage).image;
if (img?.url) {
mappedMedia.push({
kind: 'image',
id: String((n as PdpMediaImage).id ?? img.id ?? img.url),
image: img,
});
}
continue;
}
if (n.__typename === 'Video') {
const v = n as PdpVideo;
const sources = prioritizeHtml5VideoSources(v.sources);
if (sources.length) {
mappedMedia.push({
kind: 'video',
id: String(v.id ?? sources[0]!.url),
previewImage: v.previewImage ?? null,
sources,
});
}
}
}
const imageNodes = product.images?.nodes?.length
? product.images.nodes
: product.featuredImage
? [product.featuredImage]
: selectedVariant?.image
? [selectedVariant.image]
: [];
const fallbackImages =
imageNodes.length > 0
? imageNodes
: mockRelatedFallback
? ([
{
id: 'mock-1',
url: pdpImageFallback,
altText: product.title,
width: 800,
height: 1000,
},
] as ViewProps['product']['images']['nodes'])
: [];
const gallery: PdpGalleryItem[] =
mappedMedia.length > 0
? mappedMedia
: fallbackImages.map((img) => ({
kind: 'image',
id: String(img.id ?? img.url),
image: img,
}));
const starCount =
displayRating == null
? 0
: displayRating >= 4.75
? 5
: Math.max(0, Math.min(5, Math.round(displayRating)));
const ingredientRows: {name: string; benefit: string}[][] = [];
for (let i = 0; i < pdp.keyIngredients.length; i += 2) {
ingredientRows.push(pdp.keyIngredients.slice(i, i + 2));
}
const installmentEach = ((priceAmount * quantity) / 4).toFixed(2);
const sizeFromOptions = selectedVariant?.selectedOptions?.find((o) => {
if (isDefaultTitlePlaceholder(o.name, o.value)) return false;
const n = o.name.trim().toLowerCase();
const v = (o.value ?? '').trim();
if (!v) return false;
return n === 'size' || n === 'weight' || n === 'title';
})?.value;
const variantTitle = selectedVariant?.title?.trim() ?? '';
const sizeChip =
sizeFromOptions ||
(variantTitle && variantTitle.toLowerCase() !== 'default title'
? variantTitle
: null);
const descHtml = product.descriptionHtml || '';
const fullIngredients = pdp.ingredients.join(', ');
const accordions = [
{id: 'description', title: 'Description', content: descHtml, html: true},
{id: 'how-to-use', title: 'How to Use', content: pdp.howToUse, html: false},
{
id: 'ingredients',
title: 'Full Ingredients',
content: fullIngredients,
html: false,
},
{
id: 'shipping',
title: 'Shipping & Returns',
content: pdp.shippingReturns || PDP_DEFAULT_SHIPPING_RETURNS,
html: false,
},
] as const;
const addLines: OptimisticCartLineInput[] = selectedVariant
? [
{
merchandiseId: selectedVariant.id,
quantity,
selectedVariant,
},
]
: [];
const showRelated =
relatedItems.length > 0 &&
(mockRelatedFallback
? true
: relatedItems.filter((r) => r.handle !== product.handle).length > 0);
const related =
relatedItems
.filter((r) => r.handle !== product.handle)
.slice(0, 3) || [];
return (
<div className="min-h-screen" style={{background: creamBg}}>
<div
className="box-border w-full"
style={{
borderBottom: '1px solid rgba(26,26,24,0.06)',
minHeight: '61.859px',
}}
>
<div className="mx-auto max-w-[1710px] px-6 pt-[21.25px] pb-[21.25px] sm:px-10 lg:px-16 2xl:px-[206px]">
<div className="flex flex-wrap items-center gap-[8.5px]">
<Link
className="flex items-center gap-1.5 font-['Raleway',sans-serif] text-[12.24px] leading-[18.36px] uppercase tracking-[1.4688px] text-[#7A7068] no-underline transition-opacity hover:opacity-65"
to="/collections/all"
>
<ArrowLeft className="shrink-0" size={12} strokeWidth={1.5} />
Collection
</Link>
<span className="text-[12.24px] leading-[18.36px] text-[rgba(26,26,24,0.2)]">
/
</span>
<span className="font-['Raleway',sans-serif] text-[12.24px] leading-[18.36px] uppercase tracking-[1.4688px] text-[#1a1a18]">
{product.title}
</span>
</div>
</div>
</div>
<div className="mx-auto max-w-[1710px] px-6 pt-[68px] pb-16 sm:px-10 lg:px-16 lg:pb-24 2xl:px-[206px]">
<div className="abiir-pdp-main-stack mx-auto flex max-w-[1400px] flex-col">
<div className="grid items-start gap-10 lg:grid-cols-2 lg:gap-[85px]">
<PdpGallery
activeImage={activeImage}
badges={badges}
gallery={gallery}
productTitle={product.title ?? ''}
setActiveImage={setActiveImage}
/>
<motion.div
className="flex w-full max-w-[606.5px] flex-col lg:max-w-none"
initial={{opacity: 0, x: 20}}
animate={{opacity: 1, x: 0}}
style={{gap: '24px'}}
transition={{duration: 0.65, delay: 0.08}}
>
<p className="m-0 font-['Raleway',sans-serif] text-[12.24px] font-medium leading-[18.36px] uppercase tracking-[2.9376px] text-[#c17d5c]">
{pdp.subtitle}
</p>
<h1 className="m-0 font-['Cormorant_Garamond',serif] text-[clamp(2.5rem,5vw,57.8px)] font-light leading-[1.1] tracking-[-1.156px] text-[#1a1a18] not-italic">
{product.title}
</h1>
<p className="m-0 max-w-[601px] font-['Raleway',sans-serif] text-[16px] font-medium leading-[24px] text-[#5a5550]">
{pdp.shortDescription}
</p>
<div className="flex flex-wrap items-center gap-[12.75px]">
{displayRating != null && displayReviewCount != null ? (
<>
<div className="flex items-center gap-[4.25px]">
{Array.from({length: 5}).map((_, i) => (
<Star
aria-hidden
fill={i < starCount ? starGold : '#D4C5B0'}
key={i}
size={14}
strokeWidth={0}
/>
))}
</div>
<span className="font-['Raleway',sans-serif] text-[14.45px] font-normal leading-[21.675px] text-[#7A7068]">
{displayRating} ({displayReviewCount} reviews)
</span>
</>
) : null}
</div>
<div
className="flex flex-wrap items-center gap-[25.5px] border-b border-[rgba(26,26,24,0.08)] pb-px"
>
<div className="flex flex-wrap items-baseline gap-3">
<span
className="font-['Cormorant_Garamond',serif] text-[37.4px] font-normal leading-[56.1px]"
style={{color: showSalePrice ? burgundy : charcoal}}
>
${formatPrice(priceAmount)}
</span>
{showSalePrice && (
<span className="font-['Raleway',sans-serif] text-[13.94px] font-light leading-[13.94px] text-[#5a5550] line-through">
${formatPrice(compareAmount)}
</span>
)}
</div>
{sizeChip ? (
<span
className="inline-flex items-center font-['Raleway',sans-serif] text-[11.05px] font-medium uppercase leading-[16.575px] tracking-[1.105px] text-[#7A7068]"
style={{
background: chipBg,
padding: '5.88px 12.75px',
}}
>
{sizeChip}
</span>
) : null}
</div>
<PdpOptionControls productOptions={productOptions} />
{pdp.benefitChips.length > 0 && (
<div className="flex flex-wrap gap-2">
{pdp.benefitChips.map((b) => (
<span
className="inline-flex min-h-[33.57px] items-center gap-2 box-border bg-[#ede8e0] py-[8px] pl-[12px] pr-[14px] font-['Raleway',sans-serif] text-[11.05px] font-medium uppercase leading-[16.575px] tracking-[1.326px] text-[#1a1a18]"
key={b}
>
<Check
className="shrink-0"
size={10}
strokeWidth={2.5}
style={{color: charcoal}}
/>
{b}
</span>
))}
</div>
)}
{ingredientRows.length > 0 && (
<div
className="box-border flex w-full flex-col gap-[17px] border-l-[3px] pl-[24.25px] pr-[21.25px] pt-6 pb-6"
style={{
background: chipBg,
borderColor: accent,
padding: '24px 21.25px 24px 24.25px',
}}
>
<div className="flex items-start gap-2">
<Sparkles
className="mt-[3px] shrink-0"
size={11}
strokeWidth={1.75}
style={{color: accent}}
/>
<p className="m-0 font-['Raleway',sans-serif] text-[11.05px] font-semibold uppercase leading-[19.89px] tracking-[2.431px] text-[#c17d5c]">
Key Ingredients
</p>
</div>
<div className="flex flex-col gap-4">
{ingredientRows.map((pair, rowIdx) => (
<div
className="grid grid-cols-1 gap-4 sm:grid-cols-2"
key={rowIdx}
>
{pair.map((ki) => (
<div
className="flex min-w-0 flex-col gap-1"
key={ki.name}
>
<p className="m-0 font-['Raleway',sans-serif] text-[13.94px] font-semibold leading-[20.91px] text-[#1a1a18]">
{ki.name}
</p>
<p className="m-0 font-['Raleway',sans-serif] text-[14px] font-medium leading-[18px] text-[#5a5550]">
{ki.benefit}
</p>
</div>
))}
</div>
))}
</div>
</div>
)}
<div
className="flex flex-wrap items-center gap-x-[15px] gap-y-2 border-b border-[rgba(26,26,24,0.08)] py-[17px]"
>
<p className="m-0 font-['Raleway',sans-serif] text-[14.45px] font-light leading-[21.675px] text-[#1a1a18]">
Pay in 4 interest-free installments of{' '}
<span className="font-semibold">${installmentEach}</span> with
</p>
<span
className="inline-flex items-baseline rounded px-1.5 py-0.5 font-sans text-[11px] font-bold text-white"
style={{background: shopPay}}
>
shop pay
</span>
<button
className="cursor-pointer border-none bg-transparent p-0 font-['Raleway',sans-serif] text-[14.45px] font-light leading-[21.675px] text-[#1a1a18] underline"
type="button"
>
Learn more
</button>
</div>
<div className="flex items-center gap-[8.5px]">
<div
className="h-[8.5px] w-[8.5px] shrink-0 rounded-full"
style={{background: stockGreen}}
/>
<span className="font-['Raleway',sans-serif] text-[13.94px] font-normal leading-[20.91px] text-[#8a9e85]">
{selectedVariant?.availableForSale
? 'In stock, ready to be shipped'
: 'Out of stock'}
</span>
</div>
<div className="abiir-pdp-purchase">
<div className="abiir-pdp-purchase-qty">
<button
className="flex h-full cursor-pointer items-center justify-center border-none bg-transparent px-3.5 text-[#1a1a18] transition-opacity hover:opacity-60"
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
type="button"
>
<Minus size={14} strokeWidth={1.5} />
</button>
<span className="min-w-[42px] flex-1 text-center font-['Raleway',sans-serif] text-[16.15px] font-medium leading-[24.225px] text-[#1a1a18]">
{quantity}
</span>
<button
className="flex h-full cursor-pointer items-center justify-center border-none bg-transparent px-3.5 text-[#1a1a18] transition-opacity hover:opacity-60"
onClick={() => setQuantity((q) => q + 1)}
type="button"
>
<Plus size={14} strokeWidth={1.5} />
</button>
</div>
<div className="abiir-pdp-purchase-actions">
<div className="abiir-pdp-add-to-cart-form">
<CartForm
action={CartForm.ACTIONS.LinesAdd}
inputs={{lines: addLines}}
route="/cart"
>
{(fetcher: FetcherWithComponents<unknown>) => (
<button
className="flex h-[53.883px] w-full min-w-0 cursor-pointer items-center justify-center gap-2 font-['Raleway',sans-serif] text-[clamp(11px,3.2vw,16px)] font-bold uppercase tracking-[2.3868px] text-white transition-opacity duration-200"
disabled={
!selectedVariant?.availableForSale ||
fetcher.state !== 'idle'
}
onClick={() => {
setAddedToCart(true);
onOpenCart();
setTimeout(() => setAddedToCart(false), 2000);
}}
style={{
background: addedToCart ? '#5d0b24' : burgundyCta,
border: 'none',
opacity: addedToCart ? 0.88 : 1,
}}
type="submit"
>
<ShoppingBag className="shrink-0" size={15} strokeWidth={1.75} />
<span className="truncate">
{addedToCart ? 'Added to Cart' : 'Add to Cart'}
</span>
</button>
)}
</CartForm>
</div>
<button
aria-label={
wishlisted ? 'Remove from wishlist' : 'Add to wishlist'
}
className="abiir-pdp-icon-btn"
onClick={toggleWishlist}
type="button"
>
<Heart
fill={wishlisted ? burgundyCta : 'none'}
size={17}
strokeWidth={1.5}
style={{color: wishlisted ? burgundyCta : charcoal}}
/>
</button>
<button
aria-label="Share"
className="abiir-pdp-icon-btn abiir-pdp-icon-btn--share"
onClick={() => {
if (typeof navigator !== 'undefined' && navigator.share) {
void navigator.share({
title: product.title ?? '',
url: window.location.href,
});
}
}}
type="button"
>
<Share2 size={17} strokeWidth={1.5} />
</button>
</div>
</div>
<button
className="w-full cursor-pointer border-none bg-transparent p-0 font-['Raleway',sans-serif] text-[13.26px] font-normal leading-[19.89px] tracking-[1.5912px] text-[#7A7068] underline transition-opacity hover:opacity-70"
type="button"
>
More payment options
</button>
<div className="border-t border-[rgba(26,26,24,0.08)]">
{accordions.map((acc) => {
const open = openAccordion === acc.id;
const panelClass =
acc.id === 'description'
? 'abiir-pdp-accordion-panel abiir-pdp-accordion-panel--description'
: 'abiir-pdp-accordion-panel';
return (
<div
className="border-b border-[rgba(26,26,24,0.08)]"
key={acc.id}
>
<button
className="flex w-full min-h-[62.383px] cursor-pointer items-center justify-between border-none bg-transparent px-0 py-[12px] text-left transition-opacity hover:opacity-75"
onClick={() =>
setOpenAccordion(open ? null : acc.id)
}
type="button"
>
<span className="font-['Raleway',sans-serif] text-[13.26px] font-semibold uppercase leading-[19.89px] tracking-[1.8564px] text-[#1a1a18]">
{acc.title}
</span>
{open ? (
<ChevronUp
className="shrink-0 text-[#7A7068]"
size={15}
strokeWidth={1.5}
/>
) : (
<ChevronDown
className="shrink-0 text-[#7A7068]"
size={15}
strokeWidth={1.5}
/>
)}
</button>
<motion.div
animate={{height: open ? 'auto' : 0, opacity: open ? 1 : 0}}
className="overflow-hidden"
initial={false}
>
<div
className={panelClass}
style={{
paddingBottom:
acc.id === 'description' ? '1.25rem' : '15px',
}}
>
{acc.html ? (
<div
className="prose prose-sm m-0 max-w-[598px] font-['Raleway',sans-serif] text-[16px] font-medium leading-[1.75] text-[#5a5550] [&_p+_p]:mt-[1.25em] [&_p]:mb-0"
dangerouslySetInnerHTML={{__html: acc.content as string}}
/>
) : (
<p className="m-0 max-w-[598px] whitespace-pre-line font-['Raleway',sans-serif] text-[16px] font-medium leading-[24px] text-[#5a5550]">
{acc.content}
</p>
)}
</div>
</motion.div>
</div>
);
})}
</div>
</motion.div>
</div>
<div className="mt-6 sm:mt-0">
<PdpCustomerReviewsSection
isLoggedIn={isLoggedIn}
productHandle={product.handle ?? ''}
reviews={productReviews}
/>
</div>
{showRelated && related.length > 0 && (
<div className="flex w-full flex-col gap-[42.5px]">
<div className="flex flex-col gap-[12.75px]">
<p className="m-0 font-['Raleway',sans-serif] text-[11.56px] font-semibold uppercase leading-[17.34px] tracking-[3.2368px] text-[#c17d5c]">
Complete Your Ritual
</p>
<h2 className="m-0 font-['Cormorant_Garamond',serif] text-[clamp(2.25rem,5vw,64.6px)] font-light leading-[1.12] tracking-[-0.969px] text-[#1a1a18] not-italic">
You May Also Love
</h2>
</div>
<div className={ABIIR_PRODUCT_LIST_GRID_CLASS}>
{related.map((item, i) => (
<AbiirProductListItem
index={i}
item={item}
key={item.handle + item.displayTitle}
showQuickAdd
/>
))}
<div aria-hidden className="hidden min-h-0 lg:block" />
</div>
</div>
)}
</div>
</div>
</div>
);
}
function formatReviewDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
type ReviewSubmitResponse = {ok?: boolean; error?: string};
function PdpCustomerReviewsSection({
productHandle,
reviews,
isLoggedIn,
}: {
productHandle: string;
reviews: PdpProductReview[];
isLoggedIn: boolean;
}) {
const fetcher = useFetcher<ReviewSubmitResponse>();
const revalidator = useRevalidator();
const [starRating, setStarRating] = useState(5);
const [starHover, setStarHover] = useState<number | null>(null);
const starsDisplay = starHover ?? starRating;
useEffect(() => {
if (fetcher.state === 'idle' && fetcher.data?.ok) {
revalidator.revalidate();
}
}, [fetcher.state, fetcher.data, revalidator]);
const submitted =
fetcher.state === 'idle' && fetcher.data?.ok === true;
const err = fetcher.data && !fetcher.data.ok ? fetcher.data.error : undefined;
const errMsg =
err === 'duplicate_review'
? 'You have already submitted a review for this product.'
: err === 'unauthorized' || err === 'customer_unavailable'
? 'Please sign in again to leave a review.'
: err === 'not_configured' || err === 'create_failed'
? 'Reviews are temporarily unavailable. Please try again later.'
: err === 'body_too_short' || err === 'body_too_long' || err === 'invalid_rating' || err === 'invalid_handle' || err === 'title_too_long'
? 'Please check your review and try again.'
: err
? 'Something went wrong. Please try again.'
: null;
return (
<div className="abiir-pdp-reviews w-full" style={{maxWidth: 1400}}>
<p
className="m-0 mb-[34px] font-['Raleway',sans-serif] text-[11.56px] font-semibold uppercase leading-[17.34px] tracking-[3.2368px] text-[#c17d5c]"
style={{marginBottom: 20}}
>
Reviews
</p>
<h2 className="m-0 mb-6 font-['Cormorant_Garamond',serif] text-[clamp(1.75rem,4vw,2.5rem)] font-light text-[#1a1a18]">
What customers say
</h2>
{reviews.length > 0 ? (
<ul className="m-0 flex list-none flex-col gap-6 p-0">
{reviews.map((r, idx) => {
const rStars = Math.max(0, Math.min(5, Math.round(Number(r.rating) || 0)));
return (
<li
className="border-b border-[rgba(26,26,24,0.08)] pb-6 last:border-b-0"
key={`${r.submittedAt}-${idx}`}
>
<div className="mb-1 flex flex-wrap items-center gap-2">
<div className="flex items-center gap-0.5" role="img" aria-label={`${rStars} out of 5 stars`}>
{Array.from({length: 5}).map((_, i) => (
<Star
key={i}
aria-hidden
fill={i < rStars ? starGold : '#D4C5B0'}
size={12}
strokeWidth={0}
/>
))}
</div>
{r.submittedAt ? (
<span className="font-['Raleway',sans-serif] text-[12px] text-[#7A7068]">
{formatReviewDate(r.submittedAt)}
</span>
) : null}
</div>
<p className="m-0 font-['Raleway',sans-serif] text-[13.94px] font-semibold text-[#1a1a18]">
{r.authorDisplayName}
{r.title ? (
<span className="font-bold"> — {r.title}</span>
) : null}
</p>
<p className="m-0 mt-2 max-w-[720px] whitespace-pre-wrap font-['Raleway',sans-serif] text-[15px] font-medium leading-[23px] text-[#5a5550]">
{r.body}
</p>
</li>
);
})}
</ul>
) : (
<p className="m-0 mb-3 sm:mb-6 font-['Raleway',sans-serif] text-[15px] text-[#5a5550]">
No reviews yet — be the first to share your experience.
</p>
)}
<div className="mt-0 sm:mt-8 rounded-sm border border-[rgba(26,26,24,0.1)] bg-white/40 p-6">
{isLoggedIn ? (
<fetcher.Form
className="flex max-w-lg flex-col gap-4"
method="post"
action="/api/product-reviews"
>
<input name="productHandle" type="hidden" value={productHandle} />
<input name="rating" type="hidden" value={String(starRating)} />
<div className="flex flex-col gap-1.5">
<span className="font-['Raleway',sans-serif] text-[11.05px] font-semibold uppercase tracking-[1.2px] text-[#1a1a18]">
Rating
</span>
<div
className="flex w-fit items-center gap-0.5"
onMouseLeave={() => setStarHover(null)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setStarHover(null);
}
}}
role="group"
aria-label={`Rating: ${starRating} out of 5 stars`}
>
{[1, 2, 3, 4, 5].map((n) => (
<button
className="cursor-pointer rounded p-1.5 text-[#1a1a18] transition-opacity hover:opacity-90"
key={n}
type="button"
onClick={() => setStarRating(n)}
onMouseEnter={() => setStarHover(n)}
aria-label={`Set rating to ${n} out of 5 stars`}
>
<Star
aria-hidden
fill={n <= starsDisplay ? starGold : '#D4C5B0'}
size={26}
strokeWidth={0}
/>
</button>
))}
</div>
</div>
<label className="flex flex-col gap-1.5">
<span className="font-['Raleway',sans-serif] text-[11.05px] font-semibold uppercase tracking-[1.2px] text-[#1a1a18]">
Title (optional)
</span>
<input
className="min-h-11 rounded border border-[rgba(26,26,24,0.2)] bg-[#f8f4ef] px-3 font-['Raleway',sans-serif] text-[14px] text-[#1a1a18]"
maxLength={200}
name="title"
type="text"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="font-['Raleway',sans-serif] text-[11.05px] font-semibold uppercase tracking-[1.2px] text-[#1a1a18]">
Your review
</span>
<textarea
className="min-h-[120px] rounded border border-[rgba(26,26,24,0.2)] bg-[#f8f4ef] p-3 font-['Raleway',sans-serif] text-[14px] text-[#1a1a18]"
name="body"
required
minLength={4}
maxLength={5000}
placeholder="Share how this product works for you."
/>
</label>
{submitted ? (
<p className="m-0 text-[14px] text-[#3d5c45]">
Thank you. Your review was received and will appear after our team
approves it.
</p>
) : null}
{errMsg ? (
<p className="m-0 text-[14px] text-[#7a2340]">{errMsg}</p>
) : null}
<button
className="min-h-11 max-w-xs cursor-pointer rounded border-none font-['Raleway',sans-serif] text-[14px] font-bold uppercase tracking-[1.4px] text-white transition-opacity hover:opacity-90 disabled:opacity-50"
disabled={fetcher.state !== 'idle'}
style={{background: burgundyCta}}
type="submit"
>
{fetcher.state !== 'idle' ? 'Submitting…' : 'Submit review'}
</button>
</fetcher.Form>
) : (
<p className="m-0 font-['Raleway',sans-serif] text-[15px] leading-[24px] text-[#5a5550]">
<Link
className="text-[#c17d5c] underline underline-offset-2"
to="/account/login"
>
Log in
</Link>{' '}
to write a review.
</p>
)}
</div>
</div>
);
}
function PdpGallery({
gallery,
activeImage,
setActiveImage,
badges,
productTitle,
}: {
gallery: PdpGalleryItem[];
activeImage: number;
setActiveImage: Dispatch<SetStateAction<number>>;
badges: {label: string; bg: string}[];
productTitle: string;
}) {
const hasMulti = gallery.length > 1;
const current = gallery[activeImage] ?? gallery[0];
const goPrev = useCallback(() => {
if (gallery.length <= 1) return;
setActiveImage((i) => (i - 1 + gallery.length) % gallery.length);
}, [gallery.length, setActiveImage]);
const goNext = useCallback(() => {
if (gallery.length <= 1) return;
setActiveImage((i) => (i + 1) % gallery.length);
}, [gallery.length, setActiveImage]);
const swipeGallery = useSwipeNavigate(goPrev, goNext, {threshold: 48});
return (
<motion.div
animate={{opacity: 1, x: 0}}
className="flex w-full gap-3 lg:gap-[17px]"
initial={{opacity: 0, x: -20}}
transition={{duration: 0.65}}
>
{hasMulti && (
<div className="hidden w-[72px] shrink-0 flex-col gap-[12.75px] sm:flex">
{gallery.map((item, i) => (
<button
className="shrink-0 cursor-pointer overflow-hidden border-none p-0 transition-opacity duration-200 hover:opacity-85"
key={item.id ?? i}
onClick={() => setActiveImage(i)}
style={{
width: 72,
height: 72,
background: chipBg,
outline:
activeImage === i
? `2px solid ${accent}`
: '2px solid transparent',
outlineOffset: 2,
}}
type="button"
>
{item.kind === 'image' ? (
<Image alt="" className="h-full w-full object-cover" data={item.image} />
) : item.previewImage?.url ? (
<div className="relative h-full w-full">
<Image alt="" className="h-full w-full object-cover" data={item.previewImage} />
<div
aria-hidden
className="absolute inset-0 flex items-center justify-center"
style={{background: 'rgba(0,0,0,0.18)'}}
>
<div
className="rounded-full"
style={{
width: 26,
height: 26,
background: 'rgba(248,244,239,0.9)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span
aria-hidden
style={{
width: 0,
height: 0,
borderTop: '6px solid transparent',
borderBottom: '6px solid transparent',
borderLeft: `9px solid ${charcoal}`,
marginLeft: 2,
}}
/>
</div>
</div>
</div>
) : (
<div className="flex h-full w-full items-center justify-center bg-[#ede8e0]">
<span className="font-['Raleway',sans-serif] text-[10px] text-[#5a5550]">
Video
</span>
</div>
)}
</button>
))}
</div>
)}
<div className="relative min-w-0 flex-1">
<div
className="relative w-full overflow-hidden"
style={{
aspectRatio: '517.5 / 646.875',
background: chipBg,
}}
{...(hasMulti ? swipeGallery : {})}
>
<AnimatePresence initial={false} mode="wait">
{current?.kind === 'image' && current.image?.url ? (
<motion.div
animate={{opacity: 1}}
className="absolute inset-0"
exit={{opacity: 0}}
initial={{opacity: 0}}
key={activeImage}
transition={{duration: 0.35}}
>
<Image
alt={current.image.altText || productTitle}
className="h-full w-full object-cover"
data={current.image}
sizes="(min-width: 1024px) 45vw, 100vw"
/>
</motion.div>
) : current?.kind === 'video' ? (
<motion.div
animate={{opacity: 1}}
className="absolute inset-0 z-[1]"
exit={{opacity: 0}}
initial={{opacity: 1}}
key={activeImage}
transition={{duration: 0.2}}
>
<PdpGalleryAutoplayVideo item={current} />
</motion.div>
) : null}
</AnimatePresence>
</div>
{badges.length > 0 && (
<div
className="pointer-events-none absolute z-[3] flex max-w-[calc(100%-51px)] flex-wrap gap-2"
style={{left: '25.5px', top: '25.5px'}}
>
{badges.map((b) => (
<span
className="inline-flex items-center font-['Jost',sans-serif] text-[10.2px] font-medium leading-[15.3px] tracking-[1.224px] text-[#F8F4EF] uppercase"
key={b.label}
style={{
background: b.bg,
padding: '8.5px 17px',
}}
>
{b.label}
</span>
))}
</div>
)}
{hasMulti && (
<div className="mt-4 flex items-center justify-center gap-2 sm:hidden">
{gallery.map((_, i) => (
<button
className="rounded-full border-none p-0 transition-all duration-200"
key={i}
onClick={() => setActiveImage(i)}
style={{
width: i === activeImage ? 18 : 6,
height: 6,
background: i === activeImage ? accent : '#D4C5B0',
}}
type="button"
/>
))}
</div>
)}
</div>
</motion.div>
);
}