import React, { useState, useCallback, useMemo, memo, useRef, useEffect } from "react"; import { View, Dimensions, Image, RefreshControl } from "react-native"; import { ScrollView } from "react-native-gesture-handler"; import { useRouter } from "expo-router"; import { theme, tw, useManualRefresh, useMarkDataFetchers, LoadingDialog, MyTouchableOpacity, MyText, SearchBar } from "common-ui"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import ProductCard from "@/components/ProductCard"; import AddToCartDialog from "@/src/components/AddToCartDialog"; import MyFlatList from "common-ui/src/components/flat-list"; import { trpc } from "@/src/trpc-client"; import { useAllProducts, useStores, useSlots, useGetEssentialConsts } from "@/src/hooks/prominent-api-hooks"; import { useProductSlotIdentifier } from "@/hooks/useProductSlotIdentifier"; import { useCentralSlotStore } from "@/src/store/centralSlotStore"; import { useCentralProductStore } from "@/src/store/centralProductStore"; import FloatingCartBar from "@/components/floating-cart-bar"; import { useUserDetails } from "@/src/contexts/AuthContext"; import TabLayoutWrapper from "@/components/TabLayoutWrapper"; import { useNavigationStore } from "@/src/store/navigationStore"; import NextOrderGlimpse from "@/components/NextOrderGlimpse"; dayjs.extend(relativeTime); const { width: screenWidth } = Dimensions.get("window"); const itemWidth = screenWidth * 0.45; const heroItemWidth = (screenWidth - 72) / 3; const gridItemWidth = (screenWidth - 48) / 2; 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 staticStyles = { flatListContent: { gap: 16 }, columnWrapper: { justifyContent: 'space-between', paddingHorizontal: 16 }, slotsListContent: { paddingBottom: 24 }, } as const; const TAG_COLORS = [ { pageBg: '#FFF8F9', bg: '#FFF1F2', border: '#FECDD3', text: '#9F1239', dot: '#E11D48' }, // rose { pageBg: '#FFFCF1', bg: '#FFFBEB', border: '#FDE68A', text: '#92400E', dot: '#D97706' }, // amber { pageBg: '#F6FEF9', bg: '#F0FDF4', border: '#BBF7D0', text: '#166534', dot: '#16A34A' }, // green { pageBg: '#F5FAFF', bg: '#EFF6FF', border: '#BFDBFE', text: '#1E3A8A', dot: '#2563EB' }, // blue { pageBg: '#FAF8FF', bg: '#F5F3FF', border: '#DDD6FE', text: '#5B21B6', dot: '#7C3AED' }, // violet { pageBg: '#FFF9F2', bg: '#FFF7ED', border: '#FFD6B0', text: '#9A3412', dot: '#EA580C' }, // orange { pageBg: '#F3FEFF', bg: '#ECFEFF', border: '#A5F3FC', text: '#155E75', dot: '#0891B2' }, // cyan { pageBg: '#FFF7FB', bg: '#FDF2F8', border: '#FBCFE8', text: '#9D174D', dot: '#DB2777' }, // pink ]; const getTagColor = (id: number) => TAG_COLORS[id % TAG_COLORS.length]; interface RenderStoreProps { item: any; } const RenderStore = memo(({ item }: RenderStoreProps) => { const router = useRouter(); const { setNavigatedFromHome, setSelectedStoreId } = useNavigationStore(); const handlePress = useCallback(() => { setNavigatedFromHome(true); setSelectedStoreId(item.id); router.push('/(drawer)/(tabs)/stores'); }, [item.id, router, setNavigatedFromHome, setSelectedStoreId]); return ( {item.signedImageUrl ? ( ) : ( )} {item.name.replace(/^The\s+/i, "")} ); }); RenderStore.displayName = 'RenderStore'; interface SlotCardProps { slot: any; } const SlotCard = memo(({ slot }: SlotCardProps) => { const router = useRouter(); const now = dayjs(); const freezeTime = dayjs(slot.freezeTime); const isClosingSoon = freezeTime.diff(now, "hour") < 4 && freezeTime.isAfter(now); const handlePress = useCallback(() => { router.push(`/(drawer)/(tabs)/home/slot-view?slotId=${slot.id}`); }, [router, slot.id]); return ( {isClosingSoon && ( CLOSING SOON )} Delivery At {formatTimeRange(slot.deliveryTime)} {dayjs(slot.deliveryTime).format("ddd, MMM DD")} Order By {dayjs(slot.freezeTime).format("h:mm A")} {dayjs(slot.freezeTime).format("ddd, MMM DD")} {slot.products.slice(0, 3).map((p: any, i: number) => ( 0 && tw`-ml-3`]} > {p.images?.[0] ? ( ) : ( )} ))} View all {slot.products.length} items ); }); interface TagTabStripProps { dashboardTags: any[]; activeTagId: number | null; onSelectTag: (id: number) => void; } // Fixed, horizontally scrollable tab strip (plain ScrollView — no nesting, // so it scrolls reliably on Android). Sits OUTSIDE the vertical FlatList. const TagTabStrip = memo(({ dashboardTags, activeTagId, onSelectTag, }: TagTabStripProps) => { const scrollRef = useRef(null); const positions = useRef([]); const routes = useMemo( () => dashboardTags.map((tag) => ({ key: String(tag.id), title: tag.tagName })), [dashboardTags] ); const index = useMemo(() => { const idx = dashboardTags.findIndex((tag) => tag.id === activeTagId); return idx < 0 ? 0 : idx; }, [dashboardTags, activeTagId]); useEffect(() => { const x = positions.current[index]; if (x != null) { scrollRef.current?.scrollTo({ x: Math.max(0, x - 16), animated: true }); } }, [index]); return ( {routes.map((route, i) => { const isActive = i === index; return ( { positions.current[i] = e.nativeEvent.layout.x; }} onPress={() => onSelectTag(Number(route.key))} style={tw`h-12 px-3 justify-center`} activeOpacity={0.7} > {route.title} ); })} ); }); interface TagSceneProps { dashboardTags: any[]; activeTagId: number | null; productsByTagId: Record; expandedByTagId: Record; onToggleExpand: (tagId: number) => void; onProductPress: (id: number) => void; } // Active tag's products grid — rendered inside the vertical FlatList so it // scrolls with the page. const TagScene = memo(({ dashboardTags, activeTagId, productsByTagId, expandedByTagId, onToggleExpand, onProductPress, }: TagSceneProps) => { const tagId = activeTagId ?? dashboardTags[0]?.id ?? null; if (tagId == null) return null; const products = productsByTagId[tagId] || []; const expanded = !!expandedByTagId[tagId]; const visible = expanded ? products : products.slice(0, 6); const hasMore = products.length > 6 && !expanded; return ( {products.length > 0 ? ( {visible.map((product: any) => ( ))} ) : ( No products in this category yet )} {hasMore && ( onToggleExpand(tagId)} > Show More )} ); }); interface ExploreProductItemProps { item: any; onPress: (id: number) => void; } const ExploreProductItem = memo(({ item, onPress }: ExploreProductItemProps) => { const handlePress = useCallback(() => onPress(item.id), [item.id, onPress]); return ( ); }); interface SlotItemProps { item: any; } const SlotItem = memo(({ item }: SlotItemProps) => ); interface ProductItemProps { item: any; onPress: (id: number) => void; } const ProductItem = memo(({ item, onPress }: ProductItemProps) => { const handlePress = useCallback(() => onPress(item.id), [item.id, onPress]); return ( ); }); interface ListHeaderProps { dashboardTags: any[]; activeTagId: number | null; productsByTagId: Record; expandedByTagId: Record; onToggleExpand: (tagId: number) => void; onProductPress: (id: number) => void; onSelectTag: (id: number) => void; storesData: any; sortedSlots: any[]; } const ListHeader = memo(({ dashboardTags, activeTagId, productsByTagId, expandedByTagId, onToggleExpand, onProductPress, onSelectTag, storesData, sortedSlots, }: ListHeaderProps) => { const renderSlotItem = useCallback(({ item }: { item: any }) => ( ), []); return ( <> {dashboardTags.length > 0 && ( )} {dashboardTags.length > 0 && ( )} {storesData?.stores && storesData.stores.length > 0 && ( Our Stores Fresh from our locations {storesData.stores.map((store: any) => ( ))} )} {sortedSlots.length > 0 && ( Upcoming Delivery Slots Plan your fresh deliveries ahead item.id.toString()} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={staticStyles.slotsListContent} decelerationRate="fast" snapToInterval={280 + 16} renderItem={renderSlotItem} removeClippedSubviews={true} /> )} All Available Products Browse our complete selection ); }); export default function Dashboard() { const router = useRouter(); const userDetails = useUserDetails(); const [inputQuery, setInputQuery] = useState(""); const [isLoadingDialogOpen, setIsLoadingDialogOpen] = useState(false); const [displayedProducts, setDisplayedProducts] = useState([]); const [visibleCount, setVisibleCount] = useState(21); const [hasMore, setHasMore] = useState(true); const { getQuickestSlot } = useProductSlotIdentifier(); const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap); const isSlotsLoaded = useCentralSlotStore((state) => state.isSlotsLoaded); const refetchProducts = useCentralProductStore((state) => state.refetchProducts); const refetchSlotsFromStore = useCentralSlotStore((state) => state.refetchSlots); const [isRefreshing, setIsRefreshing] = useState(false); const { data: productsData, isLoading, error, } = useAllProducts(); const { data: essentialConsts, error: constsError, refetch: refetchConsts } = useGetEssentialConsts(); const { data: storesData, refetch: refetchStores } = useStores(); const { data: slotsData } = useSlots(); const products = productsData?.products || []; const dashboardTags = productsData?.tags || []; const [selectedTagId, setSelectedTagId] = useState(null); const activeTagId = selectedTagId ?? dashboardTags[0]?.id ?? null; const [expandedByTagId, setExpandedByTagId] = useState>({}); const handleToggleExpand = useCallback((tagId: number) => { setExpandedByTagId((prev) => ({ ...prev, [tagId]: true })); }, []); React.useEffect(() => { if (products.length === 0) { setDisplayedProducts([]) setHasMore(false) return } const allSorted = products .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) || (isSlotsLoaded && !slotA) const bOutOfStock = Boolean(productSlotsMap[b.id]?.isOutOfStock) || (isSlotsLoaded && !slotB) if (aOutOfStock && !bOutOfStock) return 1 if (!aOutOfStock && bOutOfStock) return -1 return 0 }) setDisplayedProducts(allSorted) setVisibleCount(21) setHasMore(allSorted.length > 21) }, [productsData, productSlotsMap]); const handleShowMore = useCallback(() => { setVisibleCount((prev) => { const next = prev + 21; setHasMore(next < displayedProducts.length); return next; }); }, [displayedProducts.length]); const sortedSlots = useMemo(() => { if (!slotsData?.slots) return []; const now = dayjs(); return [...slotsData.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)); }); }, [slotsData]); const productsByTagId = useMemo(() => { const map: Record = {}; for (const tag of dashboardTags) { const productById = new Map(); for (const product of products) { productById.set(product.id, product); } // Respect the admin-curated order — tag.productIds is already ordered by // the tag's sortOrder (backend reorders it when building the cache). // Out-of-stock items are pushed to the end, preserving their relative order. const ordered = (tag.productIds || []) .map((id: number) => productById.get(id)) .filter(Boolean) as any[]; const inStock: any[] = []; const outOfStock: any[] = []; for (const product of ordered) { const isOut = Boolean(productSlotsMap[product.id]?.isOutOfStock) || (isSlotsLoaded && !getQuickestSlot(product.id)); if (isOut) outOfStock.push(product); else inStock.push(product); } map[tag.id] = [...inStock, ...outOfStock]; } return map; }, [dashboardTags, products, productSlotsMap, getQuickestSlot, isSlotsLoaded]); const handleRefresh = useCallback(async () => { setIsRefreshing(true); try { const promises = []; if (refetchProducts) { promises.push(refetchProducts()); } if (refetchSlotsFromStore) { promises.push(refetchSlotsFromStore()); } promises.push(refetchStores()); promises.push(refetchConsts()); await Promise.all(promises); } finally { setIsRefreshing(false); } }, [refetchProducts, refetchSlotsFromStore, refetchStores, refetchConsts]); useManualRefresh(() => { handleRefresh(); }); useMarkDataFetchers(() => { handleRefresh(); }); const handleProductPress = useCallback((id: number) => { router.push(`/(drawer)/(tabs)/home/product-detail/${id}`); }, [router]); const handleSearchPress = useCallback(() => { router.push("/(drawer)/(tabs)/home/search-results"); }, [router]); const renderProductItem = useCallback(({ item }: { item: any }) => ( // ), [handleProductPress]); const listHeader = useMemo(() => ( ), [dashboardTags, activeTagId, productsByTagId, expandedByTagId, handleToggleExpand, handleProductPress, storesData, sortedSlots]); const pageTint = activeTagId == null ? '#FFFFFF' : getTagColor(activeTagId).pageBg; const pageTintStyle = useMemo(() => [ tw`flex-1`, { backgroundColor: '#FFFFFF' } ], [pageTint]); const searchBarContainerStyle = useMemo(() => [ tw`w-full px-4 pt-4 pb-0`, { backgroundColor: '#FFFFFF' } ], [pageTint]); const listContentContainerStyle = useMemo(() => [ tw`pb-24`, staticStyles.flatListContent ], []); if (isLoading) { return ( Loading products... ); } if (error || constsError) { return ( Oops! {error ? 'Failed to load products' : 'Failed to load app settings'} ); } return ( { }} onPress={handleSearchPress} editable={false} containerStyle={tw`bg-white`} onSubmitEditing={() => { if (inputQuery.trim()) { router.push(`/(drawer)/(tabs)/home/search-results?q=${encodeURIComponent(inputQuery.trim())}`); } }} returnKeyType="search" /> item.id.toString()} numColumns={3} style={{ backgroundColor: '#FFFFFF' }} contentContainerStyle={listContentContainerStyle} columnWrapperStyle={staticStyles.columnWrapper} renderItem={renderProductItem} ListHeaderComponent={listHeader} ListFooterComponent={ hasMore ? ( Show More ) : null } refreshControl={ } ListEmptyComponent={ No products available } // NOTE: do not add removeClippedSubviews here - it breaks touch // delivery to the nested horizontal scrollables in ListHeaderComponent // (tag tabs / slots) on Android. maxToRenderPerBatch={10} windowSize={5} initialNumToRender={10} updateCellsBatchingPeriod={50} /> ); }