enh
This commit is contained in:
parent
48decbac6f
commit
be40f0cffa
7 changed files with 378 additions and 81 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
127
apps/web-ui/src/routes/offers.tsx
Normal file
127
apps/web-ui/src/routes/offers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue