Brand-led storefront header, fashion two-column 4:5 product feed with compare-at prices, ratings, filter + cycling-sort chips, wishlist hearts, and staggered card mount
import * as React from "react";
import { useMemo, useState } from "react";
import { View, FlatList, ScrollView } from "react-native";
import Animated, { useAnimatedStyle, useSharedValue, withSequence, withSpring, withTiming } from "react-native-reanimated";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Chip, EmptyState, Glass, Icon, Image, SearchInput, Skeleton, Text } from "@lumioui/ui";
import { AnimatedPressable, spring, timing, useHaptic, usePressAnimation, useReducedMotion } from "@lumioui/core";
import { catalogImg, LumioMark, Mount, Press, ROW_STAGGER, ROW_STAGGER_CAP } from "../shared";
export interface Product {
id: string;
name: string;
/** Brand/designer line shown above the name. */
brand?: string;
price: number;
/** Original price, struck through when higher than price. */
compareAtPrice?: number;
currency?: string;
rating?: number;
imageUrl?: string;
/** Category key used by the filter chips. */
category?: string;
/** Small badge on the image ("New", "Limited"). */
tag?: string;
wishlisted?: boolean;
}
export interface ProductGridScreenProps {
products?: Product[];
title?: string;
/** Store/wordmark line above the title — the brand-led header. */
storeName?: string;
/** Show skeleton placeholders while products load. */
loading?: boolean;
/** Bag count on the header button; 0 hides the badge. */
cartCount?: number;
onProductPress?: (product: Product) => void;
onToggleWishlist?: (product: Product, wishlisted: boolean) => void;
onSearch?: (query: string) => void;
onCartPress?: () => void;
}
const img = (p: Parameters<typeof catalogImg>[0]) => catalogImg(p, 600, 750);
const DEFAULT_PRODUCTS: Product[] = [
{ id: "1", name: "Structured Wool Overcoat", brand: "Nord Standard", price: 248, compareAtPrice: 320, rating: 4.9, category: "Outerwear", tag: "New", imageUrl: img("overcoat"), wishlisted: true },
{ id: "2", name: "Cream Knit Poncho", brand: "Atelier M", price: 168, rating: 4.7, category: "Knitwear", imageUrl: img("poncho") },
{ id: "3", name: "Leather Lace-Up Boots", brand: "Marchetti & Sons", price: 295, rating: 4.8, category: "Footwear", imageUrl: img("boots") },
{ id: "4", name: "Garment-Dyed Tee", brand: "Nord Standard", price: 118, rating: 4.6, category: "Tops", imageUrl: img("tee") },
{ id: "5", name: "Quilted Puffer Jacket", brand: "Fieldline", price: 210, rating: 4.5, category: "Outerwear", imageUrl: img("puffer") },
{ id: "6", name: "Tailored Check Blazer", brand: "Marchetti & Sons", price: 340, rating: 4.9, category: "Tailoring", imageUrl: img("blazer") },
{ id: "7", name: "Heritage Graphic Tee", brand: "Atelier M", price: 85, compareAtPrice: 110, rating: 4.4, category: "Tops", tag: "New", imageUrl: img("graphicTee") },
{ id: "8", name: "Court Leather Sneakers", brand: "Fieldline", price: 145, rating: 4.6, category: "Footwear", imageUrl: img("sneakers") },
];
type SortKey = "featured" | "price-asc" | "price-desc" | "rating";
const SORT_ORDER: { key: SortKey; label: string; icon: "star" | "arrow-up" | "arrow-down" }[] = [
{ key: "featured", label: "Featured", icon: "star" },
{ key: "price-asc", label: "Price: low to high", icon: "arrow-up" },
{ key: "price-desc", label: "Price: high to low", icon: "arrow-down" },
{ key: "rating", label: "Top rated", icon: "star" },
];
/** Prices always render with two decimals ($248.00). */
const fmt = (n: number, currency = "$") =>
`${currency}${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
interface ProductCardProps {
item: Product;
index: number;
wishlisted: boolean;
onPress?: (product: Product) => void;
onToggleWishlist?: (product: Product, wishlisted: boolean) => void;
}
function ProductCard({ item, index, wishlisted, onPress, onToggleWishlist }: ProductCardProps) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const press = usePressAnimation({ pressedScale: 0.97 });
const onSale = item.compareAtPrice != null && item.compareAtPrice > item.price;
// Heart pop — a quick squash-and-bounce when wishlist flips so the
// state change lands physically instead of just repainting.
const heartScale = useSharedValue(1);
React.useEffect(() => {
if (reduce) return;
heartScale.value = withSequence(
withTiming(1.35, timing.instant),
withSpring(1, spring.bouncy)
);
}, [wishlisted, reduce, heartScale]);
const heartStyle = useAnimatedStyle(() => ({
transform: [{ scale: heartScale.value }],
}));
return (
<Mount delay={Math.min(index * ROW_STAGGER, ROW_STAGGER_CAP)} className="flex-1">
<AnimatedPressable
className="flex-1"
style={press.animatedStyle}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
onPress={() => {
haptics.light();
onPress?.(item);
}}
accessibilityRole="button"
accessibilityLabel={`${item.brand ? `${item.brand} ` : ""}${item.name}, ${fmt(item.price, item.currency)}`}
>
<View>
<Image
source={item.imageUrl ? { uri: item.imageUrl } : undefined}
rounded="xl"
aspectRatio={4 / 5}
className="w-full"
accessibilityLabel={`Photo of ${item.name}`}
/>
<Glass interactive effect="clear" className="absolute right-2 top-2 rounded-full">
<Press
onPress={() => {
haptics.selection();
onToggleWishlist?.(item, !wishlisted);
}}
accessibilityRole="button"
accessibilityLabel={
wishlisted
? `Remove ${item.name} from wishlist`
: `Add ${item.name} to wishlist`
}
accessibilityState={{ selected: wishlisted }}
hitSlop={12}
className="h-9 w-9 items-center justify-center rounded-full"
>
<Animated.View style={heartStyle}>
<Icon
name="heart"
size="sm"
color={wishlisted ? "destructive" : "foreground"}
filled={wishlisted}
/>
</Animated.View>
</Press>
</Glass>
{item.tag ? (
<View className="absolute bottom-2 left-2 rounded-full bg-[#BEF04C] px-2.5 py-1">
<Text className="text-[11px] font-semibold text-[#1B1B26]">
{item.tag}
</Text>
</View>
) : null}
</View>
<View className="gap-0.5 pt-2">
{item.brand ? (
<Text variant="muted" className="text-xs uppercase tracking-wider" numberOfLines={1}>
{item.brand}
</Text>
) : null}
<Text variant="small" numberOfLines={1}>
{item.name}
</Text>
<View className="flex-row items-center justify-between pt-0.5">
<View className="flex-row items-baseline gap-1.5">
<Text variant="small" className="font-semibold tabular-nums">
{fmt(item.price, item.currency)}
</Text>
{onSale ? (
<Text variant="muted" className="text-xs tabular-nums line-through">
{fmt(item.compareAtPrice!, item.currency)}
</Text>
) : null}
</View>
{item.rating != null ? (
<View className="flex-row items-center gap-1">
<Icon name="star" size="sm" color="warning" filled />
<Text variant="muted" className="text-xs tabular-nums">
{item.rating.toFixed(1)}
</Text>
</View>
) : null}
</View>
</View>
</AnimatedPressable>
</Mount>
);
}
ProductCard.displayName = "ProductCard";
/** Product grid — fashion-idiom 2-up feed: 4:5 imagery, brand + name + price,
* wishlist heart, and a pinned filter/sort chip row. */
export function ProductGridScreen({
products = DEFAULT_PRODUCTS,
title = "Shop",
storeName = "Maison Lumière",
loading = false,
// Matches the cart screen's seeded bag — one shopping universe.
cartCount = 4,
onProductPress,
onToggleWishlist,
onSearch,
onCartPress,
}: ProductGridScreenProps) {
const insets = useSafeAreaInsets();
const haptics = useHaptic();
const [query, setQuery] = useState("");
const [category, setCategory] = useState("All");
const [sortIndex, setSortIndex] = useState(0);
// Local mirror so the heart flips even when the parent doesn't pass state back.
const [wishlist, setWishlist] = useState<Set<string>>(
() => new Set(products.filter((p) => p.wishlisted).map((p) => p.id))
);
const categories = useMemo(() => {
const set = new Set<string>();
products.forEach((p) => p.category && set.add(p.category));
return ["All", ...Array.from(set)];
}, [products]);
// sortIndex is always kept within bounds by cycleSort's modulo.
const sort = SORT_ORDER[sortIndex] ?? SORT_ORDER[0]!;
const cycleSort = () => setSortIndex((i) => (i + 1) % SORT_ORDER.length);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
let list = products.filter(
(p) =>
(category === "All" || p.category === category) &&
(q === "" ||
p.name.toLowerCase().includes(q) ||
(p.brand ?? "").toLowerCase().includes(q))
);
if (sort.key === "price-asc") list = [...list].sort((a, b) => a.price - b.price);
if (sort.key === "price-desc") list = [...list].sort((a, b) => b.price - a.price);
if (sort.key === "rating") list = [...list].sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0));
return list;
}, [products, category, query, sort.key]);
const clearSearch = () => {
setQuery("");
onSearch?.("");
};
const toggleWishlist = (item: Product, next: boolean) => {
setWishlist((prev) => {
const copy = new Set(prev);
if (next) copy.add(item.id);
else copy.delete(item.id);
return copy;
});
onToggleWishlist?.(item, next);
};
if (loading) {
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<View className="gap-3 px-6 pb-2 pt-4">
<Text variant="h3">{title}</Text>
<Skeleton className="h-12 rounded-full" />
<View className="flex-row gap-2 pt-1">
<Skeleton className="h-9 w-16 rounded-full" />
<Skeleton className="h-9 w-24 rounded-full" />
<Skeleton className="h-9 w-20 rounded-full" />
</View>
</View>
<View className="flex-row gap-3 px-6 pb-8 pt-4">
{[0, 1].map((col) => (
<View key={col} className="flex-1 gap-3">
<Skeleton className="aspect-[4/5] w-full rounded-xl" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/3" />
</View>
))}
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<View className="gap-3 px-6 pb-2 pt-4">
{storeName ? (
<View className="flex-row items-center gap-2">
<LumioMark size={20} />
<Text variant="muted" className="text-xs font-semibold uppercase tracking-widest">
{storeName}
</Text>
</View>
) : null}
<View className="flex-row items-center justify-between">
<View className="gap-0.5">
<Text variant="h2">{title}</Text>
{/* Freshness signal — store feeds surface what's new. */}
<Text variant="muted" className="text-xs">
New drop every Friday
</Text>
</View>
<View className="flex-row items-center gap-3">
<Text variant="muted">{visible.length} items</Text>
{cartCount != null ? (
<Press
onPress={() => {
haptics.light();
onCartPress?.();
}}
accessibilityRole="button"
accessibilityLabel={
cartCount > 0 ? `Bag, ${cartCount} items` : "Bag"
}
hitSlop={8}
className="h-10 w-10 items-center justify-center rounded-full bg-muted"
>
<Icon name="shopping-cart" size="md" color="foreground" />
{cartCount > 0 ? (
<View
className="absolute -right-0.5 -top-0.5 h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1"
accessibilityElementsHidden
importantForAccessibility="no"
>
<Text className="text-[10px] font-bold leading-none text-destructive-foreground">
{cartCount > 99 ? "99+" : cartCount}
</Text>
</View>
) : null}
</Press>
) : null}
</View>
</View>
<SearchInput
placeholder="Search products, brands…"
value={query}
onChangeText={(text) => {
setQuery(text);
onSearch?.(text);
}}
onClear={clearSearch}
returnKeyType="search"
accessibilityLabel="Search products"
/>
</View>
{/* Filter + sort chips — pinned above the grid */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="grow-0"
contentContainerClassName="gap-2 px-6 py-2"
keyboardShouldPersistTaps="handled"
>
{categories.map((c) => (
<Chip
key={c}
variant="filter"
selected={c === category}
selectedClassName="border-[#BEF04C] bg-[#BEF04C]"
selectedTextClassName="font-semibold text-[#1B1B26]"
onPress={() => setCategory(c)}
accessibilityLabel={`Filter by ${c}`}
className="h-10 px-4"
>
{c}
</Chip>
))}
<View className="mx-1 w-px self-stretch bg-border" accessibilityElementsHidden importantForAccessibility="no" />
<Chip
variant="input"
onPress={cycleSort}
icon={<Icon name={sort.icon} size="sm" color="mutedForeground" />}
accessibilityLabel={`Sort products. Current: ${sort.label}. Activate to change.`}
className="h-10 px-4"
>
{sort.label}
</Chip>
</ScrollView>
<FlatList
data={visible}
keyExtractor={(item) => item.id}
numColumns={2}
className="flex-1"
columnWrapperStyle={{ gap: 12 }}
contentContainerClassName="gap-5 px-6 pt-2"
contentContainerStyle={{ paddingBottom: insets.bottom + 16 }}
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
ListEmptyComponent={
<EmptyState
variant="search"
title="No products found"
description={
query
? `Nothing matches "${query}".`
: "No products in this category yet."
}
action={
query || category !== "All" ? (
<Button
variant="secondary"
onPress={() => {
clearSearch();
setCategory("All");
}}
>
Clear filters
</Button>
) : undefined
}
/>
}
renderItem={({ item, index }) => (
<ProductCard
item={item}
index={index}
wishlisted={wishlist.has(item.id)}
onPress={onProductPress}
onToggleWishlist={toggleWishlist}
/>
)}
/>
</SafeAreaView>
);
}
Recreate the "Product Grid Screen" screen in React Native using LumioUI.
Goal: Brand-led storefront header, fashion two-column 4:5 product feed with compare-at prices, ratings, filter + cycling-sort chips, wishlist hearts, and staggered card mount 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-grid
```
Or pull just the components: `npx lumioui add badge button chip empty-state glass icon image search-input skeleton text`
Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-reanimated@*
- react-native-safe-area-context@*
- 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
### `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>` ×2
### `empty-state` — Illustrated placeholder with title, copy and action slot.
- `<EmptyState>` ×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>` ×1
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×4
### `image` — Cached image with placeholder, fallback and aspect handling.
- `<Image>` ×1
### `search-input` — Search field with clear button, debounce, focus halo — plus a glass capsule mode that floats over content like iOS 26
- `<SearchInput>` ×1
### `skeleton` — Shimmer placeholder that respects reduced motion.
- `<Skeleton>` ×8
### `text` — Themed type scale from caption to display.
- `<Text>` ×12
## 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-grid.tsx` exporting ProductGridScreen. 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 { useMemo, useState } from "react";
import { View, FlatList, ScrollView } from "react-native";
import Animated, { useAnimatedStyle, useSharedValue, withSequence, withSpring, withTiming } from "react-native-reanimated";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Chip, EmptyState, Glass, Icon, Image, SearchInput, Skeleton, Text } from "@lumioui/ui";
import { AnimatedPressable, spring, timing, useHaptic, usePressAnimation, useReducedMotion } from "@lumioui/core";
import { catalogImg, LumioMark, Mount, Press, ROW_STAGGER, ROW_STAGGER_CAP } from "../shared";
export interface Product {
id: string;
name: string;
/** Brand/designer line shown above the name. */
brand?: string;
price: number;
/** Original price, struck through when higher than price. */
compareAtPrice?: number;
currency?: string;
rating?: number;
imageUrl?: string;
/** Category key used by the filter chips. */
category?: string;
/** Small badge on the image ("New", "Limited"). */
tag?: string;
wishlisted?: boolean;
}
export interface ProductGridScreenProps {
products?: Product[];
title?: string;
/** Store/wordmark line above the title — the brand-led header. */
storeName?: string;
/** Show skeleton placeholders while products load. */
loading?: boolean;
/** Bag count on the header button; 0 hides the badge. */
cartCount?: number;
onProductPress?: (product: Product) => void;
onToggleWishlist?: (product: Product, wishlisted: boolean) => void;
onSearch?: (query: string) => void;
onCartPress?: () => void;
}
const img = (p: Parameters<typeof catalogImg>[0]) => catalogImg(p, 600, 750);
const DEFAULT_PRODUCTS: Product[] = [
{ id: "1", name: "Structured Wool Overcoat", brand: "Nord Standard", price: 248, compareAtPrice: 320, rating: 4.9, category: "Outerwear", tag: "New", imageUrl: img("overcoat"), wishlisted: true },
{ id: "2", name: "Cream Knit Poncho", brand: "Atelier M", price: 168, rating: 4.7, category: "Knitwear", imageUrl: img("poncho") },
{ id: "3", name: "Leather Lace-Up Boots", brand: "Marchetti & Sons", price: 295, rating: 4.8, category: "Footwear", imageUrl: img("boots") },
{ id: "4", name: "Garment-Dyed Tee", brand: "Nord Standard", price: 118, rating: 4.6, category: "Tops", imageUrl: img("tee") },
{ id: "5", name: "Quilted Puffer Jacket", brand: "Fieldline", price: 210, rating: 4.5, category: "Outerwear", imageUrl: img("puffer") },
{ id: "6", name: "Tailored Check Blazer", brand: "Marchetti & Sons", price: 340, rating: 4.9, category: "Tailoring", imageUrl: img("blazer") },
{ id: "7", name: "Heritage Graphic Tee", brand: "Atelier M", price: 85, compareAtPrice: 110, rating: 4.4, category: "Tops", tag: "New", imageUrl: img("graphicTee") },
{ id: "8", name: "Court Leather Sneakers", brand: "Fieldline", price: 145, rating: 4.6, category: "Footwear", imageUrl: img("sneakers") },
];
type SortKey = "featured" | "price-asc" | "price-desc" | "rating";
const SORT_ORDER: { key: SortKey; label: string; icon: "star" | "arrow-up" | "arrow-down" }[] = [
{ key: "featured", label: "Featured", icon: "star" },
{ key: "price-asc", label: "Price: low to high", icon: "arrow-up" },
{ key: "price-desc", label: "Price: high to low", icon: "arrow-down" },
{ key: "rating", label: "Top rated", icon: "star" },
];
/** Prices always render with two decimals ($248.00). */
const fmt = (n: number, currency = "$") =>
`${currency}${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
interface ProductCardProps {
item: Product;
index: number;
wishlisted: boolean;
onPress?: (product: Product) => void;
onToggleWishlist?: (product: Product, wishlisted: boolean) => void;
}
function ProductCard({ item, index, wishlisted, onPress, onToggleWishlist }: ProductCardProps) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const press = usePressAnimation({ pressedScale: 0.97 });
const onSale = item.compareAtPrice != null && item.compareAtPrice > item.price;
// Heart pop — a quick squash-and-bounce when wishlist flips so the
// state change lands physically instead of just repainting.
const heartScale = useSharedValue(1);
React.useEffect(() => {
if (reduce) return;
heartScale.value = withSequence(
withTiming(1.35, timing.instant),
withSpring(1, spring.bouncy)
);
}, [wishlisted, reduce, heartScale]);
const heartStyle = useAnimatedStyle(() => ({
transform: [{ scale: heartScale.value }],
}));
return (
<Mount delay={Math.min(index * ROW_STAGGER, ROW_STAGGER_CAP)} className="flex-1">
<AnimatedPressable
className="flex-1"
style={press.animatedStyle}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
onPress={() => {
haptics.light();
onPress?.(item);
}}
accessibilityRole="button"
accessibilityLabel={`${item.brand ? `${item.brand} ` : ""}${item.name}, ${fmt(item.price, item.currency)}`}
>
<View>
<Image
source={item.imageUrl ? { uri: item.imageUrl } : undefined}
rounded="xl"
aspectRatio={4 / 5}
className="w-full"
accessibilityLabel={`Photo of ${item.name}`}
/>
<Glass interactive effect="clear" className="absolute right-2 top-2 rounded-full">
<Press
onPress={() => {
haptics.selection();
onToggleWishlist?.(item, !wishlisted);
}}
accessibilityRole="button"
accessibilityLabel={
wishlisted
? `Remove ${item.name} from wishlist`
: `Add ${item.name} to wishlist`
}
accessibilityState={{ selected: wishlisted }}
hitSlop={12}
className="h-9 w-9 items-center justify-center rounded-full"
>
<Animated.View style={heartStyle}>
<Icon
name="heart"
size="sm"
color={wishlisted ? "destructive" : "foreground"}
filled={wishlisted}
/>
</Animated.View>
</Press>
</Glass>
{item.tag ? (
<View className="absolute bottom-2 left-2 rounded-full bg-[#BEF04C] px-2.5 py-1">
<Text className="text-[11px] font-semibold text-[#1B1B26]">
{item.tag}
</Text>
</View>
) : null}
</View>
<View className="gap-0.5 pt-2">
{item.brand ? (
<Text variant="muted" className="text-xs uppercase tracking-wider" numberOfLines={1}>
{item.brand}
</Text>
) : null}
<Text variant="small" numberOfLines={1}>
{item.name}
</Text>
<View className="flex-row items-center justify-between pt-0.5">
<View className="flex-row items-baseline gap-1.5">
<Text variant="small" className="font-semibold tabular-nums">
{fmt(item.price, item.currency)}
</Text>
{onSale ? (
<Text variant="muted" className="text-xs tabular-nums line-through">
{fmt(item.compareAtPrice!, item.currency)}
</Text>
) : null}
</View>
{item.rating != null ? (
<View className="flex-row items-center gap-1">
<Icon name="star" size="sm" color="warning" filled />
<Text variant="muted" className="text-xs tabular-nums">
{item.rating.toFixed(1)}
</Text>
</View>
) : null}
</View>
</View>
</AnimatedPressable>
</Mount>
);
}
ProductCard.displayName = "ProductCard";
/** Product grid — fashion-idiom 2-up feed: 4:5 imagery, brand + name + price,
* wishlist heart, and a pinned filter/sort chip row. */
export function ProductGridScreen({
products = DEFAULT_PRODUCTS,
title = "Shop",
storeName = "Maison Lumière",
loading = false,
// Matches the cart screen's seeded bag — one shopping universe.
cartCount = 4,
onProductPress,
onToggleWishlist,
onSearch,
onCartPress,
}: ProductGridScreenProps) {
const insets = useSafeAreaInsets();
const haptics = useHaptic();
const [query, setQuery] = useState("");
const [category, setCategory] = useState("All");
const [sortIndex, setSortIndex] = useState(0);
// Local mirror so the heart flips even when the parent doesn't pass state back.
const [wishlist, setWishlist] = useState<Set<string>>(
() => new Set(products.filter((p) => p.wishlisted).map((p) => p.id))
);
const categories = useMemo(() => {
const set = new Set<string>();
products.forEach((p) => p.category && set.add(p.category));
return ["All", ...Array.from(set)];
}, [products]);
// sortIndex is always kept within bounds by cycleSort's modulo.
const sort = SORT_ORDER[sortIndex] ?? SORT_ORDER[0]!;
const cycleSort = () => setSortIndex((i) => (i + 1) % SORT_ORDER.length);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
let list = products.filter(
(p) =>
(category === "All" || p.category === category) &&
(q === "" ||
p.name.toLowerCase().includes(q) ||
(p.brand ?? "").toLowerCase().includes(q))
);
if (sort.key === "price-asc") list = [...list].sort((a, b) => a.price - b.price);
if (sort.key === "price-desc") list = [...list].sort((a, b) => b.price - a.price);
if (sort.key === "rating") list = [...list].sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0));
return list;
}, [products, category, query, sort.key]);
const clearSearch = () => {
setQuery("");
onSearch?.("");
};
const toggleWishlist = (item: Product, next: boolean) => {
setWishlist((prev) => {
const copy = new Set(prev);
if (next) copy.add(item.id);
else copy.delete(item.id);
return copy;
});
onToggleWishlist?.(item, next);
};
if (loading) {
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<View className="gap-3 px-6 pb-2 pt-4">
<Text variant="h3">{title}</Text>
<Skeleton className="h-12 rounded-full" />
<View className="flex-row gap-2 pt-1">
<Skeleton className="h-9 w-16 rounded-full" />
<Skeleton className="h-9 w-24 rounded-full" />
<Skeleton className="h-9 w-20 rounded-full" />
</View>
</View>
<View className="flex-row gap-3 px-6 pb-8 pt-4">
{[0, 1].map((col) => (
<View key={col} className="flex-1 gap-3">
<Skeleton className="aspect-[4/5] w-full rounded-xl" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/3" />
</View>
))}
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<View className="gap-3 px-6 pb-2 pt-4">
{storeName ? (
<View className="flex-row items-center gap-2">
<LumioMark size={20} />
<Text variant="muted" className="text-xs font-semibold uppercase tracking-widest">
{storeName}
</Text>
</View>
) : null}
<View className="flex-row items-center justify-between">
<View className="gap-0.5">
<Text variant="h2">{title}</Text>
{/* Freshness signal — store feeds surface what's new. */}
<Text variant="muted" className="text-xs">
New drop every Friday
</Text>
</View>
<View className="flex-row items-center gap-3">
<Text variant="muted">{visible.length} items</Text>
{cartCount != null ? (
<Press
onPress={() => {
haptics.light();
onCartPress?.();
}}
accessibilityRole="button"
accessibilityLabel={
cartCount > 0 ? `Bag, ${cartCount} items` : "Bag"
}
hitSlop={8}
className="h-10 w-10 items-center justify-center rounded-full bg-muted"
>
<Icon name="shopping-cart" size="md" color="foreground" />
{cartCount > 0 ? (
<View
className="absolute -right-0.5 -top-0.5 h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1"
accessibilityElementsHidden
importantForAccessibility="no"
>
<Text className="text-[10px] font-bold leading-none text-destructive-foreground">
{cartCount > 99 ? "99+" : cartCount}
</Text>
</View>
) : null}
</Press>
) : null}
</View>
</View>
<SearchInput
placeholder="Search products, brands…"
value={query}
onChangeText={(text) => {
setQuery(text);
onSearch?.(text);
}}
onClear={clearSearch}
returnKeyType="search"
accessibilityLabel="Search products"
/>
</View>
{/* Filter + sort chips — pinned above the grid */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="grow-0"
contentContainerClassName="gap-2 px-6 py-2"
keyboardShouldPersistTaps="handled"
>
{categories.map((c) => (
<Chip
key={c}
variant="filter"
selected={c === category}
selectedClassName="border-[#BEF04C] bg-[#BEF04C]"
selectedTextClassName="font-semibold text-[#1B1B26]"
onPress={() => setCategory(c)}
accessibilityLabel={`Filter by ${c}`}
className="h-10 px-4"
>
{c}
</Chip>
))}
<View className="mx-1 w-px self-stretch bg-border" accessibilityElementsHidden importantForAccessibility="no" />
<Chip
variant="input"
onPress={cycleSort}
icon={<Icon name={sort.icon} size="sm" color="mutedForeground" />}
accessibilityLabel={`Sort products. Current: ${sort.label}. Activate to change.`}
className="h-10 px-4"
>
{sort.label}
</Chip>
</ScrollView>
<FlatList
data={visible}
keyExtractor={(item) => item.id}
numColumns={2}
className="flex-1"
columnWrapperStyle={{ gap: 12 }}
contentContainerClassName="gap-5 px-6 pt-2"
contentContainerStyle={{ paddingBottom: insets.bottom + 16 }}
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
ListEmptyComponent={
<EmptyState
variant="search"
title="No products found"
description={
query
? `Nothing matches "${query}".`
: "No products in this category yet."
}
action={
query || category !== "All" ? (
<Button
variant="secondary"
onPress={() => {
clearSearch();
setCategory("All");
}}
>
Clear filters
</Button>
) : undefined
}
/>
}
renderItem={({ item, index }) => (
<ProductCard
item={item}
index={index}
wishlisted={wishlist.has(item.id)}
onPress={onProductPress}
onToggleWishlist={toggleWishlist}
/>
)}
/>
</SafeAreaView>
);
}
```
Screen: https://lumioui.dev/screens/ecommerce-product-grid