630 lines
23 KiB
TypeScript
Executable file
630 lines
23 KiB
TypeScript
Executable file
import React, { useState } from "react";
|
|
import { View, Dimensions, Image, Alert, ScrollView, StatusBar as RNStatusBar } 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
|
|
} 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 { 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";
|
|
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 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 text-white tracking-wide drop-shadow-sm text-white`}
|
|
numberOfLines={1}
|
|
>
|
|
{item.name.replace(/^The\s+/i, "")}
|
|
</MyText>
|
|
</MyTouchableOpacity>
|
|
);
|
|
};
|
|
|
|
// const headerColor = '#fedf89'
|
|
// const headerColor = '#444ce7'
|
|
const headerColor = '#f81260'
|
|
|
|
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 [page, setPage] = useState(1);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
const { backgroundColor } = useStatusBarStore();
|
|
|
|
const {
|
|
data: productsData,
|
|
isLoading,
|
|
error,
|
|
refetch,
|
|
} = trpc.common.product.getAllProductsSummary.useQuery({
|
|
searchQuery: searchQuery || undefined,
|
|
tagId: selectedTagId || undefined,
|
|
});
|
|
|
|
const { data: essentialConsts, isLoading: isLoadingConsts, error: constsError } = useGetEssentialConsts();
|
|
|
|
const { data: tagsData } = trpc.common.product.getDashboardTags.useQuery();
|
|
const { data: storesData } = trpc.user.stores.getStores.useQuery();
|
|
const { data: defaultAddressResponse } =
|
|
trpc.user.address.getDefaultAddress.useQuery();
|
|
const { data: slotsData } = trpc.user.slots.getSlotsWithProducts.useQuery();
|
|
|
|
const products = productsData?.products || [];
|
|
const dashboardTags = tagsData?.tags || [];
|
|
const defaultAddress = defaultAddressResponse?.data;
|
|
const { getQuickestSlot } = useProductSlotIdentifier();
|
|
|
|
// Function to load more products
|
|
const loadMoreProducts = () => {
|
|
if (!hasMore || isLoadingMore) return;
|
|
|
|
setIsLoadingMore(true);
|
|
|
|
// Simulate loading more products by taking the next batch
|
|
// In a real app, you would make an API call with pagination params
|
|
setTimeout(() => {
|
|
const batchSize = 10;
|
|
const startIndex = page * batchSize;
|
|
const endIndex = startIndex + batchSize;
|
|
|
|
// Get the next batch of products
|
|
const nextBatch = products.slice(startIndex, endIndex);
|
|
|
|
if (nextBatch.length > 0) {
|
|
setDisplayedProducts(prev => [...prev, ...nextBatch]);
|
|
setPage(prev => prev + 1);
|
|
setHasMore(endIndex < products.length);
|
|
} else {
|
|
setHasMore(false);
|
|
}
|
|
|
|
setIsLoadingMore(false);
|
|
}, 500); // Simulate network delay
|
|
};
|
|
|
|
// Initialize with the first batch of products
|
|
React.useEffect(() => {
|
|
if (products.length > 0) {
|
|
const initialBatch = products.slice(0, 10); // First 10 products
|
|
setDisplayedProducts(initialBatch);
|
|
setHasMore(products.length > 10);
|
|
setPage(1);
|
|
} else {
|
|
setDisplayedProducts([]);
|
|
setHasMore(false);
|
|
}
|
|
}, [products]);
|
|
|
|
// Extract popular items IDs as an array to preserve order
|
|
const popularItemIds = (() => {
|
|
const popularItems = essentialConsts?.popularItems;
|
|
if (!popularItems) return [];
|
|
|
|
if (Array.isArray(popularItems)) {
|
|
// Already an array of IDs
|
|
return popularItems.map((id: any) => parseInt(id)).filter((id: number) => !isNaN(id));
|
|
} else if (typeof popularItems === 'string') {
|
|
// Comma-separated string
|
|
return popularItems
|
|
.split(',')
|
|
.map((id: string) => parseInt(id.trim()))
|
|
.filter((id: number) => !isNaN(id));
|
|
}
|
|
return [];
|
|
})();
|
|
|
|
// 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);
|
|
|
|
|
|
useManualRefresh(() => {
|
|
refetch();
|
|
});
|
|
|
|
useMarkDataFetchers(() => {
|
|
refetch();
|
|
});
|
|
|
|
const handleScroll = (event: any) => {
|
|
// const scrollY = event.nativeEvent.contentOffset.y;
|
|
// const stickyBarBottom = stickyBarLayout.y + stickyBarLayout.height;
|
|
// const whiteSectionTop = whiteSectionLayout.y;
|
|
|
|
// const shouldBeWhite = scrollY + stickyBarBottom >= whiteSectionTop;
|
|
|
|
// if (shouldBeWhite) {
|
|
// updateStatusBarColor('dark', '#ffffff');
|
|
// } else {
|
|
// updateStatusBarColor('light', headerColor);
|
|
// }
|
|
};
|
|
|
|
// React.useFocu(() => {
|
|
// // Initial status bar color
|
|
// return () => updateStatusBarColor('dark', '#ffffff');
|
|
// }, []);
|
|
|
|
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]}
|
|
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-white`}
|
|
>
|
|
Our Stores
|
|
</MyText>
|
|
<MyText style={tw`text-xs text-white/80 font-medium mt-0.5 text-white`}>
|
|
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>
|
|
|
|
<TestingPhaseNote />
|
|
|
|
{/* White Section */}
|
|
<View
|
|
style={tw`bg-white rounded-t-3xl px-4`}
|
|
onLayout={(event) => {
|
|
const { y } = event.nativeEvent.layout;
|
|
setWhiteSectionLayout({ y });
|
|
}}
|
|
>
|
|
{/* 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 */}
|
|
{slotsData?.slots && slotsData.slots.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} // card width + margin
|
|
>
|
|
{slotsData.slots.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`}
|
|
>
|
|
{dayjs(slot.deliveryTime).format("h:mm A")}
|
|
</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 Grid */}
|
|
<View style={tw`flex-row flex-wrap`}>
|
|
{displayedProducts.map((item, index: number) => (
|
|
|
|
<ProductCard
|
|
item={item}
|
|
itemWidth={(screenWidth * 0.9) / 2} // Half of screen width minus padding
|
|
onPress={() =>
|
|
router.push(
|
|
`/(drawer)/(tabs)/home/product-detail/${item.id}`
|
|
)
|
|
}
|
|
showDeliveryInfo={true}
|
|
miniView={false}
|
|
nullIfNotAvailable={true}
|
|
containerComp={({children}) => <View key={item.id} style={tw`w-1/2 pr-2 pb-4`}>{children}</View>}
|
|
/>
|
|
))}
|
|
</View>
|
|
|
|
{isLoadingMore && (
|
|
<View style={tw`items-center py-4`}>
|
|
<MyText>Loading more...</MyText>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
</View>
|
|
|
|
<LoadingDialog open={isLoadingDialogOpen} message="Adding to cart..." />
|
|
<View style={tw`h-16`}></View>
|
|
</ScrollView>
|
|
<View style={tw`absolute bottom-2 left-4 right-4`}>
|
|
<FloatingCartBar />
|
|
</View>
|
|
</TabLayoutWrapper>
|
|
);
|
|
}
|