freshyo/apps/web-ui/src/routes/home.index.tsx
2026-05-13 00:04:23 +05:30

514 lines
No EOL
19 KiB
TypeScript

import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState, useEffect, useMemo, useRef } from 'react'
import dayjs from 'dayjs'
import {
p,
div,
SearchBar,
} from 'web-components'
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 { useCentralProductStore } from '../lib/stores/central-product-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 } from 'lucide-react'
// Scroll Indicator Component
function ScrollIndicator({
containerRef,
itemCount,
itemWidth
}: {
containerRef: React.RefObject<HTMLDivElement>
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-500'
: 'w-1.5 bg-gray-300'
}`}
/>
))}
</div>
)
}
export const Route = createFileRoute('/home/')({ component: HomePage })
function HomePage() {
const navigate = useNavigate()
const { data: productsData } = useAllProducts()
const { data: storesData } = useStores()
const { data: bannersData } = useBanners()
const { data: slotsData } = useSlots()
const { data: essentialConsts } = useGetEssentialConsts()
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
usePopulateCentralStores()
const stores = storesData?.stores || []
// const banners = bannersData?.banners || []
const allProducts = productsData?.products || []
const slots = slotsData?.slots || []
// 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)
if (slotA && !slotB) return -1
if (!slotA && slotB) return 1
const aOutOfStock = productSlotsMap[a.id]?.isOutOfStock
const bOutOfStock = productSlotsMap[b.id]?.isOutOfStock
if (aOutOfStock && !bOutOfStock) return 1
if (!aOutOfStock && bOutOfStock) return -1
return 0
})
}, [allProducts, productSlotsMap, getQuickestSlot])
// 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()
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="min-h-screen bg-white pb-24">
{/* Search Bar */}
<div className="sticky top-0 z-10 border-b border-gray-100 bg-white/95 px-4 pb-3 pt-4 backdrop-blur-sm">
<SearchBar
placeholder="Search products here..."
onClick={() => navigate({ to: '/home/search' })}
/>
</div>
<div className="px-4">
{/* Banner Carousel - Commented out */}
{/* {banners.length > 0 && (
<div className="mb-6 mt-4 overflow-hidden rounded-xl">
<BannerCarousel banners={banners} />
</div>
)} */}
{/* Download App Banner */}
<div className="mb-6 mt-4 rounded-xl bg-gradient-to-r from-brand-500 to-brand-600 p-4 shadow-lg">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-lg font-bold text-white">Get the FreshYo App</p>
<p className="text-sm text-white/80 mt-1">Download for exclusive offers & faster checkout</p>
</div>
<a
href={essentialConsts?.playStoreUrl || '#'}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg bg-white px-4 py-2 text-sm font-bold text-brand-600 shadow-md hover:bg-gray-50 transition-colors"
>
<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>
</div>
{/* Stores Section */}
{stores.length > 0 && (
<div className="mb-6">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="font-bold text-xl text-gray-900">
Our Stores
</p>
<p className="mt-0.5 text-xs text-gray-500">
Fresh from our locations
</p>
</div>
</div>
<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>
</div>
)}
{/* Popular Items Section */}
{popularProducts.length > 0 && (
<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>
<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">
<ProductCard
item={product}
onPress={() => handleProductPress(product.id)}
showDeliveryInfo={false}
miniView={true}
useAddToCartDialog={true}
/>
</div>
))}
</div>
<ScrollIndicator
containerRef={popularScrollRef}
itemCount={popularProducts.length}
itemWidth={160}
/>
</div>
)}
{/* Upcoming Delivery Slots Section */}
{sortedSlots.length > 0 && (
<div className="mb-6">
<div className="mb-4">
<p className="font-bold text-xl text-gray-900">
Upcoming Delivery Slots
</p>
<p className="mt-0.5 text-sm text-gray-500">
Plan your fresh deliveries ahead
</p>
</div>
<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}
/>
</div>
)}
{/* All Products Section */}
<div className="rounded-t-3xl bg-white pt-4">
<div className="mb-4">
<p className="font-bold text-xl text-gray-900">
All Available Products
</p>
<p className="mt-0.5 text-sm text-gray-500">
Browse our complete selection
</p>
</div>
{/* Responsive Grid for Products - 2 columns on mobile */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
{sortedProducts.map((product) => (
<ProductCard
key={product.id}
item={product}
onPress={() => handleProductPress(product.id)}
showDeliveryInfo={true}
miniView={false}
useAddToCartDialog={true}
/>
))}
</div>
{sortedProducts.length === 0 && (
<div className="py-8 text-center">
<p className="text-gray-500">No products available</p>
</div>
)}
</div>
</div>
<AddToCartDialog />
</div>
</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-lg">
{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="font-bold text-center text-xs 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-3xl border border-slate-100 bg-white p-5 shadow-xl ${
isClosingSoon ? 'border-l-4 border-l-amber-400' : 'border-l-4 border-l-brand-500'
}`}
>
<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-50 p-1">
<svg className="h-3 w-3 text-brand-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</div>
<p className="text-[10px] font-bold uppercase text-brand-700">Delivery At</p>
</div>
<p className="font-bold text-sm text-slate-900">
{formatTimeRange(slot.deliveryTime)}
</p>
<p className="text-[11px] font-bold text-slate-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">
<svg className="h-3 w-3 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="text-[10px] font-bold uppercase text-amber-700">Order By</p>
</div>
<p className="font-bold text-sm text-slate-900">
{dayjs(slot.freezeTime).format('h:mm A')}
</p>
<p className="text-[11px] font-bold text-slate-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-slate-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-slate-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>
)
}