Paged full-bleed photo gallery with floating controls, option chips with sold-out sizes, size-guide link, shipping/returns promises, rating-summary reviews card, and a shadowed sticky add-to-bag bar
import * as React from "react";
import { useState } from "react";
import { View, ScrollView, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, Button, Chip, Glass, Icon, Image, Separator, Text, type IconName } from "@lumioui/ui";
import { cn, useHaptic } from "@lumioui/core";
import { BLOCK_STAGGER, catalogImg, LumioMark, Mount, Press, SERIF } from "../shared";
import { StickyBottomBar } from "../shared";
export interface ProductReview {
author: string;
rating: number;
date: string;
text: string;
}
export interface ProductDetail {
id: string;
name: string;
brand?: string;
price: number;
currency?: string;
rating?: number;
reviewCount?: number;
description?: string;
/** Gallery images; falls back to imageUrl. */
imageUrls?: string[];
imageUrl?: string;
colors?: string[];
sizes?: string[];
/** Sizes rendered but disabled (sold out). */
unavailableSizes?: string[];
/** Shipping promise row under the description. */
shipping?: { label: string; detail: string };
/** Returns promise row under the description. */
returns?: { label: string; detail: string };
badge?: string;
reviews?: ProductReview[];
}
export interface ProductDetailScreenProps {
product?: ProductDetail;
onAddToCart?: (product: ProductDetail, options: { color?: string; size?: string }) => void;
onToggleWishlist?: (product: ProductDetail, wishlisted: boolean) => void;
onSeeAllReviews?: () => void;
onSizeGuide?: () => void;
onBack?: () => void;
}
const img = (p: Parameters<typeof catalogImg>[0]) => catalogImg(p, 900, 1125);
const DEFAULT_PRODUCT: ProductDetail = {
id: "1",
name: "Structured Wool Overcoat",
brand: "Nord Standard",
price: 248,
rating: 4.9,
reviewCount: 214,
description:
"Cut from double-faced Italian wool with a clean, unstructured shoulder. Fully lined in cupro, finished with horn buttons and a hidden interior pocket. Designed in Copenhagen, made to be worn for decades.",
// Gallery: product shot → styled flat-lay → in-store. The bomber was a
// different garment — real PDPs never show another product mid-gallery.
imageUrls: [img("overcoat"), img("flatlay"), img("shop")],
colors: ["Camel", "Charcoal", "Black"],
sizes: ["XS", "S", "M", "L", "XL"],
unavailableSizes: ["XS"],
// Matches checkout's free Standard option — one shipping promise universe.
shipping: { label: "Free shipping", detail: "Arrives in 5–7 business days" },
returns: { label: "Free returns", detail: "30-day return window" },
badge: "New",
reviews: [
{
author: "Priya N.",
rating: 5,
date: "Sep 2, 2026",
text: "Beautifully made — the drape is perfect and it layers over knitwear without pulling. Runs true to size.",
},
],
};
/** Prices always render with two decimals ($248.00). */
const fmt = (n: number, currency = "$") =>
`${currency}${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
function Stars({ rating, size = "sm" }: { rating: number; size?: "sm" | "md" }) {
return (
<View className="flex-row items-center gap-0.5" accessibilityElementsHidden importantForAccessibility="no">
{[0, 1, 2, 3, 4].map((i) => (
<Icon
key={i}
name="star"
size={size}
color={i < Math.round(rating) ? "warning" : "mutedForeground"}
filled={i < Math.round(rating)}
/>
))}
</View>
);
}
function InfoRow({ icon, label, detail }: { icon: IconName; label: string; detail: string }) {
return (
<View className="flex-row items-center gap-3">
<View className="h-10 w-10 items-center justify-center rounded-full bg-[#EDF8C4] dark:bg-[#1E2410]">
<Icon name={icon} size="sm" color="#65A30D" />
</View>
<View className="flex-1 gap-0.5">
<Text variant="small" className="font-semibold">
{label}
</Text>
<Text variant="muted" className="text-xs">
{detail}
</Text>
</View>
</View>
);
}
InfoRow.displayName = "InfoRow";
function ChipRow({
label,
options,
disabledOptions,
selected,
onSelect,
trailing,
}: {
label: string;
options: string[];
/** Options rendered but not selectable (sold out). */
disabledOptions?: string[];
selected?: string;
onSelect: (value: string) => void;
/** Optional accessory on the label row — e.g. a "Size guide" link. */
trailing?: React.ReactNode;
}) {
if (options.length === 0) return null;
return (
<View className="gap-2.5">
<View className="flex-row items-center justify-between">
<Text variant="small" className="font-semibold">
{label}
{selected ? <Text variant="muted"> — {selected}</Text> : null}
</Text>
{trailing}
</View>
<View className="flex-row flex-wrap gap-2">
{options.map((option) => {
const active = option === selected;
const unavailable = disabledOptions?.includes(option) ?? false;
return (
<Chip
key={option}
variant="filter"
selected={active}
disabled={unavailable}
onPress={() => onSelect(option)}
accessibilityLabel={
unavailable ? `${label}: ${option}, unavailable` : `${label}: ${option}`
}
// Selected = filled primary (store-app idiom); default chip
// selected style is a tint meant for filter rows.
className={cn("h-11 min-w-11 justify-center px-4", active && "border-[#1B1B26] bg-[#1B1B26]")}
textClassName={cn(active && "text-white", unavailable && "line-through")}
>
{option}
</Chip>
);
})}
</View>
</View>
);
}
ChipRow.displayName = "ChipRow";
/** Product detail — paged full-bleed hero with dots, option selectors,
* review snippet, and a sticky price + Add to bag bar. */
export function ProductDetailScreen({
product = DEFAULT_PRODUCT,
onAddToCart,
onToggleWishlist,
onSeeAllReviews,
onSizeGuide,
onBack,
}: ProductDetailScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
// Measure the hero container — useWindowDimensions() returns the browser
// window in web previews (the column caps at 440px), which would make the
// gallery pages window-wide.
const [heroWidth, setHeroWidth] = useState(0);
const onHeroLayout = (e: LayoutChangeEvent) => {
const w = e.nativeEvent.layout.width;
if (w > 0 && w !== heroWidth) setHeroWidth(w);
};
const [color, setColor] = useState(product.colors?.[0]);
// Default to the first in-stock size — never open on a sold-out option.
const [size, setSize] = useState(
() => product.sizes?.find((s) => !product.unavailableSizes?.includes(s)) ?? product.sizes?.[0]
);
const [page, setPage] = useState(0);
const [wishlisted, setWishlisted] = useState(false);
const [qty, setQty] = useState(1);
const galleryRef = React.useRef<ScrollView>(null);
const images =
product.imageUrls && product.imageUrls.length > 0
? product.imageUrls
: product.imageUrl
? [product.imageUrl]
: [];
const onHeroScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
if (heroWidth <= 0) return;
const next = Math.round(e.nativeEvent.contentOffset.x / heroWidth);
if (next !== page) setPage(next);
};
return (
<View className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]">
<ScrollView
className="flex-1"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 96 }}
// "never" keeps the hero edge-to-edge under the status bar;
// floating controls position themselves with insets.top.
contentInsetAdjustmentBehavior="never"
scrollEnabled
>
{/* Hero gallery — full-bleed under the status bar */}
<View onLayout={onHeroLayout}>
{images.length > 1 && heroWidth > 0 ? (
<ScrollView
ref={galleryRef}
horizontal
className="grow-0"
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={onHeroScroll}
accessibilityLabel={`${product.name} photo gallery`}
>
{images.map((uri, i) => (
// Explicit page width — the Image wrapper's own w-full can
// mis-measure inside a horizontal ScrollView on web.
<View key={uri} style={{ width: heroWidth }}>
<Image
source={{ uri }}
rounded="none"
aspectRatio={4 / 5}
className="w-full"
accessibilityLabel={`${product.name}, photo ${i + 1} of ${images.length}`}
/>
</View>
))}
</ScrollView>
) : (
// Single image, or the first frame while the gallery measures.
<Image
source={images[0] ? { uri: images[0] } : undefined}
rounded="none"
aspectRatio={4 / 5}
className="w-full"
accessibilityLabel={`Product image for ${product.name}`}
/>
)}
{onBack ? (
<Glass interactive effect="regular" style={{ top: insets.top + 8 }} className="absolute left-4 rounded-full">
<Press
onPress={() => {
haptics.light();
onBack();
}}
accessibilityRole="button"
accessibilityLabel="Go back"
hitSlop={12}
className="h-10 w-10 items-center justify-center rounded-full"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</Glass>
) : null}
<Glass interactive effect="regular" style={{ top: insets.top + 8 }} className="absolute right-4 rounded-full">
<Press
onPress={() => {
haptics.selection();
setWishlisted((w) => !w);
onToggleWishlist?.(product, !wishlisted);
}}
accessibilityRole="button"
accessibilityLabel={wishlisted ? "Remove from wishlist" : "Add to wishlist"}
accessibilityState={{ selected: wishlisted }}
hitSlop={12}
className="h-10 w-10 items-center justify-center rounded-full"
>
<Icon name="heart" size="md" color={wishlisted ? "destructive" : "foreground"} filled={wishlisted} />
</Press>
</Glass>
{images.length > 1 ? (
<Glass interactive
effect="clear"
className="absolute bottom-4 self-center flex-row items-center gap-1.5 rounded-full px-2.5 py-2"
accessibilityElementsHidden
importantForAccessibility="no"
>
{images.map((uri, i) => (
<View
key={uri}
className={cn("h-1.5 rounded-full bg-card", i === page ? "w-4" : "w-1.5 opacity-50")}
/>
))}
</Glass>
) : null}
{/* Thumbnail rail — taps jump the gallery; the ref's right-edge
strip, kept translucent so the hero shows through. */}
{images.length > 1 ? (
<View className="absolute bottom-4 right-4 gap-2">
{images.map((uri, i) => (
<Press
key={uri}
onPress={() => {
haptics.selection();
galleryRef.current?.scrollTo({ x: i * heroWidth, animated: true });
setPage(i);
}}
accessibilityRole="button"
accessibilityLabel={`Photo ${i + 1}`}
>
<Image
source={{ uri }}
rounded="lg"
className={cn(
"h-14 w-14 border-2",
i === page ? "border-white" : "border-white/40 opacity-70",
)}
accessibilityElementsHidden
importantForAccessibility="no"
/>
</Press>
))}
</View>
) : null}
{product.badge ? (
<View className="absolute bottom-4 left-4 rounded-full bg-[#BEF04C] px-3 py-1.5">
<Text className="text-[11px] font-semibold text-[#1B1B26]">
{product.badge}
</Text>
</View>
) : null}
</View>
<View className="gap-6 px-6 pt-5">
{/* Brand / name / price / rating */}
<Mount>
<View className="gap-1.5">
{product.brand ? (
<View className="flex-row items-center gap-2">
<LumioMark size={18} />
<Text variant="muted" className="text-xs uppercase tracking-wider">
{product.brand}
</Text>
</View>
) : null}
<Text variant="h3" style={{ fontFamily: SERIF }}>{product.name}</Text>
{/* Demand signal — product pages that feel alive show who's watching. */}
<Text variant="muted" className="pt-0.5 text-xs">
21 people are viewing this · restocked this week
</Text>
<View className="flex-row items-center justify-between pt-1">
<View className="flex-row items-center gap-4">
<Text variant="h4" className="tabular-nums">
{fmt(product.price, product.currency)}
</Text>
{/* Qty stepper — the ref's capsule, horizontal here so it
sits in the price row like a real PDP. */}
<View className="flex-row items-center gap-2.5 rounded-full bg-muted/60 px-2 py-1.5">
<Press
onPress={() => {
haptics.selection();
setQty((q) => Math.max(1, q - 1));
}}
accessibilityRole="button"
accessibilityLabel="Decrease quantity"
hitSlop={6}
className="h-7 w-7 items-center justify-center rounded-full bg-card"
>
<Icon name="minus" size="sm" color="foreground" />
</Press>
<Text variant="small" className="w-5 text-center font-semibold tabular-nums">
{String(qty).padStart(2, "0")}
</Text>
<Press
onPress={() => {
haptics.selection();
setQty((q) => q + 1);
}}
accessibilityRole="button"
accessibilityLabel="Increase quantity"
hitSlop={6}
className="h-7 w-7 items-center justify-center rounded-full bg-[#1B1B26]"
>
<Icon name="plus" size="sm" color="#FFFFFF" />
</Press>
</View>
</View>
{product.rating != null ? (
<View
className="flex-row items-center gap-2"
accessibilityLabel={`Rated ${product.rating} out of 5${
product.reviewCount != null ? `, ${product.reviewCount} reviews` : ""
}`}
>
<Stars rating={product.rating} />
<Text variant="muted" className="tabular-nums">
{product.rating.toFixed(1)}
{product.reviewCount != null ? ` (${product.reviewCount.toLocaleString()})` : ""}
</Text>
</View>
) : null}
</View>
</View>
</Mount>
<Mount delay={BLOCK_STAGGER}>
<View className="gap-6">
<ChipRow
label="Color"
options={product.colors ?? []}
selected={color}
// Chip fires haptics.selection() internally — don't double it.
onSelect={setColor}
/>
<ChipRow
label="Size"
options={product.sizes ?? []}
disabledOptions={product.unavailableSizes}
selected={size}
onSelect={setSize}
trailing={
onSizeGuide ? (
<Press
onPress={() => {
haptics.light();
onSizeGuide();
}}
accessibilityRole="button"
accessibilityLabel="Open size guide"
hitSlop={12}
className="min-h-11 flex-row items-center justify-center gap-1.5"
>
<Icon name="info" size="sm" color="#65A30D" />
<Text variant="small" className="text-[#65A30D]">
Size guide
</Text>
</Press>
) : undefined
}
/>
</View>
</Mount>
{product.description ? (
<Mount delay={BLOCK_STAGGER * 2}>
<View className="gap-2">
<Text variant="small" className="font-semibold">
Description
</Text>
<Text variant="muted" className="leading-6">
{product.description}
</Text>
</View>
</Mount>
) : null}
{product.shipping || product.returns ? (
<Mount delay={BLOCK_STAGGER * 3}>
<View className="gap-3.5 rounded-3xl bg-muted/50 p-4">
{product.shipping ? (
<InfoRow icon="package" label={product.shipping.label} detail={product.shipping.detail} />
) : null}
{product.shipping && product.returns ? <Separator /> : null}
{product.returns ? (
<InfoRow icon="repeat" label={product.returns.label} detail={product.returns.detail} />
) : null}
</View>
</Mount>
) : null}
{product.reviews && product.reviews.length > 0 ? (
<Mount delay={BLOCK_STAGGER * 4}>
<View className="gap-3">
<View className="flex-row items-center justify-between">
<Text variant="small" className="font-semibold">
Reviews{product.reviewCount != null ? ` (${product.reviewCount.toLocaleString()})` : ""}
</Text>
{onSeeAllReviews ? (
<Press
onPress={onSeeAllReviews}
accessibilityRole="button"
accessibilityLabel="See all reviews"
hitSlop={12}
className="min-h-11 justify-center"
>
<Text variant="small" className="text-[#65A30D]">
See all
</Text>
</Press>
) : null}
</View>
<View className="gap-4 rounded-3xl bg-muted/50 p-4">
{/* Rating summary — the anchor of the card. */}
{product.rating != null ? (
<View
className="flex-row items-center gap-3"
accessibilityLabel={`Average rating ${product.rating} out of 5${
product.reviewCount != null ? ` from ${product.reviewCount} reviews` : ""
}`}
>
<Text variant="h2" className="tabular-nums">
{product.rating.toFixed(1)}
</Text>
<View className="gap-1">
<Stars rating={product.rating} />
{product.reviewCount != null ? (
<Text variant="muted" className="text-xs">
Based on {product.reviewCount.toLocaleString()} reviews
</Text>
) : null}
</View>
</View>
) : null}
{product.reviews.slice(0, 1).map((review) => (
<View key={review.author} className="gap-2">
<Separator />
<View className="flex-row items-center gap-3">
<Avatar size="sm" fallback={review.author.slice(0, 1)} alt={`${review.author} avatar`} />
<View className="flex-1">
<Text variant="small">{review.author}</Text>
<Text variant="muted" className="text-xs">
{review.date}
</Text>
</View>
<Stars rating={review.rating} />
</View>
<Text variant="muted" className="leading-5" numberOfLines={3}>
{review.text}
</Text>
<View className="flex-row items-center gap-1.5 pt-0.5">
<Icon name="check-circle" size="sm" color="success" />
<Text variant="muted" className="text-xs">
Verified purchase
</Text>
</View>
</View>
))}
</View>
</View>
</Mount>
) : null}
</View>
</ScrollView>
{/* Sticky bottom bar — price + Add to bag over the scrolling detail. */}
<StickyBottomBar>
<Button
tone="inverse" className="w-full"
haptic="heavy"
icon={<Icon name="shopping-cart" size="sm" color="primaryForeground" />}
onPress={() => onAddToCart?.(product, { color, size })}
accessibilityLabel={`Add ${qty} of ${product.name} to bag, ${fmt(product.price * qty, product.currency)}`}
>
<Text className="text-[15px] font-semibold text-background">
Buy Now · <Text className="font-bold tabular-nums text-background">{fmt(product.price * qty, product.currency)}</Text>
</Text>
</Button>
</StickyBottomBar>
</View>
);
}
Recreate the "Product Detail Screen" screen in React Native using LumioUI.
Goal: Paged full-bleed photo gallery with floating controls, option chips with sold-out sizes, size-guide link, shipping/returns promises, rating-summary reviews card, and a shadowed sticky add-to-bag bar Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.
## Setup
Fastest path — install the screen plus its component deps:
```bash
npx lumioui add ecommerce-product-detail
```
Or pull just the components: `npx lumioui add avatar badge button chip glass icon image separator text`
Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-safe-area-context@*
- react-native-reanimated@*
- react-native-svg@*
- nativewind@*
Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.
## Components — how, where, when
### `avatar` — Circular user image with fallback initials and status dot.
- `<Avatar>` ×1
### `badge` — Compact status pill with variants, icons and pulse option.
- imported but not rendered directly — likely used through a sibling or as a utility
### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×1
### `chip` — Compact selectable pill with press scale and ink overlay.
- `<Chip>` ×1
### `glass` — iOS 26 liquid-glass surface — real glass via expo-glass-effect (Expo Go) or @callstack/liquid-glass (dev builds), SVG refraction + specular rim on web, frosted fallback elsewhere; GlassContainer melts adjacent panes into one blob
- `<Glass>` ×3
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×9
### `image` — Cached image with placeholder, fallback and aspect handling.
- `<Image>` ×3
### `separator` — Hairline rule with orientation and spacing control.
- `<Separator>` ×2
### `text` — Themed type scale from caption to display.
- `<Text>` ×24
## Conventions (non-negotiable)
- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.
## Deliverable
One file: `screens/ecommerce/product-detail.tsx` exporting ProductDetailScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.
## Reference implementation
The LumioUI source for this screen — reproduce it faithfully:
```tsx
import * as React from "react";
import { useState } from "react";
import { View, ScrollView, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, Button, Chip, Glass, Icon, Image, Separator, Text, type IconName } from "@lumioui/ui";
import { cn, useHaptic } from "@lumioui/core";
import { BLOCK_STAGGER, catalogImg, LumioMark, Mount, Press, SERIF } from "../shared";
import { StickyBottomBar } from "../shared";
export interface ProductReview {
author: string;
rating: number;
date: string;
text: string;
}
export interface ProductDetail {
id: string;
name: string;
brand?: string;
price: number;
currency?: string;
rating?: number;
reviewCount?: number;
description?: string;
/** Gallery images; falls back to imageUrl. */
imageUrls?: string[];
imageUrl?: string;
colors?: string[];
sizes?: string[];
/** Sizes rendered but disabled (sold out). */
unavailableSizes?: string[];
/** Shipping promise row under the description. */
shipping?: { label: string; detail: string };
/** Returns promise row under the description. */
returns?: { label: string; detail: string };
badge?: string;
reviews?: ProductReview[];
}
export interface ProductDetailScreenProps {
product?: ProductDetail;
onAddToCart?: (product: ProductDetail, options: { color?: string; size?: string }) => void;
onToggleWishlist?: (product: ProductDetail, wishlisted: boolean) => void;
onSeeAllReviews?: () => void;
onSizeGuide?: () => void;
onBack?: () => void;
}
const img = (p: Parameters<typeof catalogImg>[0]) => catalogImg(p, 900, 1125);
const DEFAULT_PRODUCT: ProductDetail = {
id: "1",
name: "Structured Wool Overcoat",
brand: "Nord Standard",
price: 248,
rating: 4.9,
reviewCount: 214,
description:
"Cut from double-faced Italian wool with a clean, unstructured shoulder. Fully lined in cupro, finished with horn buttons and a hidden interior pocket. Designed in Copenhagen, made to be worn for decades.",
// Gallery: product shot → styled flat-lay → in-store. The bomber was a
// different garment — real PDPs never show another product mid-gallery.
imageUrls: [img("overcoat"), img("flatlay"), img("shop")],
colors: ["Camel", "Charcoal", "Black"],
sizes: ["XS", "S", "M", "L", "XL"],
unavailableSizes: ["XS"],
// Matches checkout's free Standard option — one shipping promise universe.
shipping: { label: "Free shipping", detail: "Arrives in 5–7 business days" },
returns: { label: "Free returns", detail: "30-day return window" },
badge: "New",
reviews: [
{
author: "Priya N.",
rating: 5,
date: "Sep 2, 2026",
text: "Beautifully made — the drape is perfect and it layers over knitwear without pulling. Runs true to size.",
},
],
};
/** Prices always render with two decimals ($248.00). */
const fmt = (n: number, currency = "$") =>
`${currency}${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
function Stars({ rating, size = "sm" }: { rating: number; size?: "sm" | "md" }) {
return (
<View className="flex-row items-center gap-0.5" accessibilityElementsHidden importantForAccessibility="no">
{[0, 1, 2, 3, 4].map((i) => (
<Icon
key={i}
name="star"
size={size}
color={i < Math.round(rating) ? "warning" : "mutedForeground"}
filled={i < Math.round(rating)}
/>
))}
</View>
);
}
function InfoRow({ icon, label, detail }: { icon: IconName; label: string; detail: string }) {
return (
<View className="flex-row items-center gap-3">
<View className="h-10 w-10 items-center justify-center rounded-full bg-[#EDF8C4] dark:bg-[#1E2410]">
<Icon name={icon} size="sm" color="#65A30D" />
</View>
<View className="flex-1 gap-0.5">
<Text variant="small" className="font-semibold">
{label}
</Text>
<Text variant="muted" className="text-xs">
{detail}
</Text>
</View>
</View>
);
}
InfoRow.displayName = "InfoRow";
function ChipRow({
label,
options,
disabledOptions,
selected,
onSelect,
trailing,
}: {
label: string;
options: string[];
/** Options rendered but not selectable (sold out). */
disabledOptions?: string[];
selected?: string;
onSelect: (value: string) => void;
/** Optional accessory on the label row — e.g. a "Size guide" link. */
trailing?: React.ReactNode;
}) {
if (options.length === 0) return null;
return (
<View className="gap-2.5">
<View className="flex-row items-center justify-between">
<Text variant="small" className="font-semibold">
{label}
{selected ? <Text variant="muted"> — {selected}</Text> : null}
</Text>
{trailing}
</View>
<View className="flex-row flex-wrap gap-2">
{options.map((option) => {
const active = option === selected;
const unavailable = disabledOptions?.includes(option) ?? false;
return (
<Chip
key={option}
variant="filter"
selected={active}
disabled={unavailable}
onPress={() => onSelect(option)}
accessibilityLabel={
unavailable ? `${label}: ${option}, unavailable` : `${label}: ${option}`
}
// Selected = filled primary (store-app idiom); default chip
// selected style is a tint meant for filter rows.
className={cn("h-11 min-w-11 justify-center px-4", active && "border-[#1B1B26] bg-[#1B1B26]")}
textClassName={cn(active && "text-white", unavailable && "line-through")}
>
{option}
</Chip>
);
})}
</View>
</View>
);
}
ChipRow.displayName = "ChipRow";
/** Product detail — paged full-bleed hero with dots, option selectors,
* review snippet, and a sticky price + Add to bag bar. */
export function ProductDetailScreen({
product = DEFAULT_PRODUCT,
onAddToCart,
onToggleWishlist,
onSeeAllReviews,
onSizeGuide,
onBack,
}: ProductDetailScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
// Measure the hero container — useWindowDimensions() returns the browser
// window in web previews (the column caps at 440px), which would make the
// gallery pages window-wide.
const [heroWidth, setHeroWidth] = useState(0);
const onHeroLayout = (e: LayoutChangeEvent) => {
const w = e.nativeEvent.layout.width;
if (w > 0 && w !== heroWidth) setHeroWidth(w);
};
const [color, setColor] = useState(product.colors?.[0]);
// Default to the first in-stock size — never open on a sold-out option.
const [size, setSize] = useState(
() => product.sizes?.find((s) => !product.unavailableSizes?.includes(s)) ?? product.sizes?.[0]
);
const [page, setPage] = useState(0);
const [wishlisted, setWishlisted] = useState(false);
const [qty, setQty] = useState(1);
const galleryRef = React.useRef<ScrollView>(null);
const images =
product.imageUrls && product.imageUrls.length > 0
? product.imageUrls
: product.imageUrl
? [product.imageUrl]
: [];
const onHeroScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
if (heroWidth <= 0) return;
const next = Math.round(e.nativeEvent.contentOffset.x / heroWidth);
if (next !== page) setPage(next);
};
return (
<View className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]">
<ScrollView
className="flex-1"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 96 }}
// "never" keeps the hero edge-to-edge under the status bar;
// floating controls position themselves with insets.top.
contentInsetAdjustmentBehavior="never"
scrollEnabled
>
{/* Hero gallery — full-bleed under the status bar */}
<View onLayout={onHeroLayout}>
{images.length > 1 && heroWidth > 0 ? (
<ScrollView
ref={galleryRef}
horizontal
className="grow-0"
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={onHeroScroll}
accessibilityLabel={`${product.name} photo gallery`}
>
{images.map((uri, i) => (
// Explicit page width — the Image wrapper's own w-full can
// mis-measure inside a horizontal ScrollView on web.
<View key={uri} style={{ width: heroWidth }}>
<Image
source={{ uri }}
rounded="none"
aspectRatio={4 / 5}
className="w-full"
accessibilityLabel={`${product.name}, photo ${i + 1} of ${images.length}`}
/>
</View>
))}
</ScrollView>
) : (
// Single image, or the first frame while the gallery measures.
<Image
source={images[0] ? { uri: images[0] } : undefined}
rounded="none"
aspectRatio={4 / 5}
className="w-full"
accessibilityLabel={`Product image for ${product.name}`}
/>
)}
{onBack ? (
<Glass interactive effect="regular" style={{ top: insets.top + 8 }} className="absolute left-4 rounded-full">
<Press
onPress={() => {
haptics.light();
onBack();
}}
accessibilityRole="button"
accessibilityLabel="Go back"
hitSlop={12}
className="h-10 w-10 items-center justify-center rounded-full"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</Glass>
) : null}
<Glass interactive effect="regular" style={{ top: insets.top + 8 }} className="absolute right-4 rounded-full">
<Press
onPress={() => {
haptics.selection();
setWishlisted((w) => !w);
onToggleWishlist?.(product, !wishlisted);
}}
accessibilityRole="button"
accessibilityLabel={wishlisted ? "Remove from wishlist" : "Add to wishlist"}
accessibilityState={{ selected: wishlisted }}
hitSlop={12}
className="h-10 w-10 items-center justify-center rounded-full"
>
<Icon name="heart" size="md" color={wishlisted ? "destructive" : "foreground"} filled={wishlisted} />
</Press>
</Glass>
{images.length > 1 ? (
<Glass interactive
effect="clear"
className="absolute bottom-4 self-center flex-row items-center gap-1.5 rounded-full px-2.5 py-2"
accessibilityElementsHidden
importantForAccessibility="no"
>
{images.map((uri, i) => (
<View
key={uri}
className={cn("h-1.5 rounded-full bg-card", i === page ? "w-4" : "w-1.5 opacity-50")}
/>
))}
</Glass>
) : null}
{/* Thumbnail rail — taps jump the gallery; the ref's right-edge
strip, kept translucent so the hero shows through. */}
{images.length > 1 ? (
<View className="absolute bottom-4 right-4 gap-2">
{images.map((uri, i) => (
<Press
key={uri}
onPress={() => {
haptics.selection();
galleryRef.current?.scrollTo({ x: i * heroWidth, animated: true });
setPage(i);
}}
accessibilityRole="button"
accessibilityLabel={`Photo ${i + 1}`}
>
<Image
source={{ uri }}
rounded="lg"
className={cn(
"h-14 w-14 border-2",
i === page ? "border-white" : "border-white/40 opacity-70",
)}
accessibilityElementsHidden
importantForAccessibility="no"
/>
</Press>
))}
</View>
) : null}
{product.badge ? (
<View className="absolute bottom-4 left-4 rounded-full bg-[#BEF04C] px-3 py-1.5">
<Text className="text-[11px] font-semibold text-[#1B1B26]">
{product.badge}
</Text>
</View>
) : null}
</View>
<View className="gap-6 px-6 pt-5">
{/* Brand / name / price / rating */}
<Mount>
<View className="gap-1.5">
{product.brand ? (
<View className="flex-row items-center gap-2">
<LumioMark size={18} />
<Text variant="muted" className="text-xs uppercase tracking-wider">
{product.brand}
</Text>
</View>
) : null}
<Text variant="h3" style={{ fontFamily: SERIF }}>{product.name}</Text>
{/* Demand signal — product pages that feel alive show who's watching. */}
<Text variant="muted" className="pt-0.5 text-xs">
21 people are viewing this · restocked this week
</Text>
<View className="flex-row items-center justify-between pt-1">
<View className="flex-row items-center gap-4">
<Text variant="h4" className="tabular-nums">
{fmt(product.price, product.currency)}
</Text>
{/* Qty stepper — the ref's capsule, horizontal here so it
sits in the price row like a real PDP. */}
<View className="flex-row items-center gap-2.5 rounded-full bg-muted/60 px-2 py-1.5">
<Press
onPress={() => {
haptics.selection();
setQty((q) => Math.max(1, q - 1));
}}
accessibilityRole="button"
accessibilityLabel="Decrease quantity"
hitSlop={6}
className="h-7 w-7 items-center justify-center rounded-full bg-card"
>
<Icon name="minus" size="sm" color="foreground" />
</Press>
<Text variant="small" className="w-5 text-center font-semibold tabular-nums">
{String(qty).padStart(2, "0")}
</Text>
<Press
onPress={() => {
haptics.selection();
setQty((q) => q + 1);
}}
accessibilityRole="button"
accessibilityLabel="Increase quantity"
hitSlop={6}
className="h-7 w-7 items-center justify-center rounded-full bg-[#1B1B26]"
>
<Icon name="plus" size="sm" color="#FFFFFF" />
</Press>
</View>
</View>
{product.rating != null ? (
<View
className="flex-row items-center gap-2"
accessibilityLabel={`Rated ${product.rating} out of 5${
product.reviewCount != null ? `, ${product.reviewCount} reviews` : ""
}`}
>
<Stars rating={product.rating} />
<Text variant="muted" className="tabular-nums">
{product.rating.toFixed(1)}
{product.reviewCount != null ? ` (${product.reviewCount.toLocaleString()})` : ""}
</Text>
</View>
) : null}
</View>
</View>
</Mount>
<Mount delay={BLOCK_STAGGER}>
<View className="gap-6">
<ChipRow
label="Color"
options={product.colors ?? []}
selected={color}
// Chip fires haptics.selection() internally — don't double it.
onSelect={setColor}
/>
<ChipRow
label="Size"
options={product.sizes ?? []}
disabledOptions={product.unavailableSizes}
selected={size}
onSelect={setSize}
trailing={
onSizeGuide ? (
<Press
onPress={() => {
haptics.light();
onSizeGuide();
}}
accessibilityRole="button"
accessibilityLabel="Open size guide"
hitSlop={12}
className="min-h-11 flex-row items-center justify-center gap-1.5"
>
<Icon name="info" size="sm" color="#65A30D" />
<Text variant="small" className="text-[#65A30D]">
Size guide
</Text>
</Press>
) : undefined
}
/>
</View>
</Mount>
{product.description ? (
<Mount delay={BLOCK_STAGGER * 2}>
<View className="gap-2">
<Text variant="small" className="font-semibold">
Description
</Text>
<Text variant="muted" className="leading-6">
{product.description}
</Text>
</View>
</Mount>
) : null}
{product.shipping || product.returns ? (
<Mount delay={BLOCK_STAGGER * 3}>
<View className="gap-3.5 rounded-3xl bg-muted/50 p-4">
{product.shipping ? (
<InfoRow icon="package" label={product.shipping.label} detail={product.shipping.detail} />
) : null}
{product.shipping && product.returns ? <Separator /> : null}
{product.returns ? (
<InfoRow icon="repeat" label={product.returns.label} detail={product.returns.detail} />
) : null}
</View>
</Mount>
) : null}
{product.reviews && product.reviews.length > 0 ? (
<Mount delay={BLOCK_STAGGER * 4}>
<View className="gap-3">
<View className="flex-row items-center justify-between">
<Text variant="small" className="font-semibold">
Reviews{product.reviewCount != null ? ` (${product.reviewCount.toLocaleString()})` : ""}
</Text>
{onSeeAllReviews ? (
<Press
onPress={onSeeAllReviews}
accessibilityRole="button"
accessibilityLabel="See all reviews"
hitSlop={12}
className="min-h-11 justify-center"
>
<Text variant="small" className="text-[#65A30D]">
See all
</Text>
</Press>
) : null}
</View>
<View className="gap-4 rounded-3xl bg-muted/50 p-4">
{/* Rating summary — the anchor of the card. */}
{product.rating != null ? (
<View
className="flex-row items-center gap-3"
accessibilityLabel={`Average rating ${product.rating} out of 5${
product.reviewCount != null ? ` from ${product.reviewCount} reviews` : ""
}`}
>
<Text variant="h2" className="tabular-nums">
{product.rating.toFixed(1)}
</Text>
<View className="gap-1">
<Stars rating={product.rating} />
{product.reviewCount != null ? (
<Text variant="muted" className="text-xs">
Based on {product.reviewCount.toLocaleString()} reviews
</Text>
) : null}
</View>
</View>
) : null}
{product.reviews.slice(0, 1).map((review) => (
<View key={review.author} className="gap-2">
<Separator />
<View className="flex-row items-center gap-3">
<Avatar size="sm" fallback={review.author.slice(0, 1)} alt={`${review.author} avatar`} />
<View className="flex-1">
<Text variant="small">{review.author}</Text>
<Text variant="muted" className="text-xs">
{review.date}
</Text>
</View>
<Stars rating={review.rating} />
</View>
<Text variant="muted" className="leading-5" numberOfLines={3}>
{review.text}
</Text>
<View className="flex-row items-center gap-1.5 pt-0.5">
<Icon name="check-circle" size="sm" color="success" />
<Text variant="muted" className="text-xs">
Verified purchase
</Text>
</View>
</View>
))}
</View>
</View>
</Mount>
) : null}
</View>
</ScrollView>
{/* Sticky bottom bar — price + Add to bag over the scrolling detail. */}
<StickyBottomBar>
<Button
tone="inverse" className="w-full"
haptic="heavy"
icon={<Icon name="shopping-cart" size="sm" color="primaryForeground" />}
onPress={() => onAddToCart?.(product, { color, size })}
accessibilityLabel={`Add ${qty} of ${product.name} to bag, ${fmt(product.price * qty, product.currency)}`}
>
<Text className="text-[15px] font-semibold text-background">
Buy Now · <Text className="font-bold tabular-nums text-background">{fmt(product.price * qty, product.currency)}</Text>
</Text>
</Button>
</StickyBottomBar>
</View>
);
}
```
Screen: https://lumioui.dev/screens/ecommerce-product-detail