783 lines
26 KiB
TypeScript
Executable file
783 lines
26 KiB
TypeScript
Executable file
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 (
|
|
<MyTouchableOpacity
|
|
style={tw`items-center mb-4`}
|
|
onPress={handlePress}
|
|
activeOpacity={0.7}
|
|
>
|
|
<View
|
|
style={tw`w-16 h-16 rounded-2xl bg-brand50 border border-brand100 items-center justify-center mb-2 shadow-sm overflow-hidden`}
|
|
>
|
|
{item.signedImageUrl ? (
|
|
<Image source={{ uri: item.signedImageUrl }} style={tw`w-16 h-16 rounded-2xl`} resizeMode="cover" />
|
|
) : (
|
|
<MaterialIcons name="storefront" size={28} color={theme.colors.brand600} />
|
|
)}
|
|
</View>
|
|
<MyText style={tw`font-bold text-xs text-center tracking-wide text-neutral-800`} numberOfLines={1}>
|
|
{item.name.replace(/^The\s+/i, "")}
|
|
</MyText>
|
|
</MyTouchableOpacity>
|
|
);
|
|
});
|
|
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 (
|
|
<MyTouchableOpacity
|
|
testID={`slot-card-${slot.id}`}
|
|
style={[
|
|
tw`bg-white rounded-[24px] p-5 mr-4 shadow-sm border border-gray-100 min-w-[280px]`,
|
|
isClosingSoon ? tw`border-l-4 border-l-amber-400` : tw`border-l-4 border-l-brand500`,
|
|
]}
|
|
onPress={handlePress}
|
|
activeOpacity={0.9}
|
|
>
|
|
<View style={tw`flex-row justify-end items-start mb-4`}>
|
|
{isClosingSoon && (
|
|
<View style={tw`bg-amber-50 px-2 py-0.5 rounded-md border border-amber-100 flex-row items-center`}>
|
|
<View style={tw`w-1 h-1 rounded-full bg-amber-500 mr-1`} />
|
|
<MyText style={tw`text-[10px] font-bold text-amber-700`}>CLOSING SOON</MyText>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
<View style={tw`flex-row justify-between mb-5`}>
|
|
<View style={tw`flex-1 mr-4`}>
|
|
<View style={tw`flex-row items-center mb-1.5`}>
|
|
<View style={tw`bg-brand50 p-1 rounded-md mr-1.5`}>
|
|
<MaterialIcons name="local-shipping" size={12} color={theme.colors.brand600} />
|
|
</View>
|
|
<MyText style={tw`text-[10px] font-bold text-brand700 uppercase`}>Delivery At</MyText>
|
|
</View>
|
|
<MyText style={tw`text-sm font-extrabold text-slate-900`}>{formatTimeRange(slot.deliveryTime)}</MyText>
|
|
<MyText style={tw`text-[11px] font-bold text-slate-500`}>{dayjs(slot.deliveryTime).format("ddd, MMM DD")}</MyText>
|
|
</View>
|
|
|
|
<View style={tw`flex-1`}>
|
|
<View style={tw`flex-row items-center mb-1.5`}>
|
|
<View style={tw`bg-amber-50 p-1 rounded-md mr-1.5`}>
|
|
<MaterialIcons name="timer" size={12} color="#D97706" />
|
|
</View>
|
|
<MyText style={tw`text-[10px] font-bold text-amber-700 uppercase`}>Order By</MyText>
|
|
</View>
|
|
<MyText style={tw`text-sm font-extrabold text-slate-900`}>{dayjs(slot.freezeTime).format("h:mm A")}</MyText>
|
|
<MyText style={tw`text-[11px] font-bold text-slate-500`}>{dayjs(slot.freezeTime).format("ddd, MMM DD")}</MyText>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={tw`flex-row items-center`}>
|
|
<View style={tw`flex-row mr-3`}>
|
|
{slot.products.slice(0, 3).map((p: any, i: number) => (
|
|
<View
|
|
key={p.id}
|
|
style={[tw`w-8 h-8 rounded-full border-2 border-white bg-slate-100 overflow-hidden`, i > 0 && tw`-ml-3`]}
|
|
>
|
|
{p.images?.[0] ? (
|
|
<Image source={{ uri: p.images?.[0] }} style={tw`w-full h-full`} />
|
|
) : (
|
|
<MaterialIcons name="image" size={14} color="#94A3B8" />
|
|
)}
|
|
</View>
|
|
))}
|
|
</View>
|
|
<View style={tw`flex-row items-center bg-brand50 rounded-full px-3 py-1.5`}>
|
|
<MyText style={tw`text-[11px] font-bold text-brand700`}>View all {slot.products.length} items</MyText>
|
|
<MaterialIcons name="chevron-right" size={16} color={theme.colors.brand600} style={tw`ml-0.5`} />
|
|
</View>
|
|
</View>
|
|
</MyTouchableOpacity>
|
|
);
|
|
});
|
|
|
|
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<any>(null);
|
|
const positions = useRef<number[]>([]);
|
|
|
|
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 (
|
|
<ScrollView
|
|
ref={scrollRef}
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
// horizontal ScrollViews default to flexGrow: 1 — pin it to content height
|
|
// so it doesn't eat the page space below the tabs
|
|
style={[tw`bg-transparent`, { flexGrow: 0, height: 48 }]}
|
|
contentContainerStyle={tw`px-2 items-center`}
|
|
>
|
|
{routes.map((route, i) => {
|
|
const isActive = i === index;
|
|
return (
|
|
<MyTouchableOpacity
|
|
key={route.key}
|
|
onLayout={(e: any) => {
|
|
positions.current[i] = e.nativeEvent.layout.x;
|
|
}}
|
|
onPress={() => onSelectTag(Number(route.key))}
|
|
style={tw`h-12 px-3 justify-center`}
|
|
activeOpacity={0.7}
|
|
>
|
|
<View style={tw`items-center justify-center`}>
|
|
<MyText
|
|
style={[
|
|
tw`text-sm font-bold`,
|
|
isActive
|
|
? { color: theme.colors.brand600 }
|
|
: { color: '#64748B' },
|
|
]}
|
|
>
|
|
{route.title}
|
|
</MyText>
|
|
<View
|
|
style={[
|
|
tw`h-1 rounded-full mt-1 self-stretch`,
|
|
{
|
|
backgroundColor: isActive
|
|
? theme.colors.brand500
|
|
: 'transparent',
|
|
},
|
|
]}
|
|
/>
|
|
</View>
|
|
</MyTouchableOpacity>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
);
|
|
});
|
|
|
|
interface TagSceneProps {
|
|
dashboardTags: any[];
|
|
activeTagId: number | null;
|
|
productsByTagId: Record<number, any[]>;
|
|
expandedByTagId: Record<number, boolean>;
|
|
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 (
|
|
<View style={tw`mt-3`}>
|
|
{products.length > 0 ? (
|
|
<View style={tw`flex-row flex-wrap justify-between`}>
|
|
{visible.map((product: any) => (
|
|
<ExploreProductItem key={product.id} item={product} onPress={onProductPress} />
|
|
))}
|
|
</View>
|
|
) : (
|
|
<View style={tw`py-6 items-center`}>
|
|
<MyText style={tw`text-sm text-gray-500 font-medium`}>
|
|
No products in this category yet
|
|
</MyText>
|
|
</View>
|
|
)}
|
|
{hasMore && (
|
|
<MyTouchableOpacity
|
|
style={tw`self-center mt-1 mb-2 px-5 py-2.5 rounded-full bg-brand500 shadow-sm`}
|
|
activeOpacity={0.85}
|
|
onPress={() => onToggleExpand(tagId)}
|
|
>
|
|
<MyText style={tw`text-white text-sm font-semibold`}>Show More</MyText>
|
|
</MyTouchableOpacity>
|
|
)}
|
|
</View>
|
|
);
|
|
});
|
|
|
|
interface ExploreProductItemProps {
|
|
item: any;
|
|
onPress: (id: number) => void;
|
|
}
|
|
|
|
const ExploreProductItem = memo(({ item, onPress }: ExploreProductItemProps) => {
|
|
const handlePress = useCallback(() => onPress(item.id), [item.id, onPress]);
|
|
|
|
return (
|
|
<View style={tw`mb-3`}>
|
|
<ProductCard
|
|
item={item}
|
|
itemWidth={heroItemWidth}
|
|
onPress={handlePress}
|
|
showDeliveryInfo={false}
|
|
useAddToCartDialog={true}
|
|
miniView={true}
|
|
variant="hero"
|
|
/>
|
|
</View>
|
|
);
|
|
});
|
|
|
|
interface SlotItemProps {
|
|
item: any;
|
|
}
|
|
|
|
const SlotItem = memo(({ item }: SlotItemProps) => <SlotCard slot={item} />);
|
|
|
|
interface ProductItemProps {
|
|
item: any;
|
|
onPress: (id: number) => void;
|
|
}
|
|
|
|
const ProductItem = memo(({ item, onPress }: ProductItemProps) => {
|
|
const handlePress = useCallback(() => onPress(item.id), [item.id, onPress]);
|
|
|
|
return (
|
|
<View style={tw`mb-3`}>
|
|
<ProductCard
|
|
item={item}
|
|
itemWidth={heroItemWidth}
|
|
onPress={handlePress}
|
|
showDeliveryInfo={false}
|
|
miniView={true}
|
|
useAddToCartDialog={true}
|
|
variant="hero"
|
|
/>
|
|
</View>
|
|
);
|
|
});
|
|
|
|
interface ListHeaderProps {
|
|
dashboardTags: any[];
|
|
activeTagId: number | null;
|
|
productsByTagId: Record<number, any[]>;
|
|
expandedByTagId: Record<number, boolean>;
|
|
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 }) => (
|
|
<SlotItem item={item} />
|
|
), []);
|
|
|
|
return (
|
|
<>
|
|
{dashboardTags.length > 0 && (
|
|
<TagTabStrip
|
|
dashboardTags={dashboardTags}
|
|
activeTagId={activeTagId}
|
|
onSelectTag={onSelectTag}
|
|
/>
|
|
)}
|
|
{dashboardTags.length > 0 && (
|
|
<View
|
|
style={[
|
|
tw`mx-4 mt-1 rounded-[28px] px-3 pt-3 pb-4`,
|
|
{ backgroundColor: theme.colors.brand25 },
|
|
]}
|
|
>
|
|
<TagScene
|
|
dashboardTags={dashboardTags}
|
|
activeTagId={activeTagId}
|
|
productsByTagId={productsByTagId}
|
|
expandedByTagId={expandedByTagId}
|
|
onToggleExpand={onToggleExpand}
|
|
onProductPress={onProductPress}
|
|
/>
|
|
</View>
|
|
)}
|
|
<View
|
|
style={[
|
|
tw`rounded-t-3xl px-4`,
|
|
{ backgroundColor: '#FFFFFF' },
|
|
]}
|
|
>
|
|
{storesData?.stores && storesData.stores.length > 0 && (
|
|
<View style={tw`mt-4 mb-5 rounded-[28px] bg-white shadow-sm border border-gray-100 px-3 pt-4 pb-2`}>
|
|
<View style={tw`flex-row items-center justify-between mb-4 px-1`}>
|
|
<View>
|
|
<MyText style={tw`text-xl font-extrabold text-gray-900 tracking-tight`}>
|
|
Our Stores
|
|
</MyText>
|
|
<MyText style={tw`text-xs font-medium mt-0.5 text-gray-500`}>Fresh from our locations</MyText>
|
|
</View>
|
|
</View>
|
|
<View style={tw`pb-2`}>
|
|
<View style={tw`flex-row flex-wrap`}>
|
|
{storesData.stores.map((store: any) => (
|
|
<View key={store.id} style={tw`w-1/4 p-1`}>
|
|
<RenderStore item={store} />
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
<View style={tw`py-2`}>
|
|
<NextOrderGlimpse />
|
|
</View>
|
|
|
|
{sortedSlots.length > 0 && (
|
|
<View style={tw`mt-2 mb-4`}>
|
|
<View style={tw`flex-row items-center justify-between px-1 mb-6`}>
|
|
<View>
|
|
<View style={tw`flex-row items-center mb-1.5`}>
|
|
<View style={tw`w-1 h-5 rounded-full bg-brand500 mr-2.5`} />
|
|
<MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>Upcoming Delivery Slots</MyText>
|
|
</View>
|
|
<MyText style={tw`text-sm text-gray-500 font-medium ml-4`}>Plan your fresh deliveries ahead</MyText>
|
|
</View>
|
|
</View>
|
|
<MyFlatList
|
|
data={sortedSlots.slice(0, 5)}
|
|
keyExtractor={(item) => item.id.toString()}
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={staticStyles.slotsListContent}
|
|
decelerationRate="fast"
|
|
snapToInterval={280 + 16}
|
|
renderItem={renderSlotItem}
|
|
removeClippedSubviews={true}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
<View style={tw`mt-2 mb-[11px]`}>
|
|
<View style={tw`flex-row items-center justify-between px-1 mb-2`}>
|
|
<View>
|
|
<View style={tw`flex-row items-center mb-1.5`}>
|
|
<View style={tw`w-1 h-5 rounded-full bg-brand500 mr-2.5`} />
|
|
<MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>All Available Products</MyText>
|
|
</View>
|
|
<MyText style={tw`text-sm text-gray-500 font-medium ml-4`}>Browse our complete selection</MyText>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</>
|
|
);
|
|
});
|
|
|
|
export default function Dashboard() {
|
|
const router = useRouter();
|
|
const userDetails = useUserDetails();
|
|
const [inputQuery, setInputQuery] = useState("");
|
|
const [isLoadingDialogOpen, setIsLoadingDialogOpen] = useState(false);
|
|
const [displayedProducts, setDisplayedProducts] = useState<any[]>([]);
|
|
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<number | null>(null);
|
|
const activeTagId = selectedTagId ?? dashboardTags[0]?.id ?? null;
|
|
const [expandedByTagId, setExpandedByTagId] = useState<Record<number, boolean>>({});
|
|
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<number, any[]> = {};
|
|
for (const tag of dashboardTags) {
|
|
const productById = new Map<number, any>();
|
|
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 }) => (
|
|
<ProductItem item={item} onPress={handleProductPress} />
|
|
// <Image style={{ width: 150, height: 235 }} source={{ uri: item.images[0]}} />
|
|
), [handleProductPress]);
|
|
|
|
const listHeader = useMemo(() => (
|
|
<ListHeader
|
|
dashboardTags={dashboardTags}
|
|
activeTagId={activeTagId}
|
|
productsByTagId={productsByTagId}
|
|
expandedByTagId={expandedByTagId}
|
|
onToggleExpand={handleToggleExpand}
|
|
onProductPress={handleProductPress}
|
|
onSelectTag={setSelectedTagId}
|
|
storesData={storesData}
|
|
sortedSlots={sortedSlots}
|
|
/>
|
|
), [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 (
|
|
<View style={tw`flex-1 justify-center items-center bg-gray-50`}>
|
|
<MyText style={tw`text-gray-500 font-medium`}>
|
|
Loading products...
|
|
</MyText>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (error || constsError) {
|
|
return (
|
|
<View style={tw`flex-1 justify-center items-center bg-gray-50`}>
|
|
<MaterialIcons name="error-outline" size={48} color="#EF4444" />
|
|
<MyText style={tw`text-gray-900 text-lg font-bold mt-4`}>Oops!</MyText>
|
|
<MyText style={tw`text-gray-500 mt-2`}>
|
|
{error ? 'Failed to load products' : 'Failed to load app settings'}
|
|
</MyText>
|
|
</View>
|
|
);
|
|
}
|
|
return (
|
|
<TabLayoutWrapper style={{ backgroundColor: '#FFFFFF' }}>
|
|
<View style={pageTintStyle}>
|
|
<View
|
|
style={searchBarContainerStyle}
|
|
>
|
|
<SearchBar
|
|
value={""}
|
|
onChangeText={() => { }}
|
|
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"
|
|
/>
|
|
</View>
|
|
|
|
<MyFlatList
|
|
data={displayedProducts.slice(0, visibleCount)}
|
|
keyExtractor={(item) => item.id.toString()}
|
|
numColumns={3}
|
|
style={{ backgroundColor: '#FFFFFF' }}
|
|
contentContainerStyle={listContentContainerStyle}
|
|
columnWrapperStyle={staticStyles.columnWrapper}
|
|
renderItem={renderProductItem}
|
|
ListHeaderComponent={listHeader}
|
|
ListFooterComponent={
|
|
hasMore ? (
|
|
<View style={tw`items-center py-4`}>
|
|
<MyTouchableOpacity
|
|
style={tw`px-6 py-3 rounded-full bg-brand500 shadow-sm`}
|
|
activeOpacity={0.85}
|
|
onPress={handleShowMore}
|
|
>
|
|
<MyText style={tw`text-white text-sm font-semibold`}>Show More</MyText>
|
|
</MyTouchableOpacity>
|
|
</View>
|
|
) : null
|
|
}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={isRefreshing}
|
|
onRefresh={handleRefresh}
|
|
tintColor={theme.colors.brand500}
|
|
colors={[theme.colors.brand500]}
|
|
/>
|
|
}
|
|
ListEmptyComponent={
|
|
<View style={tw`items-center py-8`}>
|
|
<MyText style={tw`text-gray-500`}>No products available</MyText>
|
|
</View>
|
|
}
|
|
// 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}
|
|
/>
|
|
|
|
<LoadingDialog open={isLoadingDialogOpen} message="Adding to cart..." />
|
|
<AddToCartDialog />
|
|
<View style={tw`absolute bottom-2 left-4 right-4`}>
|
|
<FloatingCartBar />
|
|
</View>
|
|
</View>
|
|
</TabLayoutWrapper>
|
|
);
|
|
}
|