diff --git a/.commandcode/taste/taste/taste.md b/.commandcode/taste/taste/taste.md index 815ed5f..6952e7f 100644 --- a/.commandcode/taste/taste/taste.md +++ b/.commandcode/taste/taste/taste.md @@ -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 diff --git a/apps/web-ui/src/components/BottomNavigation.tsx b/apps/web-ui/src/components/BottomNavigation.tsx index 528e724..fa7de08 100644 --- a/apps/web-ui/src/components/BottomNavigation.tsx +++ b/apps/web-ui/src/components/BottomNavigation.tsx @@ -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: , - iconActive: , + name: 'offers', + path: '/offers', + label: 'Offers', + icon: , + iconActive: , }, { name: 'me', diff --git a/apps/web-ui/src/hooks/prominent-api-hooks.ts b/apps/web-ui/src/hooks/prominent-api-hooks.ts index fba16bb..8b809fd 100644 --- a/apps/web-ui/src/hooks/prominent-api-hooks.ts +++ b/apps/web-ui/src/hooks/prominent-api-hooks.ts @@ -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 { + 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(key: string): Promise | null> { + try { + const raw = localStorage.getItem(key) + if (!raw) return null + return JSON.parse(raw) as PersistedCache + } catch { + return null + } +} + +async function writePersistedCache(key: string, version: string, data: T): Promise { + try { + localStorage.setItem(key, JSON.stringify({ version, data })) + } catch (e) { + console.error('writePersistedCache error:', e) + } +} + +function usePersistedCache(storageKey: string, version: string | null) { + const [initialData, setInitialData] = useState(undefined) + const [isReady, setIsReady] = useState(false) + + useEffect(() => { + let cancelled = false + setIsReady(false) + setInitialData(undefined) + if (!version) { + setIsReady(true) + return + } + readPersistedCache(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(CACHE_STORAGE_KEYS.products, version) const productsQuery = useQuery({ - queryKey: ['all-products', cacheUrl], + queryKey: ['all-products', version], queryFn: async () => { if (!cacheUrl) { throw new Error('Cache URL not available') } const response = await axios.get(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(CACHE_STORAGE_KEYS.availability, version) return useQuery({ - queryKey: ['availability', cacheUrl], + queryKey: ['availability', version], queryFn: async () => { if (!cacheUrl) { throw new Error('Cache URL not available') } const response = await axios.get(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(CACHE_STORAGE_KEYS.stores, version) return useQuery({ - queryKey: ['stores', cacheUrl], + queryKey: ['stores', version], queryFn: async () => { if (!cacheUrl) { throw new Error('Cache URL not available') } const response = await axios.get(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(CACHE_STORAGE_KEYS.slots, version) return useQuery({ - queryKey: ['slots', cacheUrl], + queryKey: ['slots', version], queryFn: async () => { if (!cacheUrl) { throw new Error('Cache URL not available') } const response = await axios.get(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(CACHE_STORAGE_KEYS.banners, version) return useQuery({ - queryKey: ['banners', cacheUrl], + queryKey: ['banners', version], queryFn: async () => { if (!cacheUrl) { throw new Error('Cache URL not available') } const response = await axios.get(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(CACHE_STORAGE_KEYS.storeProducts(storeId), version) return useQuery({ - 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(cacheUrl) + await writePersistedCache(CACHE_STORAGE_KEYS.storeProducts(storeId), version, response.data) return response.data }, staleTime: 60000, - enabled: !!cacheUrl, + enabled: !!cacheUrl && isReady, + placeholderData: initialData, }) } diff --git a/apps/web-ui/src/hooks/usePopulateCentralStores.ts b/apps/web-ui/src/hooks/usePopulateCentralStores.ts index 4e506ee..31d7a88 100644 --- a/apps/web-ui/src/hooks/usePopulateCentralStores.ts +++ b/apps/web-ui/src/hooks/usePopulateCentralStores.ts @@ -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, diff --git a/apps/web-ui/src/routeTree.gen.ts b/apps/web-ui/src/routeTree.gen.ts index 5f7e39d..435d558 100644 --- a/apps/web-ui/src/routeTree.gen.ts +++ b/apps/web-ui/src/routeTree.gen.ts @@ -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, diff --git a/apps/web-ui/src/routes/home.index.tsx b/apps/web-ui/src/routes/home.index.tsx index e2885ee..deb760d 100644 --- a/apps/web-ui/src/routes/home.index.tsx +++ b/apps/web-ui/src/routes/home.index.tsx @@ -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(null) const slotsScrollRef = useRef(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(null) + const activeTagId = selectedTagId ?? dashboardTags[0]?.id ?? null + + const productsByTagId = useMemo(() => { + const map: Record = {} + for (const tag of dashboardTags) { + const productById = new Map() + 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 => product != null) - }, [popularItemIds, allProducts]) - // Sort slots by delivery time const sortedSlots = useMemo(() => { const now = dayjs() @@ -252,44 +287,66 @@ function HomePage() { )} - {/* Popular Items Section */} -
-
-

- Popular Items -

-

- Trending fresh picks just for you -

-
- {productsLoading ? ( - - ) : ( - <> -
- {popularProducts.map((product) => ( -
+ {/* Explore Products Section */} + {dashboardTags.length > 0 && ( +
+
+

+ Explore Products +

+

+ Browse by category +

+
+ + {/* Tab strip */} +
+ {dashboardTags.map((tag: any) => { + const color = getTagColor(tag.id) + const active = activeTagId === tag.id + return ( + + ) + })} +
+ + {/* Active tag products */} +
+ {activeTagProducts.length > 0 ? ( +
+ {activeTagProducts.map((product: any) => ( handleProductPress(product.id)} showDeliveryInfo={false} miniView={true} useAddToCartDialog={true} /> -
- ))} -
- - - )} -
+ ))} +
+ ) : ( +

+ No products in this category yet +

+ )} +
+
+ )} {/* Upcoming Delivery Slots Section */}
diff --git a/apps/web-ui/src/routes/offers.tsx b/apps/web-ui/src/routes/offers.tsx new file mode 100644 index 0000000..90ed785 --- /dev/null +++ b/apps/web-ui/src/routes/offers.tsx @@ -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 ( +
+
+

{title}

+

{subtitle}

+
+ + {products.length === 0 ? ( +
+ +

No {title.toLowerCase()} available right now

+
+ ) : ( + <> +
+ {visible.map((item: any) => ( + onProductPress(item.id)} + showDeliveryInfo={false} + miniView={true} + useAddToCartDialog={true} + /> + ))} +
+ {hasMore && ( +
+ +
+ )} + + )} +
+ ) +} + +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 ( + +
+ +

Loading offers...

+
+
+ ) + } + + if (error) { + return ( + +
+ +

Failed to load offers. Please try again.

+
+
+ ) + } + + return ( + +
+ setExpandedOffers(true)} + onProductPress={handleProductPress} + /> + + setExpandedCombos(true)} + onProductPress={handleProductPress} + /> +
+
+ ) +}