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

505 lines
No EOL
16 KiB
TypeScript

import {
createFileRoute,
useNavigate,
useSearch,
} from "@tanstack/react-router";
import { useState, useMemo, useEffect } from "react";
import dayjs from "dayjs";
import {
p,
div,
Quantifier,
MiniQuantifier,
} from "web-components";
import {
useSlots,
useAllProducts,
useStores,
} from "../hooks/prominent-api-hooks";
import { useCentralProductStore } from "../lib/stores/central-product-store";
import { useCentralSlotStore } from "../lib/stores/central-slot-store";
import {
useAddToCart,
useGetCart,
useUpdateCartItem,
useRemoveFromCart,
} from "../hooks/cart-query-hooks";
import { usePopulateCentralProductStore } from "../hooks/usePopulateCentralProductStore";
import { AppLayout } from "../components/AppLayout";
import { Truck, Store, Grid3X3, ChevronLeft, ShoppingCart, Clock, ChevronDown } from "lucide-react";
import { Dialog } from "../components/Dialog";
export const Route = createFileRoute("/slot-view")({
component: SlotViewPage,
});
function SlotViewPage() {
const search = useSearch({ from: "/slot-view" }) as {
slotId?: string;
storeId?: string;
};
const slotId = search.slotId ? Number(search.slotId) : undefined;
const storeId = search.storeId ? Number(search.storeId) : undefined;
const navigate = useNavigate();
const { data: slotsData } = useSlots();
const { productsById } = useCentralProductStore();
const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap);
const { data: storesData } = useStores();
const [showSlotDialog, setShowSlotDialog] = useState(false);
// Populate central product store with products data
usePopulateCentralProductStore();
const stores = storesData?.stores || [];
// Find the specific slot from cached data
const slot = slotsData?.slots?.find((s: any) => s.id === slotId);
// Find earliest slot for pre-selection
const earliestSlot = slotsData?.slots
?.filter((s: any) => dayjs(s.deliveryTime).isAfter(dayjs()))
.sort((a: any, b: any) => dayjs(a.deliveryTime).diff(dayjs(b.deliveryTime)))[0];
const addToCart = useAddToCart("regular");
const { data: cartData } = useGetCart("regular");
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}`;
}
};
const formatFullDisplay = (deliveryTime: string) => {
const time = dayjs(deliveryTime);
const endTime = time.add(1, "hour");
const startPeriod = time.format("A");
const endPeriod = endTime.format("A");
let timeRange;
if (startPeriod === endPeriod) {
timeRange = `${time.format("h")}-${endTime.format("h")} ${startPeriod}`;
} else {
timeRange = `${time.format("h:mm")} ${startPeriod} - ${endTime.format("h:mm")} ${endPeriod}`;
}
return `${time.format("ddd, DD MMM ")}${timeRange}`;
};
// Get current selected slot display
const getCurrentSlotDisplay = () => {
if (slotId) {
const s = slotsData?.slots?.find((s: any) => s.id === slotId);
return s ? formatFullDisplay(s.deliveryTime as any) : 'Select time';
}
if (earliestSlot) {
return formatFullDisplay(earliestSlot.deliveryTime as any);
}
return 'Select time';
};
const handleAddToCart = (productId: number) => {
const item = filteredProducts.find((p) => p.id === productId);
const deliveryTime = slot?.deliveryTime
? dayjs(slot.deliveryTime).format("ddd, DD MMM • h:mm A")
: "";
addToCart.mutate(
{ productId, quantity: 1, slotId: slotId || 0, storeId: item?.storeId },
{
onSuccess: () => {
alert(
`Added ${item?.name || "item"} for delivery at ${deliveryTime}`,
);
},
},
);
};
const handleSlotChange = (newSlotId: number) => {
navigate({
to: "/slot-view",
search: { slotId: newSlotId, storeId },
});
setShowSlotDialog(false);
};
// Get product details from central store using slot product IDs
const slotProducts =
slot?.products
?.map((p: any) => productsById[p.id])
?.filter(
(product: any): product is NonNullable<typeof product> =>
product !== null && product !== undefined,
)
?.filter((product: any) => !productSlotsMap[product.id]?.isOutOfStock) ||
[];
const filteredProducts = storeId
? slotProducts.filter((p: any) => p.storeId === storeId)
: slotProducts;
// Transform slots for display
const slotOptions = slotsData?.slots
?.filter((s: any) => dayjs(s.deliveryTime).isAfter(dayjs()))
.sort((a: any, b: any) => dayjs(a.deliveryTime).diff(dayjs(b.deliveryTime)))
.map((s: any) => ({
id: s.id,
deliveryTime: formatFullDisplay(s.deliveryTime),
closeTime: dayjs(s.freezeTime).format("h:mm A"),
})) || [];
if (!slot) {
return (
<AppLayout>
<div className="flex min-h-screen flex-col items-center justify-center">
<p className="font-bold mb-4 text-2xl text-gray-900">
Slot View
</p>
<p className="text-gray-600">No delivery slot available.</p>
</div>
</AppLayout>
);
}
return (
<AppLayout>
<div className="min-h-screen bg-gray-50">
{/* Header with Dropdown - Matching Mobile App */}
<div className="sticky top-0 z-20 border-b border-gray-200 bg-white px-4 py-3">
<div className="flex items-center gap-2">
{/* Back Button */}
<button
onClick={() => navigate({ to: "/home" })}
className="p-1 hover:bg-gray-100 rounded-full"
>
<ChevronLeft className="h-6 w-6 text-gray-700" />
</button>
{/* Delivery Time Dropdown */}
<div className="flex-1">
<button
onClick={() => setShowSlotDialog(true)}
className="flex w-full items-center justify-between rounded-lg border border-brand-200 bg-brand-50 px-3 py-2"
>
<div className="flex-1 text-left">
<p className="text-xs font-bold uppercase text-brand-500">Delivery Time</p>
<p className="text-sm font-bold text-brand-900">
{getCurrentSlotDisplay()}
</p>
</div>
<ChevronDown className="h-5 w-5 text-brand-500" />
</button>
</div>
</div>
</div>
<div className="flex flex-row">
{/* StoreSidebar - Fixed width on left for both mobile and desktop */}
<div className="w-20 shrink-0 md:w-24">
<StoreSidebar
stores={stores}
slotId={slotId}
storeId={storeId}
onStoreSelect={(newStoreId) =>
navigate({
to: "/slot-view",
search: { slotId, storeId: newStoreId },
})
}
onAllSelect={() =>
navigate({ to: "/slot-view", search: { slotId } })
}
/>
</div>
{/* Products Grid */}
<div className="flex-1 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="font-bold text-xl text-gray-900">
{storeId
? stores.find((s: any) => s.id === storeId)?.name ||
"Store Products"
: "All Products"}
</p>
<p className="text-sm text-gray-500">
{filteredProducts.length} items
</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:gap-4 md:grid-cols-3 lg:grid-cols-4">
{filteredProducts.map((product: any) => (
<CompactProductCard
key={product.id}
item={product}
handleAddToCart={handleAddToCart}
onPress={() =>
navigate({
to: "/home/product/$id",
params: { id: String(product.id) },
})
}
/>
))}
</div>
{filteredProducts.length === 0 && (
<div className="py-10 text-center">
<p className="font-medium text-gray-400">
{storeId
? "No products from this store in this slot."
: "No products available for this slot."}
</p>
</div>
)}
</div>
</div>
{/* Slot Selection Dialog */}
<Dialog open={showSlotDialog} onClose={() => setShowSlotDialog(false)} title="Select Delivery Time">
<div className="max-h-[60vh] overflow-y-auto p-4">
{slotOptions.map((slotOption) => (
<button
key={slotOption.id}
onClick={() => handleSlotChange(slotOption.id)}
className={`mb-2 w-full rounded-lg border p-3 text-left transition-colors ${
slotOption.id === (slotId || earliestSlot?.id)
? 'border-brand-500 bg-brand-50'
: 'border-gray-200 bg-white hover:bg-gray-50'
}`}
>
<p className="font-medium text-gray-900">
Delivery: {slotOption.deliveryTime}
</p>
<p className="mt-1 text-xs text-gray-500">
Orders Close at: {slotOption.closeTime}
</p>
</button>
))}
</div>
</Dialog>
</div>
</AppLayout>
);
}
interface StoreSidebarProps {
stores: any[];
slotId?: number;
storeId?: number;
onStoreSelect: (storeId: number) => void;
onAllSelect: () => void;
}
function StoreSidebar({
stores,
slotId,
storeId,
onStoreSelect,
onAllSelect,
}: StoreSidebarProps) {
return (
<div className="sticky top-[73px] z-10 h-[calc(100vh-73px)] w-full overflow-y-auto border-r border-gray-200 bg-white p-2 md:top-0 md:h-auto md:p-3">
<div className="flex flex-col gap-2 md:gap-3">
{/* All Products Item */}
<div
onClick={onAllSelect}
className={`flex flex-col items-center rounded-2xl p-2 md:p-3 ${
!storeId
? "bg-gradient-to-br from-brand-400 to-brand-600 text-white shadow-lg"
: "border border-gray-100 bg-white text-gray-500"
}`}
>
<div
className={`mb-1 flex h-8 w-8 items-center justify-center rounded-full border md:h-10 md:w-10 ${
!storeId ? "border-white/30 bg-white/20" : "bg-gray-50"
}`}
>
<Grid3X3
className={`h-4 w-4 md:h-5 md:w-5 ${!storeId ? "text-white" : "text-gray-500"}`}
/>
</div>
<p
className={`text-center text-[10px] font-bold ${!storeId ? "text-white" : "text-gray-500"}`}
>
ALL
</p>
</div>
<div className="h-px bg-gray-200 my-1" />
{/* Store Items */}
{stores.map((store: any) => {
const isActive = storeId === store.id;
return (
<div
key={store.id}
onClick={() => onStoreSelect(store.id)}
className={`flex flex-col items-center rounded-2xl p-2 ${
isActive
? "bg-gradient-to-br from-brand-400 to-brand-600 text-white shadow-lg"
: "border border-gray-100 bg-white text-gray-500"
}`}
>
<div
className={`mb-1 md:mb-2 flex h-10 w-10 items-center justify-center overflow-hidden rounded-full border-2 md:h-12 md:w-12 ${
isActive
? "border-white bg-white"
: "border-gray-100 bg-gray-50"
}`}
>
{store.signedImageUrl ? (
<img
src={store.signedImageUrl}
alt={store.name}
className="h-full w-full object-cover"
/>
) : (
<Store
className={`h-5 w-5 md:h-6 md:w-6 ${isActive ? "text-brand-500" : "text-gray-400"}`}
/>
)}
</div>
<p
className={`w-full text-center text-[10px] leading-tight ${
isActive
? "font-bold text-white"
: "font-medium text-gray-500"
}`}
>
{store.name.replace(/^The\s+/i, "")}
</p>
</div>
);
})}
</div>
</div>
);
}
const formatQuantity = (
quantity: number,
unit: string,
): { value: string; display: string } => {
if (unit?.toLowerCase() === "kg" && quantity < 1) {
return {
value: `${Math.round(quantity * 1000)} g`,
display: `${Math.round(quantity * 1000)}g`,
};
}
return { value: `${quantity} ${unit}(s)`, display: `${quantity}${unit}` };
};
interface CompactProductCardProps {
item: any;
handleAddToCart: (productId: number) => void;
onPress?: () => void;
}
function CompactProductCard({
item,
handleAddToCart,
onPress,
}: CompactProductCardProps) {
const { data: cartData } = useGetCart("regular");
const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap);
const updateCartItem = useUpdateCartItem("regular");
const removeFromCart = useRemoveFromCart("regular");
const cartItem = cartData?.items?.find(
(cartItem: any) => cartItem.productId === item.id,
);
const quantity = cartItem?.quantity || 0;
const isOutOfStock = productSlotsMap[item.id]?.isOutOfStock;
const handleQuantityChange = (newQuantity: number) => {
if (newQuantity === 0 && cartItem) {
removeFromCart.mutate(cartItem.id);
} else if (newQuantity === 1 && !cartItem) {
handleAddToCart(item.id);
} else if (cartItem) {
updateCartItem.mutate({ productId: cartItem.id, quantity: newQuantity });
}
};
return (
<div
onClick={onPress}
className="mb-2 overflow-hidden rounded-lg border border-gray-100 bg-white shadow-sm"
>
<div className="relative">
<img
src={item.images?.[0]}
alt={item.name}
className="aspect-square w-full object-cover"
/>
{isOutOfStock && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<p className="text-xs font-bold text-white">
Out of Stock
</p>
</div>
)}
<div className="absolute bottom-2 right-2">
{quantity > 0 ? (
<MiniQuantifier
value={quantity}
setValue={handleQuantityChange}
step={item.incrementStep}
/>
) : (
<div
className="flex h-8 w-8 items-center justify-center rounded-full bg-white shadow-md"
onClick={(e) => {
e.stopPropagation();
handleQuantityChange(1);
}}
>
<ShoppingCart className="h-4 w-4 text-brand-500" />
</div>
)}
</div>
</div>
<div className="p-2">
<p
className="font-medium mb-1 text-xs text-gray-900"
>
{item.name}
</p>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-baseline">
<p className="font-bold text-sm text-brand-500">
{item.price}
</p>
{item.marketPrice &&
Number(item.marketPrice) > Number(item.price) && (
<p className="ml-1 text-xs text-gray-400 line-through">
{item.marketPrice}
</p>
)}
<p className="ml-1 text-xs text-gray-600">
Qty:{" "}
<span className="font-semibold text-brand-500">
{
formatQuantity(
item.productQuantity || 1,
item.unit || item.unitNotation,
).display
}
</span>
</p>
</div>
</div>
</div>
</div>
);
}