freshyo/apps/web-ui/src/routes/home.index.tsx
2026-08-23 14:45:52 +05:30

618 lines
24 KiB
TypeScript

import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState, useEffect, useMemo, useRef } from 'react'
import dayjs from 'dayjs'
import {
useAllProducts,
useStores,
useBanners,
useSlots,
useGetEssentialConsts,
} from '../hooks/prominent-api-hooks'
import { useCartStore } from '../lib/stores/cart-store'
import { useCentralSlotStore } from '../lib/stores/central-slot-store'
import { AppLayout } from '../components/AppLayout'
import { ProductCard } from '../components/ProductCard'
import AddToCartDialog from '../components/AddToCartDialog'
import { useProductSlotIdentifier } from '../hooks/useProductSlotIdentifier'
import { usePopulateCentralStores } from '../hooks/usePopulateCentralStores'
import { Store, ImageOff, Loader2, Clock, Truck } from 'lucide-react'
// Scroll Indicator Component
function ScrollIndicator({
containerRef,
itemCount,
itemWidth,
}: {
containerRef: React.RefObject<HTMLDivElement | null>
itemCount: number
itemWidth: number
}) {
const [activeIndex, setActiveIndex] = useState(0)
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleScroll = () => {
const scrollLeft = container.scrollLeft
const maxScroll = container.scrollWidth - container.clientWidth
const scrollProgress = maxScroll > 0 ? scrollLeft / maxScroll : 0
const totalDots = Math.min(itemCount, 5)
const newIndex = Math.round(scrollProgress * (totalDots - 1))
setActiveIndex(Math.min(newIndex, totalDots - 1))
}
container.addEventListener('scroll', handleScroll, { passive: true })
handleScroll()
return () => container.removeEventListener('scroll', handleScroll)
}, [containerRef, itemCount])
const totalDots = Math.min(itemCount, 5)
if (totalDots <= 1) return null
return (
<div className="mt-3 flex justify-center gap-1.5">
{Array.from({ length: totalDots }).map((_, i) => (
<div
key={i}
className={`h-1.5 rounded-full transition-all duration-300 ${
i === activeIndex ? 'w-4 bg-brand-600' : 'w-1.5 bg-gray-300'
}`}
/>
))}
</div>
)
}
export const Route = createFileRoute('/home/')({ component: HomePage })
// Section Spinner Component
function SectionSpinner({ label }: { label?: string }) {
return (
<div className="flex flex-col items-center justify-center py-10">
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
{label && <p className="mt-2 text-sm text-gray-500">{label}</p>}
</div>
)
}
function HomePage() {
const navigate = useNavigate()
const { data: productsData, isLoading: isProductsLoading } = useAllProducts()
const { data: storesData, isLoading: isStoresLoading } = useStores()
const { data: bannersData } = useBanners()
const { data: slotsData, isLoading: isSlotsLoading } = useSlots()
const { data: essentialConsts } = useGetEssentialConsts()
// Handle bootstrapping: products/stores/slots are disabled until essentialConsts provides cacheUrl
// 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 slotsScrollRef = useRef<HTMLDivElement>(null)
// Populate central stores with slots and product data
usePopulateCentralStores()
const stores = storesData?.stores || []
// const banners = bannersData?.banners || []
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 [expandedByTagId, setExpandedByTagId] = useState<Record<number, boolean>>({})
const handleToggleExpand = (tagId: number) => {
setExpandedByTagId((prev) => ({ ...prev, [tagId]: true }))
}
// Auto-scroll the active tab into view (matches user-ui tab behavior)
const tabsScrollRef = useRef<HTMLDivElement>(null)
const tabPositions = useRef<number[]>([])
const activeTabIndex = Math.max(
0,
dashboardTags.findIndex((t: any) => t.id === activeTagId)
)
useEffect(() => {
const x = tabPositions.current[activeTabIndex]
if (x != null) {
tabsScrollRef.current?.scrollTo({ left: Math.max(0, x - 16), behavior: 'smooth' })
}
}, [activeTabIndex])
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]
.filter((p) => typeof p.id === 'number')
.sort((a, b) => {
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
})
}, [productsData, productSlotsMap])
// Sort slots by delivery time
const sortedSlots = useMemo(() => {
const now = dayjs()
return [...slots]
.filter((slot) => dayjs(slot.deliveryTime).isAfter(now))
.sort((a, b) => {
const deliveryDiff = dayjs(a.deliveryTime).diff(dayjs(b.deliveryTime))
if (deliveryDiff !== 0) return deliveryDiff
return dayjs(a.freezeTime).diff(dayjs(b.freezeTime))
})
}, [slots])
const handleProductPress = (id: number) => {
navigate({
to: '/home/product/$id',
params: { id: String(id) },
})
}
const handleAddToCart = (product: any) => {
setAddedToCartProduct({ productId: product.id, product })
}
return (
<AppLayout>
<div className="mx-auto w-full max-w-7xl px-4 pb-24 md:px-8 md:pb-12">
{/* Welcome band — desktop editorial opener */}
<div className="flex flex-col gap-1 py-6 md:py-8">
<p className="text-[11px] font-black uppercase tracking-[0.22em] text-brand-600">
Smiles delivered with
</p>
<h1 className="display-1 text-gray-900">
The best harvest, delivered to your door
</h1>
<p className="mt-1 max-w-2xl text-gray-600">
Chicken, mutton, fish and more, cut to order from our own stores.
Reserve a delivery slot and we will bring it cold and clean.
</p>
</div>
{/* Download App Banner */}
<div className="mb-8 overflow-hidden rounded-xl bg-gradient-to-r from-brand-500 to-brand-600 px-6 py-4 md:flex md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="hidden h-12 w-12 items-center justify-center overflow-hidden rounded-xl bg-white/20 md:flex">
<img
src="/freshyo-logo.png"
alt="Freshyo logo"
className="h-full w-full object-cover"
/>
</div>
<div className="flex-1">
<p className="text-lg font-bold text-white">Get the FreshYo App</p>
<p className="text-sm text-white/80">Download for exclusive offers & faster checkout</p>
</div>
</div>
<a
href={essentialConsts?.playStoreUrl || '#'}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-2 rounded-lg bg-white px-4 py-2 text-sm font-bold text-brand-600 shadow-md transition-colors hover:bg-gray-50 md:mt-0"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 20.5V3.5C3 2.91 3.34 2.39 3.84 2.15L13.69 12L3.84 21.85C3.34 21.6 3 21.09 3 20.5ZM16.81 15.12L6.05 21.34L14.54 12.85L16.81 15.12ZM20.16 10.81C20.5 11.08 20.75 11.5 20.75 12C20.75 12.5 20.53 12.9 20.18 13.18L17.89 14.5L15.39 12L17.89 9.5L20.16 10.81ZM6.05 2.66L16.81 8.88L14.54 11.15L6.05 2.66Z"/>
</svg>
Get App
</a>
</div>
{/* Explore Products Section — first, matching user-ui home order */}
{dashboardTags.length > 0 && (
<section className="mb-10">
<div className="section-rule mb-5 flex items-end justify-between">
<div>
<p className="text-[11px] font-black uppercase tracking-[0.22em] text-brand-600">
Browse by Category
</p>
<h2 className="display-2 mt-1 text-gray-900">Explore Products</h2>
</div>
</div>
{/* Tab strip — flat, brand underline active (matches user-ui home) */}
<div
ref={tabsScrollRef}
className="scrollbar-hide -mx-4 mb-5 flex gap-1 overflow-x-auto px-4 md:mx-0 md:px-0"
>
{dashboardTags.map((tag: any, i: number) => {
const active = activeTagId === tag.id
return (
<button
key={tag.id}
ref={(el) => {
tabPositions.current[i] = el?.offsetLeft ?? 0
}}
onClick={() => setSelectedTagId(tag.id)}
className="shrink-0 cursor-pointer px-3 py-2"
>
<span
className={`block text-sm font-bold ${
active ? 'text-brand-600' : 'text-gray-500'
}`}
>
{tag.tagName}
</span>
<span
className={`mt-1 block h-1 rounded-full ${
active ? 'bg-brand-500' : 'bg-transparent'
}`}
/>
</button>
)
})}
</div>
{/* Active tag products — brand-tinted container (matches user-ui) */}
<div className="mb-2 rounded-[28px] border border-brand-100 bg-brand-25 px-3 pb-4 pt-3">
{activeTagProducts.length > 0 ? (
<>
<div className="grid grid-cols-3 gap-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{activeTagProducts
.slice(0, expandedByTagId[activeTagId] ? undefined : 6)
.map((product: any) => (
<ProductCard
key={product.id}
item={product}
onPress={() => handleProductPress(product.id)}
showDeliveryInfo={false}
miniView={true}
useAddToCartDialog={true}
/>
))}
</div>
{activeTagProducts.length > 6 && !expandedByTagId[activeTagId] && (
<div className="mt-4 flex justify-center">
<button
onClick={() => handleToggleExpand(activeTagId)}
className="rounded-full bg-brand-500 px-6 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-brand-600"
>
Show More
</button>
</div>
)}
</>
) : (
<p className="py-6 text-center text-sm text-gray-500">
No products in this category yet
</p>
)}
</div>
</section>
)}
{/* Stores Section */}
<section className="mb-10">
<div className="section-rule mb-5 flex items-end justify-between">
<div>
<p className="text-[11px] font-black uppercase tracking-[0.22em] text-brand-600">
Our Outlets
</p>
<h2 className="display-2 mt-1 text-gray-900">Shop by Store</h2>
</div>
<button
onClick={() => navigate({ to: '/stores' })}
className="text-sm font-bold text-brand-600 hover:text-brand-700"
>
All stores
</button>
</div>
{storesLoading ? (
<SectionSpinner label="Loading stores..." />
) : (
<div className="flex flex-wrap gap-4">
{stores.map((store: any) => (
<div key={store.id}>
<StoreCard
store={store}
onClick={() =>
navigate({
to: '/stores/$storeId',
params: { storeId: String(store.id) },
})
}
/>
</div>
))}
</div>
)}
</section>
{/* Upcoming Delivery Slots Section */}
<section className="mb-10">
<div className="section-rule mb-5 flex items-end justify-between">
<div>
<p className="text-[11px] font-black uppercase tracking-[0.22em] text-brand-600">
Plan Ahead
</p>
<h2 className="display-2 mt-1 text-gray-900">Upcoming Delivery Slots</h2>
</div>
</div>
{slotsLoading ? (
<SectionSpinner label="Loading slots..." />
) : (
<>
<div
ref={slotsScrollRef}
className="scrollbar-hide -mx-4 flex gap-4 overflow-x-auto px-4 pb-2"
>
{sortedSlots.slice(0, 5).map((slot) => (
<SlotCard key={slot.id} slot={slot} />
))}
</div>
<ScrollIndicator
containerRef={slotsScrollRef}
itemCount={sortedSlots.slice(0, 5).length}
itemWidth={280}
/>
</>
)}
</section>
{/* All Products Section */}
<section>
<div className="section-rule mb-5">
<p className="text-[11px] font-black uppercase tracking-[0.22em] text-brand-600">
The Full Counter
</p>
<h2 className="display-2 mt-1 text-gray-900">All Available Products</h2>
<p className="mt-1 text-sm text-gray-500">Browse our complete selection</p>
</div>
{productsLoading ? (
<SectionSpinner label="Loading products..." />
) : (
<div className="grid grid-cols-3 gap-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{sortedProducts.map((product) => (
<ProductCard
key={product.id}
item={product}
onPress={() => handleProductPress(product.id)}
showDeliveryInfo={true}
miniView={true}
useAddToCartDialog={true}
/>
))}
</div>
)}
</section>
</div>
<AddToCartDialog />
</AppLayout>
)
}
function BannerCarousel({ banners }: { banners: any[] }) {
const [index, setIndex] = useState(0)
const images = banners.map((b: any) => b.imageUrl).filter(Boolean)
useEffect(() => {
if (images.length <= 1) return
const timer = setInterval(() => {
setIndex((i) => (i + 1) % images.length)
}, 4000)
return () => clearInterval(timer)
}, [images.length])
if (images.length === 0) return null
return (
<div className="flex justify-center">
<div className="group relative inline-block">
<img
src={images[index]}
alt="Banner"
className="max-h-[420px] w-auto max-w-full rounded-xl object-contain transition-all duration-500"
/>
{images.length > 1 && (
<>
<button
onClick={() => setIndex((i) => (i - 1 + images.length) % images.length)}
className="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-all hover:bg-black/70"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => setIndex((i) => (i + 1) % images.length)}
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-all hover:bg-black/70"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<div className="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 gap-1.5">
{images.map((_: any, i: number) => (
<button
key={i}
onClick={() => setIndex(i)}
className={`h-2 rounded-full transition-all ${
i === index ? 'w-6 bg-white' : 'w-2 bg-white/50'
}`}
/>
))}
</div>
</>
)}
</div>
</div>
)
}
function StoreCard({ store, onClick }: { store: any; onClick: () => void }) {
return (
<div
onClick={onClick}
className="flex flex-col items-center"
>
<div className="mb-2 flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl border-2 border-white/30 bg-gray-100 shadow-md transition-transform hover:scale-105">
{store.signedImageUrl ? (
<img
src={store.signedImageUrl}
alt={store.name}
className="h-full w-full object-cover"
/>
) : (
<Store className="h-7 w-7 text-gray-400" />
)}
</div>
<p className="text-center text-xs font-bold tracking-wide text-gray-800">
{store.name.replace(/^The\s+/i, '')}
</p>
</div>
)
}
function SlotCard({ slot }: { slot: any }) {
const navigate = useNavigate()
const now = dayjs()
const freezeTime = dayjs(slot.freezeTime)
const isClosingSoon = freezeTime.diff(now, 'hour') < 4 && freezeTime.isAfter(now)
const formatTimeRange = (deliveryTime: string) => {
const time = dayjs(deliveryTime)
const endTime = time.add(1, 'hour')
const startPeriod = time.format('A')
const endPeriod = endTime.format('A')
if (startPeriod === endPeriod) {
return `${time.format('h')}-${endTime.format('h')} ${startPeriod}`
} else {
return `${time.format('h:mm')} ${startPeriod} - ${endTime.format('h:mm')} ${endPeriod}`
}
}
return (
<div
onClick={() => navigate({ to: '/slot-view', search: { slotId: slot.id } })}
className={`min-w-70 shrink-0 cursor-pointer rounded-xl border bg-white p-5 shadow-sm transition-all hover:shadow-md ${
isClosingSoon ? 'border-l-4 border-l-amber-400' : 'border-l-4 border-l-brand-600'
}`}
>
<div className="mb-4 flex flex-row items-start justify-end">
{isClosingSoon && (
<div className="flex flex-row items-center rounded-md border border-amber-100 bg-amber-50 px-2 py-0.5">
<div className="mr-1 h-1 w-1 rounded-full bg-amber-500" />
<p className="text-[10px] font-bold text-amber-700">CLOSING SOON</p>
</div>
)}
</div>
<div className="mb-5 flex flex-row justify-between">
<div className="mr-4 flex-1">
<div className="mb-1.5 flex flex-row items-center">
<div className="mr-1.5 rounded-md bg-brand-25 p-1">
<Truck className="h-3 w-3 text-brand-600" />
</div>
<p className="text-[10px] font-bold uppercase text-brand-700">Delivery At</p>
</div>
<p className="text-sm font-bold text-gray-900">
{formatTimeRange(slot.deliveryTime)}
</p>
<p className="text-[11px] font-bold text-gray-500">
{dayjs(slot.deliveryTime).format('ddd, MMM DD')}
</p>
</div>
<div className="flex-1">
<div className="mb-1.5 flex flex-row items-center">
<div className="mr-1.5 rounded-md bg-amber-50 p-1">
<Clock className="h-3 w-3 text-amber-600" />
</div>
<p className="text-[10px] font-bold uppercase text-amber-700">Order By</p>
</div>
<p className="text-sm font-bold text-gray-900">
{dayjs(slot.freezeTime).format('h:mm A')}
</p>
<p className="text-[11px] font-bold text-gray-500">
{dayjs(slot.freezeTime).format('ddd, MMM DD')}
</p>
</div>
</div>
<div className="flex flex-row items-center">
<div className="mr-3 flex flex-row">
{slot.products?.slice(0, 3).map((p: any, i: number) => (
<div
key={p.id}
className={`h-8 w-8 overflow-hidden rounded-full border-2 border-white bg-gray-100 ${i > 0 ? '-ml-3' : ''}`}
>
{p.images?.[0] ? (
<img src={p.images[0]} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center">
<ImageOff className="h-3.5 w-3.5 text-gray-400" />
</div>
)}
</div>
))}
</div>
<p className="text-[11px] font-bold text-brand-600">
View all {slot.products?.length || 0} items
</p>
<svg className="ml-1 h-4 w-4 text-brand-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
)
}