1440 lines
52 KiB
TypeScript
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 > 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>
|
|
);
|
|
}
|