This commit is contained in:
shafi54 2026-08-16 11:06:06 +05:30
parent 48decbac6f
commit be40f0cffa
7 changed files with 378 additions and 81 deletions

View file

@ -20,3 +20,4 @@
- When updating or creating a document, prefers the agent first compare it against existing source documents, identify missing items or gaps, and add them in the same established format. Confidence: 0.9
- Dislikes nested headers in mobile/drawer navigation; prefers a single, shared header (e.g., the drawer header) and relies on device back buttons or gestures for returning to previous screens. Confidence: 0.9
- After reviewing a presented plan, prefers brief, action-oriented approval (e.g., "nice. go ahead") before implementation proceeds. Confidence: 0.6
- Wants feature parity maintained between the web-ui and user-ui apps (port logic/data patterns, rebuild UI per platform). Confidence: 0.85

View file

@ -1,7 +1,7 @@
import React from 'react'
import { useLocation, useNavigate } from '@tanstack/react-router'
import { p, div } from 'web-components'
import { Home, Store, Zap, RotateCcw, User } from 'lucide-react'
import { Home, Store, Zap, Tag, User } from 'lucide-react'
interface TabItem {
name: string
@ -41,11 +41,11 @@ export function BottomNavigation() {
isCenter: true,
},
{
name: 'order-again',
path: '/me/orders',
label: 'Order Again',
icon: <RotateCcw className="h-5 w-5" />,
iconActive: <RotateCcw className="h-5 w-5 fill-current" />,
name: 'offers',
path: '/offers',
label: 'Offers',
icon: <Tag className="h-5 w-5" />,
iconActive: <Tag className="h-5 w-5 fill-current" />,
},
{
name: 'me',

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react'
import { useMemo, useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import axios from 'axios'
import { trpc } from '../lib/trpc-client'
@ -20,6 +20,68 @@ export const useGetEssentialConsts = () => {
return { ...query, refetch: query.refetch }
}
// ---------------------------------------------------------------------------
// Persisted cache helpers — store fetched JSON keyed by its versioned URL.
// Only refetch + repersist when the version (URL) changes.
// ---------------------------------------------------------------------------
interface PersistedCache<T> {
version: string
data: T
}
const CACHE_STORAGE_KEYS = {
products: 'cache_products',
stores: 'cache_stores',
slots: 'cache_slots',
banners: 'cache_banners',
availability: 'cache_availability',
storeProducts: (storeId: number) => `cache_store_${storeId}`,
} as const
async function readPersistedCache<T>(key: string): Promise<PersistedCache<T> | null> {
try {
const raw = localStorage.getItem(key)
if (!raw) return null
return JSON.parse(raw) as PersistedCache<T>
} catch {
return null
}
}
async function writePersistedCache<T>(key: string, version: string, data: T): Promise<void> {
try {
localStorage.setItem(key, JSON.stringify({ version, data }))
} catch (e) {
console.error('writePersistedCache error:', e)
}
}
function usePersistedCache<T>(storageKey: string, version: string | null) {
const [initialData, setInitialData] = useState<T | undefined>(undefined)
const [isReady, setIsReady] = useState(false)
useEffect(() => {
let cancelled = false
setIsReady(false)
setInitialData(undefined)
if (!version) {
setIsReady(true)
return
}
readPersistedCache<T>(storageKey).then((persisted) => {
if (cancelled) return
if (persisted && persisted.version === version) {
setInitialData(persisted.data)
}
setIsReady(true)
})
return () => { cancelled = true }
}, [storageKey, version])
return { initialData, isReady }
}
type ProductsResponse = AllProductsApiType
type StoresResponse = StoresApiType
type SlotsResponse = SlotsApiType
@ -35,6 +97,8 @@ export type MergedProduct = BaseProduct & {
marketPrice: number | null
flashPrice: string | null
isFlashAvailable: boolean
isOutOfStock: boolean
isSuspended: boolean
}
function useCacheUrl(filename: string): string | null {
@ -85,18 +149,22 @@ function useSlotsCacheUrl(): string | null {
export function useAllProducts() {
const cacheUrl = useCacheUrl(CACHE_FILENAMES.products)
const { data: availabilityData } = useAvailability()
const version = cacheUrl ?? ''
const { initialData, isReady } = usePersistedCache<ProductsResponse>(CACHE_STORAGE_KEYS.products, version)
const productsQuery = useQuery<ProductsResponse>({
queryKey: ['all-products', cacheUrl],
queryKey: ['all-products', version],
queryFn: async () => {
if (!cacheUrl) {
throw new Error('Cache URL not available')
}
const response = await axios.get<ProductsResponse>(cacheUrl)
await writePersistedCache(CACHE_STORAGE_KEYS.products, version, response.data)
return response.data
},
staleTime: 60000,
enabled: !!cacheUrl,
enabled: !!cacheUrl && isReady,
placeholderData: initialData,
})
const mergedProducts = useMemo(() => {
@ -114,6 +182,8 @@ export function useAllProducts() {
marketPrice: availability?.marketPrice != null ? Number(availability.marketPrice) : null,
flashPrice: availability?.flashPrice ?? null,
isFlashAvailable: availability?.isFlashAvailable ?? false,
isOutOfStock: availability?.isOutOfStock ?? false,
isSuspended: availability?.isSuspended ?? false,
}
})
}, [productsQuery.data, availabilityData])
@ -134,69 +204,85 @@ export function useAllProducts() {
export function useAvailability() {
const cacheUrl = useAvailabilityCacheUrl()
const version = cacheUrl ?? ''
const { initialData, isReady } = usePersistedCache<AvailabilityResponse>(CACHE_STORAGE_KEYS.availability, version)
return useQuery<AvailabilityResponse>({
queryKey: ['availability', cacheUrl],
queryKey: ['availability', version],
queryFn: async () => {
if (!cacheUrl) {
throw new Error('Cache URL not available')
}
const response = await axios.get<AvailabilityResponse>(cacheUrl)
await writePersistedCache(CACHE_STORAGE_KEYS.availability, version, response.data)
return response.data
},
staleTime: 60000,
enabled: !!cacheUrl,
enabled: !!cacheUrl && isReady,
placeholderData: initialData,
})
}
export function useStores() {
const cacheUrl = useCacheUrl(CACHE_FILENAMES.stores)
const version = cacheUrl ?? ''
const { initialData, isReady } = usePersistedCache<StoresResponse>(CACHE_STORAGE_KEYS.stores, version)
return useQuery<StoresResponse>({
queryKey: ['stores', cacheUrl],
queryKey: ['stores', version],
queryFn: async () => {
if (!cacheUrl) {
throw new Error('Cache URL not available')
}
const response = await axios.get<StoresResponse>(cacheUrl)
await writePersistedCache(CACHE_STORAGE_KEYS.stores, version, response.data)
return response.data
},
staleTime: 60000,
enabled: !!cacheUrl,
enabled: !!cacheUrl && isReady,
placeholderData: initialData,
})
}
export function useSlots() {
const cacheUrl = useSlotsCacheUrl()
const version = cacheUrl ?? ''
const { initialData, isReady } = usePersistedCache<SlotsResponse>(CACHE_STORAGE_KEYS.slots, version)
return useQuery<SlotsResponse>({
queryKey: ['slots', cacheUrl],
queryKey: ['slots', version],
queryFn: async () => {
if (!cacheUrl) {
throw new Error('Cache URL not available')
}
const response = await axios.get<SlotsResponse>(cacheUrl + '?v=123')
await writePersistedCache(CACHE_STORAGE_KEYS.slots, version, response.data)
return response.data
},
staleTime: 60000,
enabled: !!cacheUrl,
enabled: !!cacheUrl && isReady,
placeholderData: initialData,
})
}
export function useBanners() {
const cacheUrl = useCacheUrl(CACHE_FILENAMES.banners)
const version = cacheUrl ?? ''
const { initialData, isReady } = usePersistedCache<BannersResponse>(CACHE_STORAGE_KEYS.banners, version)
return useQuery<BannersResponse>({
queryKey: ['banners', cacheUrl],
queryKey: ['banners', version],
queryFn: async () => {
if (!cacheUrl) {
throw new Error('Cache URL not available')
}
const response = await axios.get<BannersResponse>(cacheUrl)
await writePersistedCache(CACHE_STORAGE_KEYS.banners, version, response.data)
return response.data
},
staleTime: 60000,
enabled: !!cacheUrl,
enabled: !!cacheUrl && isReady,
placeholderData: initialData,
})
}
@ -211,17 +297,21 @@ export function useStoreWithProducts(storeId: number) {
assetsDomain && apiCacheKey
? `${assetsDomain}${apiCacheKey}/v-${cacheVersion}/stores/${storeId}.json`
: null
const version = cacheUrl ?? ''
const { initialData, isReady } = usePersistedCache<StoreWithProductsResponse>(CACHE_STORAGE_KEYS.storeProducts(storeId), version)
return useQuery<StoreWithProductsResponse>({
queryKey: ['store-with-products', storeId, cacheUrl],
queryKey: ['store-with-products', storeId, version],
queryFn: async () => {
if (!cacheUrl) {
throw new Error('Cache URL not available')
}
const response = await axios.get<StoreWithProductsResponse>(cacheUrl)
await writePersistedCache(CACHE_STORAGE_KEYS.storeProducts(storeId), version, response.data)
return response.data
},
staleTime: 60000,
enabled: !!cacheUrl,
enabled: !!cacheUrl && isReady,
placeholderData: initialData,
})
}

View file

@ -34,8 +34,9 @@ export function usePopulateCentralStores() {
// Check if flash delivery is available
const isFlashAvailable = product.isFlashAvailable || false
// Check if out of stock (no slots available)
const isOutOfStock = productSlots.length === 0 && !isFlashAvailable
// Use the availability-merged out-of-stock flag (from products cache + availability)
// instead of the "no slots" heuristic, so products aren't wrongly marked OOS on startup.
const isOutOfStock = product.isOutOfStock === true || (productSlots.length === 0 && !isFlashAvailable && product.isOutOfStock !== false)
productMap[product.id] = {
slotId: productSlots.length > 0 ? productSlots[0].id : null,

View file

@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as StoresRouteImport } from './routes/stores'
import { Route as SlotViewRouteImport } from './routes/slot-view'
import { Route as RegisterRouteImport } from './routes/register'
import { Route as OffersRouteImport } from './routes/offers'
import { Route as MeRouteImport } from './routes/me'
import { Route as LoginRouteImport } from './routes/login'
import { Route as HomeRouteImport } from './routes/home'
@ -55,6 +56,11 @@ const RegisterRoute = RegisterRouteImport.update({
path: '/register',
getParentRoute: () => rootRouteImport,
} as any)
const OffersRoute = OffersRouteImport.update({
id: '/offers',
path: '/offers',
getParentRoute: () => rootRouteImport,
} as any)
const MeRoute = MeRouteImport.update({
id: '/me',
path: '/me',
@ -200,6 +206,7 @@ export interface FileRoutesByFullPath {
'/home': typeof HomeRouteWithChildren
'/login': typeof LoginRoute
'/me': typeof MeRouteWithChildren
'/offers': typeof OffersRoute
'/register': typeof RegisterRoute
'/slot-view': typeof SlotViewRoute
'/stores': typeof StoresRouteWithChildren
@ -231,6 +238,7 @@ export interface FileRoutesByTo {
'/flash': typeof FlashRouteWithChildren
'/login': typeof LoginRoute
'/me': typeof MeRouteWithChildren
'/offers': typeof OffersRoute
'/register': typeof RegisterRoute
'/slot-view': typeof SlotViewRoute
'/stores': typeof StoresRouteWithChildren
@ -264,6 +272,7 @@ export interface FileRoutesById {
'/home': typeof HomeRouteWithChildren
'/login': typeof LoginRoute
'/me': typeof MeRouteWithChildren
'/offers': typeof OffersRoute
'/register': typeof RegisterRoute
'/slot-view': typeof SlotViewRoute
'/stores': typeof StoresRouteWithChildren
@ -298,6 +307,7 @@ export interface FileRouteTypes {
| '/home'
| '/login'
| '/me'
| '/offers'
| '/register'
| '/slot-view'
| '/stores'
@ -329,6 +339,7 @@ export interface FileRouteTypes {
| '/flash'
| '/login'
| '/me'
| '/offers'
| '/register'
| '/slot-view'
| '/stores'
@ -361,6 +372,7 @@ export interface FileRouteTypes {
| '/home'
| '/login'
| '/me'
| '/offers'
| '/register'
| '/slot-view'
| '/stores'
@ -394,6 +406,7 @@ export interface RootRouteChildren {
HomeRoute: typeof HomeRouteWithChildren
LoginRoute: typeof LoginRoute
MeRoute: typeof MeRouteWithChildren
OffersRoute: typeof OffersRoute
RegisterRoute: typeof RegisterRoute
SlotViewRoute: typeof SlotViewRoute
StoresRoute: typeof StoresRouteWithChildren
@ -422,6 +435,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof RegisterRouteImport
parentRoute: typeof rootRouteImport
}
'/offers': {
id: '/offers'
path: '/offers'
fullPath: '/offers'
preLoaderRoute: typeof OffersRouteImport
parentRoute: typeof rootRouteImport
}
'/me': {
id: '/me'
path: '/me'
@ -715,6 +735,7 @@ const rootRouteChildren: RootRouteChildren = {
HomeRoute: HomeRouteWithChildren,
LoginRoute: LoginRoute,
MeRoute: MeRouteWithChildren,
OffersRoute: OffersRoute,
RegisterRoute: RegisterRoute,
SlotViewRoute: SlotViewRoute,
StoresRoute: StoresRouteWithChildren,

View file

@ -87,6 +87,20 @@ function SectionSpinner({ label }: { label?: string }) {
)
}
// Light/pastel color pairs for the Explore Products tabs.
const TAG_COLORS = [
{ text: '#BE123C', border: '#FECDD3', bg: '#FFE4E6' }, // rose
{ text: '#B45309', border: '#FDE68A', bg: '#FEF3C7' }, // amber
{ text: '#15803D', border: '#BBF7D0', bg: '#DCFCE7' }, // green
{ text: '#1D4ED8', border: '#BFDBFE', bg: '#DBEAFE' }, // blue
{ text: '#6D28D9', border: '#DDD6FE', bg: '#EDE9FE' }, // violet
{ text: '#C2410C', border: '#FFD6B0', bg: '#FFE4CC' }, // orange
{ text: '#0E7490', border: '#A5F3FC', bg: '#CFFAFE' }, // cyan
{ text: '#BE185D', border: '#FBCFE8', bg: '#FCE7F3' }, // pink
]
const getTagColor = (id: number) => TAG_COLORS[id % TAG_COLORS.length]
function HomePage() {
const navigate = useNavigate()
const { data: productsData, isLoading: isProductsLoading } = useAllProducts()
@ -96,17 +110,15 @@ function HomePage() {
const { data: essentialConsts, isLoading: isEssentialConstsLoading } = useGetEssentialConsts()
// Handle bootstrapping: products/stores/slots are disabled until essentialConsts provides cacheUrl
const isBootstrapping = isEssentialConstsLoading
const storesLoading = isBootstrapping || isStoresLoading
const productsLoading = isBootstrapping || isProductsLoading
const slotsLoading = isBootstrapping || isSlotsLoading
// NOTE: with persisted caches, render immediately from placeholderData; no startup spinner.
const storesLoading = isStoresLoading
const productsLoading = isProductsLoading
const slotsLoading = isSlotsLoading
const { setAddedToCartProduct } = useCartStore()
const { getQuickestSlot } = useProductSlotIdentifier()
const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap)
// Refs for scrollable sections
const popularScrollRef = useRef<HTMLDivElement>(null)
const slotsScrollRef = useRef<HTMLDivElement>(null)
// Populate central stores with slots and product data
@ -117,6 +129,51 @@ function HomePage() {
const allProducts = productsData?.products || []
const slots = slotsData?.slots || []
// Explore Products tabs — tags in tagsOrder, products in per-tag sortOrder
const dashboardTags = productsData?.tags || []
const [selectedTagId, setSelectedTagId] = useState<number | null>(null)
const activeTagId = selectedTagId ?? dashboardTags[0]?.id ?? null
const productsByTagId = useMemo(() => {
const map: Record<number, any[]> = {}
for (const tag of dashboardTags) {
const productById = new Map<number, any>()
for (const product of allProducts) {
productById.set(product.id, product)
}
// tag.productIds is already in the admin-curated order (backend sorts by sortOrder).
const orderedIds = (tag.productIds || []).filter((id: number) => productById.has(id))
const ordered: any[] = []
const rest: any[] = []
for (const id of orderedIds) {
const product = productById.get(id)
const isOutOfStock = Boolean(productSlotsMap[id]?.isOutOfStock) || !getQuickestSlot(id)
if (isOutOfStock) rest.push(product)
else ordered.push(product)
}
// Products in the tag's productIds but not in the curated order (added later) — availability sort.
const seen = new Set(orderedIds)
const extra = allProducts
.filter((product: any) => (tag.productIds?.includes(product.id) ?? false) && !seen.has(product.id))
.sort((a: any, b: any) => {
const slotA = getQuickestSlot(a.id)
const slotB = getQuickestSlot(b.id)
const aOutOfStock = Boolean(productSlotsMap[a.id]?.isOutOfStock) || !slotA
const bOutOfStock = Boolean(productSlotsMap[b.id]?.isOutOfStock) || !slotB
if (aOutOfStock && !bOutOfStock) return 1
if (!aOutOfStock && bOutOfStock) return -1
return 0
})
map[tag.id] = [...ordered, ...rest, ...extra]
}
return map
}, [dashboardTags, allProducts, getQuickestSlot, productSlotsMap])
const activeTagProducts = activeTagId != null ? (productsByTagId[activeTagId] || []) : []
// Sort products: in-stock first, then by slot availability
const sortedProducts = useMemo(() => {
return [...allProducts]
@ -134,28 +191,6 @@ function HomePage() {
})
}, [productsData, productSlotsMap])
// Get popular products from essential consts
const popularItemIds = useMemo(() => {
const popularItems = essentialConsts?.popularItems
if (!popularItems) return []
if (Array.isArray(popularItems)) {
return popularItems.map((id: any) => parseInt(id)).filter((id: number) => !isNaN(id))
} else if (typeof popularItems === 'string') {
return popularItems
.split(',')
.map((id: string) => parseInt(id.trim()))
.filter((id: number) => !isNaN(id))
}
return []
}, [essentialConsts?.popularItems])
const popularProducts = useMemo(() => {
return popularItemIds
.map((id) => allProducts.find((product) => product.id === id))
.filter((product): product is NonNullable<typeof product> => product != null)
}, [popularItemIds, allProducts])
// Sort slots by delivery time
const sortedSlots = useMemo(() => {
const now = dayjs()
@ -252,44 +287,66 @@ function HomePage() {
)}
</div>
{/* Popular Items Section */}
<div className="mb-6">
<div className="mb-4">
<p className="font-bold text-xl text-gray-900">
Popular Items
</p>
<p className="mt-0.5 text-sm text-gray-500">
Trending fresh picks just for you
</p>
</div>
{productsLoading ? (
<SectionSpinner label="Loading popular items..." />
) : (
<>
<div
ref={popularScrollRef}
className="scrollbar-hide -mx-4 flex gap-4 overflow-x-auto px-4 pb-2"
>
{popularProducts.map((product) => (
<div key={product.id} className="w-40 shrink-0">
{/* Explore Products Section */}
{dashboardTags.length > 0 && (
<div className="mb-6">
<div className="mb-4">
<p className="font-bold text-xl text-gray-900">
Explore Products
</p>
<p className="mt-0.5 text-sm text-gray-500">
Browse by category
</p>
</div>
{/* Tab strip */}
<div className="scrollbar-hide -mx-4 flex gap-3 overflow-x-auto px-4 pb-2">
{dashboardTags.map((tag: any) => {
const color = getTagColor(tag.id)
const active = activeTagId === tag.id
return (
<button
key={tag.id}
onClick={() => setSelectedTagId(tag.id)}
className="shrink-0 cursor-pointer border-b-4 bg-transparent px-3 py-2 text-left transition-colors"
style={{
borderBottomColor: active ? color.text : 'transparent',
}}
>
<span
className="text-sm font-semibold whitespace-nowrap"
style={{ color: active ? color.text : '#6B7280' }}
>
{tag.tagName} ({tag.productIds?.length || 0})
</span>
</button>
)
})}
</div>
{/* Active tag products */}
<div className="mt-4">
{activeTagProducts.length > 0 ? (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
{activeTagProducts.map((product: any) => (
<ProductCard
key={product.id}
item={product}
onPress={() => handleProductPress(product.id)}
showDeliveryInfo={false}
miniView={true}
useAddToCartDialog={true}
/>
</div>
))}
</div>
<ScrollIndicator
containerRef={popularScrollRef}
itemCount={popularProducts.length}
itemWidth={160}
/>
</>
)}
</div>
))}
</div>
) : (
<p className="py-6 text-center text-sm text-gray-500">
No products in this category yet
</p>
)}
</div>
</div>
)}
{/* Upcoming Delivery Slots Section */}
<div className="mb-6">

View file

@ -0,0 +1,127 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { trpc } from '../lib/trpc-client'
import { AppContainer } from 'web-components'
import { Tag, Loader2, AlertCircle } from 'lucide-react'
import { ProductCard } from '../components/ProductCard'
import { AppLayout } from '../components/AppLayout'
export const Route = createFileRoute('/offers')({ component: OffersPage })
const SHOW_MORE_STEP = 6
interface OffersSectionProps {
title: string
subtitle: string
products: any[]
expanded: boolean
onExpand: () => void
onProductPress: (id: number) => void
}
function OffersSection({ title, subtitle, products, expanded, onExpand, onProductPress }: OffersSectionProps) {
const visible = expanded ? products : products.slice(0, SHOW_MORE_STEP)
const hasMore = products.length > SHOW_MORE_STEP && !expanded
return (
<div className="pt-4">
<div className="mb-4 px-4">
<p className="font-bold text-xl text-gray-900">{title}</p>
<p className="mt-0.5 text-sm text-gray-500">{subtitle}</p>
</div>
{products.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10">
<Tag className="mb-2 h-7 w-7 text-gray-400" />
<p className="text-gray-500">No {title.toLowerCase()} available right now</p>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-3 px-4 md:grid-cols-3 lg:grid-cols-4">
{visible.map((item: any) => (
<ProductCard
key={item.id}
item={item}
onPress={() => onProductPress(item.id)}
showDeliveryInfo={false}
miniView={true}
useAddToCartDialog={true}
/>
))}
</div>
{hasMore && (
<div className="mt-2 mb-1 flex justify-center">
<button
onClick={onExpand}
className="rounded-full bg-brand-500 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-600"
>
Show More
</button>
</div>
)}
</>
)}
</div>
)
}
function OffersPage() {
const navigate = useNavigate()
const [expandedOffers, setExpandedOffers] = useState(false)
const [expandedCombos, setExpandedCombos] = useState(false)
const { data, isLoading, error } = trpc.user.product.getOffersPage.useQuery()
const combos = data?.combos || []
const offers = data?.offers || []
const handleProductPress = (id: number) => {
navigate({ to: '/home/product/$id', params: { id: String(id) } })
}
if (isLoading) {
return (
<AppLayout>
<div className="flex min-h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
<p className="ml-3 text-gray-600">Loading offers...</p>
</div>
</AppLayout>
)
}
if (error) {
return (
<AppLayout>
<div className="flex min-h-screen flex-col items-center justify-center p-4">
<AlertCircle className="mb-2 h-12 w-12 text-red-500" />
<p className="text-center text-red-600">Failed to load offers. Please try again.</p>
</div>
</AppLayout>
)
}
return (
<AppLayout>
<div className="min-h-screen bg-white pb-24">
<OffersSection
title="Offers"
subtitle="Great prices on select items"
products={offers}
expanded={expandedOffers}
onExpand={() => setExpandedOffers(true)}
onProductPress={handleProductPress}
/>
<OffersSection
title="Combos"
subtitle="Bundle your favorites and save"
products={combos}
expanded={expandedCombos}
onExpand={() => setExpandedCombos(true)}
onProductPress={handleProductPress}
/>
</div>
</AppLayout>
)
}