freshyo/apps/user-ui/app/(drawer)/(tabs)/home/index.tsx
2026-02-04 15:34:38 +05:30

678 lines
24 KiB
TypeScript
Executable file

import React, { useState } from "react";
import { View, Dimensions, Image, Alert, ScrollView, StatusBar as RNStatusBar, RefreshControl } from "react-native";
import { StatusBar as ExpoStatusBar } from 'expo-status-bar';
import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
import {
theme,
tw,
useManualRefresh,
useMarkDataFetchers,
LoadingDialog,
AppContainer,
MyTouchableOpacity,
MyText, MyTextInput, SearchBar, useStatusBarStore,
colors
} from "common-ui";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import FontAwesome5 from "@expo/vector-icons/FontAwesome5";
import { Ionicons } from "@expo/vector-icons";
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 { useGetCart } from "@/hooks/cart-query-hooks";
import { useProductSlotIdentifier } from "@/hooks/useProductSlotIdentifier";
import FloatingCartBar from "@/components/floating-cart-bar";
import AddressSelector from "@/components/AddressSelector";
import BannerCarousel from "@/components/BannerCarousel";
import TestingPhaseNote from "@/components/TestingPhaseNote";
import { useUserDetails } from "@/src/contexts/AuthContext";
import TabLayoutWrapper from "@/components/TabLayoutWrapper";
import { useNavigationStore } from "@/src/store/navigationStore";
import { useGetEssentialConsts } from "@/src/api-hooks/essential-consts.api";
import NextOrderGlimpse from "@/components/NextOrderGlimpse";
dayjs.extend(relativeTime);
// import { StatusBar } from "expo-status-bar";
const { width: screenWidth } = Dimensions.get("window");
const itemWidth = screenWidth * 0.45; // 45% of screen width
const gridItemWidth = (screenWidth - 48) / 2; // Half of screen width minus padding
const RenderStore = ({
item,
}: {
item: any;
}) => {
const router = useRouter();
const { setNavigatedFromHome, setSelectedStoreId } = useNavigationStore();
const handlePress = () => {
setNavigatedFromHome(true);
setSelectedStoreId(item.id);
router.push('/(drawer)/(tabs)/stores');
};
return (
<MyTouchableOpacity
style={tw`items-center mb-4`}
onPress={handlePress}
activeOpacity={0.7}
>
<View
style={tw`w-16 h-16 rounded-2xl bg-white/20 border-2 border-white/30 items-center justify-center mb-2 shadow-lg 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="#FFF" />
)}
</View>
<MyText
style={tw`font-bold text-xs text-center tracking-wide drop-shadow-sm text-neutral-800`}
numberOfLines={1}
>
{item.name.replace(/^The\s+/i, "")}
</MyText>
</MyTouchableOpacity>
);
};
const headerColor = colors.secondaryPink;
// Format time range helper
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}`;
}
};
export default function Dashboard() {
const router = useRouter();
const userDetails = useUserDetails();
const [inputQuery, setInputQuery] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
const [isLoadingDialogOpen, setIsLoadingDialogOpen] = useState(false);
const [gradientHeight, setGradientHeight] = useState(0);
const [stickyBarLayout, setStickyBarLayout] = useState({ y: 0, height: 0 });
const [whiteSectionLayout, setWhiteSectionLayout] = useState({ y: 0 });
const [displayedProducts, setDisplayedProducts] = useState<any[]>([]);
const [endIndex, setEndIndex] = useState(10);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const { backgroundColor } = useStatusBarStore();
const { getQuickestSlot } = useProductSlotIdentifier();
const [isRefreshing, setIsRefreshing] = useState(false);
const {
data: productsData,
isLoading,
error,
refetch,
} = trpc.common.product.getAllProductsSummary.useQuery({
searchQuery: searchQuery || undefined,
tagId: selectedTagId || undefined,
});
const { data: essentialConsts, isLoading: isLoadingConsts, error: constsError, refetch: refetchConsts } = useGetEssentialConsts();
const { data: storesData, refetch: refetchStores } = trpc.user.stores.getStores.useQuery();
const { data: slotsData, refetch: refetchSlots } = trpc.user.slots.getSlotsWithProducts.useQuery();
const products = productsData?.products || [];
// Initialize with the first batch of products (only those with available slots)
React.useEffect(() => {
if (products.length > 0 && displayedProducts.length === 0) {
const initialBatchRaw = products;
// Filter to include only products with available slots
// const initialBatch = initialBatchRaw.filter(product => {
// const slot = getQuickestSlot(product.id);
// return !product.isOutOfStock;
// });
const initialBatch = initialBatchRaw
.filter(p => typeof p.id === "number")
.sort((a, b) => {
const slotA = getQuickestSlot(a.id);
const slotB = getQuickestSlot(b.id);
if (slotA && !slotB) return -1;
if (!slotA && slotB) return 1;
if(a.isOutOfStock && !b.isOutOfStock) return 1;
if(!a.isOutOfStock && b.isOutOfStock) return -1;
return 0;
})
setDisplayedProducts(initialBatch);
setHasMore(products.length > 10);
setEndIndex(10);
}
}, [productsData]);
// Extract popular items IDs as an array to preserve order
const popularItemIds = (() => {
const popularItems = essentialConsts?.popularItems;
if (!popularItems) return [];
if (Array.isArray(popularItems)) {
return popularItems.map((id: any) => parseInt(id)).filter((id: number) => !isNaN(id));
} else if (typeof popularItems === 'string') {
return popularItems
.split(',')
.map((id: string) => parseInt(id.trim()))
.filter((id: number) => !isNaN(id));
}
return [];
})();
const sortedSlots = React.useMemo(() => {
if (!slotsData?.slots) return [];
return slotsData.slots.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]);
// Filter products to only include those whose ID exists in popularItemIds, preserving order
// Only filter when both products and essentialConsts are loaded
const popularProducts = popularItemIds
.map(id => products.find(product => product.id === id))
.filter((product): product is NonNullable<typeof product> => product != null);
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await Promise.all([
refetch(),
refetchStores(),
refetchSlots(),
refetchConsts(),
]);
} finally {
setIsRefreshing(false);
}
};
useManualRefresh(() => {
handleRefresh();
});
useMarkDataFetchers(() => {
handleRefresh();
});
const handleScroll = (event: any) => {
};
if (isLoading || isLoadingConsts) {
return (
<View style={tw`flex-1 justify-center items-center bg-gray-50`}>
<MyText style={tw`text-gray-500 font-medium`}>
{isLoading ? 'Loading products...' : 'Loading app settings...'}
</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>
{/* <ExpoStatusBar style="light" backgroundColor={headerColor} /> */}
<ScrollView
style={[tw`flex-1 bg-white`, { position: 'relative' }]}
stickyHeaderIndices={[2]}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor="#3b82f6"
colors={["#3b82f6"]}
/>
}
onScroll={(e) => {
handleScroll(e);
// Check if we're near the end of the scroll for vertical loading
const { layoutMeasurement, contentOffset, contentSize } = e.nativeEvent;
const paddingToBottom = 40;
if (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom) {
// Load more products when reaching near the end
if (!isLoadingMore && hasMore) {
// loadMoreProducts();
}
}
}}
scrollEventThrottle={16}
>
<LinearGradient
// colors={[theme.colors.brand50, theme.colors.brand50]}
colors={[headerColor, headerColor]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0.5 }}
style={[
tw`absolute -top-8 left-0 right-0 shadow-lg`,
{ height: gradientHeight + 32, zIndex: -1 }
]}
/>
<View style={tw`flex-row justify-between items-start pt-2 px-4`}>
<AddressSelector mode="home" />
<MyTouchableOpacity
onPress={() => router.push("/(drawer)/(tabs)/me")}
style={tw`bg-white/10 rounded-full border border-white/10`}
>
{userDetails?.profileImage ? (
<Image
source={{ uri: userDetails.profileImage }}
style={tw`w-12 h-12 rounded-full`}
resizeMode="cover"
/>
) : (
<MaterialIcons name="person" size={24} color="#FFF" />
)}
</MyTouchableOpacity>
</View>
<View
// style={[tw`w-full px-4 pt-4 mb-4`, { backgroundColor }]}
style={[tw`w-full px-4 pt-4 mb-4`, { backgroundColor }]}
onLayout={(event) => {
const { y, height } = event.nativeEvent.layout;
setStickyBarLayout({ y, height });
}}
>
<SearchBar
value={""}
onChangeText={() => { }}
onPress={() => router.push("/(drawer)/(tabs)/home/search-results")}
editable={false}
containerStyle={tw` bg-white`}
onSubmitEditing={() => {
if (inputQuery.trim()) {
router.push(
`/(drawer)/(tabs)/home/search-results?q=${encodeURIComponent(
inputQuery.trim()
)}`
);
}
}}
returnKeyType="search"
/>
</View>
<View
style={tw``}
onLayout={(event) => {
const { y, height } = event.nativeEvent.layout;
setGradientHeight(y + height);
}}
>
{/* Stores Section */}
{storesData?.stores && storesData.stores.length > 0 && (
<View style={tw`px-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-white tracking-tight drop-shadow-md text-neutral-800`}
>
Our Stores
</MyText>
<MyText style={tw`text-xs font-medium mt-0.5 text-neutral-800`}>
Fresh from our locations
</MyText>
</View>
</View>
<View style={tw`pb-2`}>
<View style={tw`flex-row flex-wrap`}>
{storesData.stores.map((store, index) => (
<View key={store.id} style={tw`w-1/4 p-1`}>
<RenderStore
item={store}
/>
</View>
))}
</View>
</View>
</View>
)}
</View>
{/* Banner Carousel */}
<View style={tw`py-4`}>
<BannerCarousel />
</View>
{/* White Section */}
<View
style={tw`bg-white rounded-t-3xl px-4`}
onLayout={(event) => {
const { y } = event.nativeEvent.layout;
setWhiteSectionLayout({ y });
}}
>
<View style={tw`py-2`}>
<NextOrderGlimpse />
</View>
{/* Section Title */}
<View style={tw`mb-4 pt-2 px-1`}>
<MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>
Popular Items
</MyText>
<MyText style={tw`text-sm text-gray-500 font-medium`}>
Trending fresh picks just for you
</MyText>
</View>
<View style={tw`relative`}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={tw` pb-4`}
>
{popularProducts.map((item, index: number) => (
<View key={index} style={tw`mr-4`}>
<ProductCard
item={item}
itemWidth={itemWidth}
onPress={() =>
router.push(
`/(drawer)/(tabs)/home/product-detail/${item.id}`
)
}
showDeliveryInfo={false}
miniView={true}
/>
</View>
))}
</ScrollView>
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.08)"]}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
style={tw`absolute right-0 top-0 bottom-4 w-12 rounded-l-xl`}
pointerEvents="none"
/>
</View>
{/* Upcoming Deliveries Section */}
{sortedSlots.length > 0 && (
<View style={tw`mt-2 mb-4`}>
<View style={tw`flex-row items-center justify-between px-1 mb-6`}>
<View>
<MyText
style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}
>
Upcoming Delivery Slots
</MyText>
<MyText style={tw`text-sm text-gray-500 font-medium mt-1`}>
Plan your fresh deliveries ahead
</MyText>
</View>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={tw` pb-6`}
decelerationRate="fast"
snapToInterval={280 + 16}
>
{sortedSlots.slice(0, 5).map((slot) => {
const now = dayjs();
const freezeTime = dayjs(slot.freezeTime);
const isClosingSoon =
freezeTime.diff(now, "hour") < 4 && freezeTime.isAfter(now);
return (
<MyTouchableOpacity
key={slot.id}
style={[
tw`bg-white rounded-[24px] p-5 mr-4 shadow-xl shadow-slate-200 border border-slate-100 min-w-[280px]`,
isClosingSoon
? tw`border-l-4 border-l-amber-400`
: tw`border-l-4 border-l-brand500`,
]}
onPress={() =>
router.push(
`/(drawer)/(tabs)/home/slot-view?slotId=${slot.id}`
)
}
activeOpacity={0.9}
>
{/* Status Badge */}
<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>
{/* Main Time Grid */}
<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>
{/* Product Teaser */}
<View style={tw`flex-row items-center`}>
<View style={tw`flex-row mr-3`}>
{slot.products.slice(0, 3).map((p, i) => (
<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>
<MyText style={tw`text-[11px] font-bold text-brand600`}>
View all {slot.products.length} items
</MyText>
<MaterialIcons
name="chevron-right"
size={16}
color={theme.colors.brand600}
/>
</View>
</MyTouchableOpacity>
);
})}
</ScrollView>
</View>
)}
{/* All Products Section - Vertical Infinite Scroll */}
<View style={tw`mt-2 mb-4`}>
<View style={tw`flex-row items-center justify-between px-1 mb-6`}>
<View>
<MyText
style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}
>
All Available Products
</MyText>
<MyText style={tw`text-sm text-gray-500 font-medium mt-1`}>
Browse our complete selection
</MyText>
</View>
</View>
{/* Product List */}
<MyFlatList
// data={products}
data={displayedProducts}
keyExtractor={(item) => item.id}
numColumns={2}
// contentContainerStyle={tw`pb-8`}
contentContainerStyle={[tw` pb-24`, { gap: 16 }]}
columnWrapperStyle={{gap: 16}}
renderItem={({ item, index }) => (
<ProductCard
item={item}
itemWidth={gridItemWidth}
onPress={() =>
router.push(
`/(drawer)/(tabs)/home/product-detail/${item.id}`
)
}
showDeliveryInfo={true}
miniView={false}
useAddToCartDialog={true}
key={item.id}
/>
)}
initialNumToRender={4}
maxToRenderPerBatch={4}
windowSize={4}
// onEndReached={() => {
// if (!isLoadingMore && hasMore) {
// loadMoreProducts();
// }
// }}
onEndReachedThreshold={0.5}
ListFooterComponent={
isLoadingMore ? (
<View style={tw`items-center py-4`}>
<MyText>Loading more...</MyText>
</View>
) : null
}
ListEmptyComponent={
<View style={tw`items-center py-8`}>
<MyText style={tw`text-gray-500`}>No products available</MyText>
</View>
}
/>
</View>
</View>
<LoadingDialog open={isLoadingDialogOpen} message="Adding to cart..." />
<AddToCartDialog />
<View style={tw`h-16`}></View>
</ScrollView>
<View style={tw`absolute bottom-2 left-4 right-4`}>
<FloatingCartBar />
</View>
</TabLayoutWrapper>
);
}