618 lines
24 KiB
TypeScript
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>
|
|
)
|
|
}
|