514 lines
No EOL
19 KiB
TypeScript
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>
|
|
)
|
|
} |