505 lines
No EOL
16 KiB
TypeScript
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>
|
|
);
|
|
} |