v0.1 public beta early access on GitHub
Home / Screens / ecommerce-product-detail
Screen template

Product Detail Screen

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

pro Expo Go ✓ iosandroid gestures: tap · scroll · swipe haptics: light · selection · heavy
$ npx lumioui add ecommerce-product-detail
import { ProductDetailScreen } from "@/screens/ecommerce/product-detail";
Native packages installed6
@lumioui/ui@latest@lumioui/core@latestreact-native-safe-area-context@*react-native-reanimated@*react-native-svg@*nativewind@*
ecommerce/product-detail.tsxsource · yours to edit
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>
  );
}
agent prompt — paste into Claude Code, Cursor, Devin…
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
Pixel 10 · Android
9:41
Live preview — the real screen via Expo Web · open full ↗