enh
This commit is contained in:
parent
e2c6a292a4
commit
1139bf0f46
3 changed files with 231 additions and 203 deletions
|
|
@ -1,7 +1,6 @@
|
|||
import React, { useState, useCallback, useMemo, memo } from "react";
|
||||
import React, { useState, useCallback, useMemo, memo, useRef, useEffect } from "react";
|
||||
import { View, Dimensions, Image, RefreshControl } from "react-native";
|
||||
import { TabView, TabBar } from "react-native-tab-view";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { ScrollView } from "react-native-gesture-handler";
|
||||
import { useRouter } from "expo-router";
|
||||
import {
|
||||
theme,
|
||||
|
|
@ -37,14 +36,6 @@ const itemWidth = screenWidth * 0.45;
|
|||
const heroItemWidth = (screenWidth - 72) / 3;
|
||||
const gridItemWidth = (screenWidth - 48) / 2;
|
||||
|
||||
// Hero product card geometry (used to size the tab scene heights)
|
||||
const heroCardImageHeight = heroItemWidth * 0.82;
|
||||
const heroCardTextBlock = 92;
|
||||
const heroCardHeight = heroCardImageHeight + heroCardTextBlock;
|
||||
const heroRowHeight = heroCardHeight + 12; // + mb-3
|
||||
const TAG_GRID_COLUMNS = 3;
|
||||
const TAB_BAR_HEIGHT = 48;
|
||||
|
||||
const formatTimeRange = (deliveryTime: string) => {
|
||||
const time = dayjs(deliveryTime);
|
||||
const endTime = time.add(1, 'hour');
|
||||
|
|
@ -195,22 +186,21 @@ const SlotCard = memo(({ slot }: SlotCardProps) => {
|
|||
);
|
||||
});
|
||||
|
||||
interface TagTabViewProps {
|
||||
interface TagTabStripProps {
|
||||
dashboardTags: any[];
|
||||
activeTagId: number | null;
|
||||
productsByTagId: Record<number, any[]>;
|
||||
onSelectTag: (id: number) => void;
|
||||
onProductPress: (id: number) => void;
|
||||
}
|
||||
|
||||
const TagTabView = memo(({
|
||||
// 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,
|
||||
productsByTagId,
|
||||
onSelectTag,
|
||||
onProductPress,
|
||||
}: TagTabViewProps) => {
|
||||
const [expandedByTagId, setExpandedByTagId] = useState<Record<number, boolean>>({});
|
||||
}: TagTabStripProps) => {
|
||||
const scrollRef = useRef<any>(null);
|
||||
const positions = useRef<number[]>([]);
|
||||
|
||||
const routes = useMemo(
|
||||
() => dashboardTags.map((tag) => ({ key: String(tag.id), title: tag.tagName })),
|
||||
|
|
@ -222,39 +212,86 @@ const TagTabView = memo(({
|
|||
return idx < 0 ? 0 : idx;
|
||||
}, [dashboardTags, activeTagId]);
|
||||
|
||||
// Height: fit the active tag's visible product rows (default 6 per tag),
|
||||
// plus room for the "Show More" button when it has more products.
|
||||
const activeTagKey = routes[index]?.key;
|
||||
const sceneHeight = useMemo(() => {
|
||||
const tagId = Number(activeTagKey);
|
||||
const count = (productsByTagId[tagId]?.length) || 0;
|
||||
const expanded = expandedByTagId[tagId];
|
||||
const visible = expanded ? count : Math.min(count, 6);
|
||||
const rows = Math.max(1, Math.ceil(visible / TAG_GRID_COLUMNS));
|
||||
const showMore = count > 6 && !expanded;
|
||||
return rows * heroRowHeight + 24 + (showMore ? 52 : 0);
|
||||
}, [activeTagKey, productsByTagId, expandedByTagId]);
|
||||
useEffect(() => {
|
||||
const x = positions.current[index];
|
||||
if (x != null) {
|
||||
scrollRef.current?.scrollTo({ x: Math.max(0, x - 16), animated: true });
|
||||
}
|
||||
}, [index]);
|
||||
|
||||
const renderTabBar = useCallback((props: any) => (
|
||||
<TabBar
|
||||
{...props}
|
||||
scrollEnabled
|
||||
activeColor={theme.colors.brand600}
|
||||
inactiveColor="#64748B"
|
||||
indicatorStyle={{
|
||||
backgroundColor: theme.colors.brand500,
|
||||
height: 4,
|
||||
borderRadius: 999,
|
||||
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;
|
||||
}}
|
||||
labelStyle={tw`text-sm font-bold`}
|
||||
style={tw`bg-transparent`}
|
||||
tabStyle={{ width: 'auto', paddingHorizontal: 8, minWidth: 0 }}
|
||||
indicatorContainerStyle={tw`mb-1`}
|
||||
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 renderScene = useCallback(({ route }: any) => {
|
||||
const tagId = Number(route.key);
|
||||
const products = productsByTagId[tagId] || [];
|
||||
const expanded = !!expandedByTagId[tagId];
|
||||
const visible = expanded ? products : products.slice(0, 6);
|
||||
|
|
@ -279,26 +316,13 @@ const TagTabView = memo(({
|
|||
<MyTouchableOpacity
|
||||
style={tw`self-center mt-1 mb-2 px-5 py-2.5 rounded-full bg-brand500 shadow-sm`}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => setExpandedByTagId((prev) => ({ ...prev, [tagId]: true }))}
|
||||
onPress={() => onToggleExpand(tagId)}
|
||||
>
|
||||
<MyText style={tw`text-white text-sm font-semibold`}>Show More</MyText>
|
||||
</MyTouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}, [productsByTagId, expandedByTagId, onProductPress]);
|
||||
|
||||
return (
|
||||
<TabView
|
||||
navigationState={{ index, routes }}
|
||||
onIndexChange={(i) => onSelectTag(Number(routes[i].key))}
|
||||
renderTabBar={renderTabBar}
|
||||
renderScene={renderScene}
|
||||
swipeEnabled
|
||||
lazy
|
||||
style={{ height: TAB_BAR_HEIGHT + sceneHeight }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
interface ExploreProductItemProps {
|
||||
|
|
@ -354,72 +378,58 @@ const ProductItem = memo(({ item, onPress }: ProductItemProps) => {
|
|||
});
|
||||
|
||||
interface ListHeaderProps {
|
||||
gradientHeight: number;
|
||||
onGradientLayout: (height: number) => void;
|
||||
storesData: any;
|
||||
sortedSlots: any[];
|
||||
onProductPress: (id: number) => void;
|
||||
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(({
|
||||
gradientHeight,
|
||||
onGradientLayout,
|
||||
storesData,
|
||||
sortedSlots,
|
||||
onProductPress,
|
||||
dashboardTags,
|
||||
activeTagId,
|
||||
productsByTagId,
|
||||
expandedByTagId,
|
||||
onToggleExpand,
|
||||
onProductPress,
|
||||
onSelectTag,
|
||||
storesData,
|
||||
sortedSlots,
|
||||
}: ListHeaderProps) => {
|
||||
const handleLayout = useCallback((event: any) => {
|
||||
const { y, height } = event.nativeEvent.layout;
|
||||
onGradientLayout(y + height);
|
||||
}, [onGradientLayout]);
|
||||
|
||||
const renderSlotItem = useCallback(({ item }: { item: any }) => (
|
||||
<SlotItem item={item} />
|
||||
), []);
|
||||
|
||||
const gradientStyle = useMemo(() => [
|
||||
tw`absolute left-0 right-0 shadow-lg`,
|
||||
{ height: gradientHeight + 32, zIndex: -1 }
|
||||
], [gradientHeight]);
|
||||
const activeColor = activeTagId == null ? null : getTagColor(activeTagId);
|
||||
const pageTint = activeColor?.pageBg ?? '#FFFFFF';
|
||||
|
||||
return (
|
||||
<>
|
||||
<View onLayout={handleLayout} style={{ backgroundColor: '#FFFFFF' }}>
|
||||
<LinearGradient
|
||||
colors={['#FFFFFF', '#FFFFFF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={gradientStyle}
|
||||
{dashboardTags.length > 0 && (
|
||||
<TagTabStrip
|
||||
dashboardTags={dashboardTags}
|
||||
activeTagId={activeTagId}
|
||||
onSelectTag={onSelectTag}
|
||||
/>
|
||||
|
||||
)}
|
||||
{dashboardTags.length > 0 && (
|
||||
<View
|
||||
style={[
|
||||
tw`mx-4 mb-2 rounded-[28px] px-3 pt-3 pb-[30px]`,
|
||||
tw`mx-4 mt-1 rounded-[28px] px-3 pt-3 pb-4`,
|
||||
{ backgroundColor: theme.colors.brand25 },
|
||||
]}
|
||||
>
|
||||
<TagTabView
|
||||
<TagScene
|
||||
dashboardTags={dashboardTags}
|
||||
activeTagId={activeTagId}
|
||||
productsByTagId={productsByTagId}
|
||||
onSelectTag={onSelectTag}
|
||||
expandedByTagId={expandedByTagId}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onProductPress={onProductPress}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
tw`rounded-t-3xl px-4`,
|
||||
|
|
@ -498,7 +508,6 @@ export default function Dashboard() {
|
|||
const userDetails = useUserDetails();
|
||||
const [inputQuery, setInputQuery] = useState("");
|
||||
const [isLoadingDialogOpen, setIsLoadingDialogOpen] = useState(false);
|
||||
const [gradientHeight, setGradientHeight] = useState(0);
|
||||
const [displayedProducts, setDisplayedProducts] = useState<any[]>([]);
|
||||
const [visibleCount, setVisibleCount] = useState(21);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
|
@ -524,6 +533,10 @@ export default function Dashboard() {
|
|||
|
||||
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(() => {
|
||||
|
|
@ -642,10 +655,6 @@ export default function Dashboard() {
|
|||
router.push(`/(drawer)/(tabs)/home/product-detail/${id}`);
|
||||
}, [router]);
|
||||
|
||||
const handleGradientLayout = useCallback((height: number) => {
|
||||
setGradientHeight(height);
|
||||
}, []);
|
||||
|
||||
const handleSearchPress = useCallback(() => {
|
||||
router.push("/(drawer)/(tabs)/home/search-results");
|
||||
}, [router]);
|
||||
|
|
@ -657,17 +666,17 @@ export default function Dashboard() {
|
|||
|
||||
const listHeader = useMemo(() => (
|
||||
<ListHeader
|
||||
gradientHeight={gradientHeight}
|
||||
onGradientLayout={handleGradientLayout}
|
||||
storesData={storesData}
|
||||
sortedSlots={sortedSlots}
|
||||
onProductPress={handleProductPress}
|
||||
dashboardTags={dashboardTags}
|
||||
activeTagId={activeTagId}
|
||||
productsByTagId={productsByTagId}
|
||||
expandedByTagId={expandedByTagId}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onProductPress={handleProductPress}
|
||||
onSelectTag={setSelectedTagId}
|
||||
storesData={storesData}
|
||||
sortedSlots={sortedSlots}
|
||||
/>
|
||||
), [gradientHeight, handleGradientLayout, storesData, sortedSlots, handleProductPress, dashboardTags, activeTagId, productsByTagId]);
|
||||
), [dashboardTags, activeTagId, productsByTagId, expandedByTagId, handleToggleExpand, handleProductPress, storesData, sortedSlots]);
|
||||
|
||||
const pageTint = activeTagId == null ? '#FFFFFF' : getTagColor(activeTagId).pageBg;
|
||||
const pageTintStyle = useMemo(() => [
|
||||
|
|
@ -762,7 +771,9 @@ export default function Dashboard() {
|
|||
<MyText style={tw`text-gray-500`}>No products available</MyText>
|
||||
</View>
|
||||
}
|
||||
removeClippedSubviews={true}
|
||||
// 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}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { View, Dimensions, ScrollView } from "react-native";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useRouter } from "expo-router";
|
||||
import {
|
||||
tw,
|
||||
useManualRefresh,
|
||||
useMarkDataFetchers,
|
||||
AppContainer,
|
||||
MyFlatList,
|
||||
MyText,
|
||||
MyTouchableOpacity,
|
||||
} from "common-ui";
|
||||
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||
import ProductCard from "@/components/ProductCard";
|
||||
|
|
@ -17,34 +16,35 @@ import FloatingCartBar from "@/components/floating-cart-bar";
|
|||
import TabLayoutWrapper from "@/components/TabLayoutWrapper";
|
||||
|
||||
const { width: screenWidth } = Dimensions.get("window");
|
||||
const itemWidth = screenWidth * 0.45;
|
||||
// Same 3-column hero card sizing used on the home page.
|
||||
const heroItemWidth = (screenWidth - 72) / 3;
|
||||
const SHOW_MORE_STEP = 6;
|
||||
|
||||
const rowListContent = { paddingBottom: 16, paddingHorizontal: 16 };
|
||||
|
||||
interface OffersRowProps {
|
||||
interface OffersSectionProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
products: any[];
|
||||
expanded: boolean;
|
||||
onExpand: () => void;
|
||||
onProductPress: (id: number) => void;
|
||||
}
|
||||
|
||||
const OffersRow = ({ title, subtitle, products, onProductPress }: OffersRowProps) => {
|
||||
const renderItem = ({ item }: { item: any }) => (
|
||||
<View style={tw`mr-4`}>
|
||||
<ProductCard
|
||||
item={item}
|
||||
itemWidth={itemWidth}
|
||||
onPress={() => onProductPress(item.id)}
|
||||
showDeliveryInfo={false}
|
||||
useAddToCartDialog={true}
|
||||
miniView={true}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
// Grid section (home page style): shows the first SHOW_MORE_STEP products
|
||||
// with a "Show More" button to reveal the rest.
|
||||
const OffersSection = ({
|
||||
title,
|
||||
subtitle,
|
||||
products,
|
||||
expanded,
|
||||
onExpand,
|
||||
onProductPress,
|
||||
}: OffersSectionProps) => {
|
||||
const visible = expanded ? products : products.slice(0, SHOW_MORE_STEP);
|
||||
const hasMore = products.length > SHOW_MORE_STEP && !expanded;
|
||||
|
||||
return (
|
||||
<View style={tw`bg-white`}>
|
||||
<View style={tw`mb-2 pt-4 px-4`}>
|
||||
<View style={tw`pt-4`}>
|
||||
<View style={tw`mb-2 px-4`}>
|
||||
<MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>
|
||||
{title}
|
||||
</MyText>
|
||||
|
|
@ -61,24 +61,32 @@ const OffersRow = ({ title, subtitle, products, onProductPress }: OffersRowProps
|
|||
</MyText>
|
||||
</View>
|
||||
) : (
|
||||
<View style={tw`relative`}>
|
||||
<MyFlatList
|
||||
data={products}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={rowListContent}
|
||||
renderItem={renderItem}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
<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-4 top-0 bottom-4 w-12 rounded-l-xl`}
|
||||
pointerEvents="none"
|
||||
<>
|
||||
<View style={tw`flex-row flex-wrap justify-between px-4`}>
|
||||
{visible.map((item: any) => (
|
||||
<View key={item.id} style={tw`mb-3`}>
|
||||
<ProductCard
|
||||
item={item}
|
||||
itemWidth={heroItemWidth}
|
||||
onPress={() => onProductPress(item.id)}
|
||||
showDeliveryInfo={false}
|
||||
useAddToCartDialog={true}
|
||||
miniView={true}
|
||||
variant="hero"
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</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={onExpand}
|
||||
>
|
||||
<MyText style={tw`text-white text-sm font-semibold`}>Show More</MyText>
|
||||
</MyTouchableOpacity>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
|
@ -86,6 +94,8 @@ const OffersRow = ({ title, subtitle, products, onProductPress }: OffersRowProps
|
|||
|
||||
export default function Offers() {
|
||||
const router = useRouter();
|
||||
const [expandedOffers, setExpandedOffers] = useState(false);
|
||||
const [expandedCombos, setExpandedCombos] = useState(false);
|
||||
|
||||
const { data, isLoading, error, refetch } =
|
||||
trpc.user.product.getOffersPage.useQuery();
|
||||
|
|
@ -135,17 +145,21 @@ export default function Offers() {
|
|||
return (
|
||||
<TabLayoutWrapper>
|
||||
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-20`}>
|
||||
<OffersRow
|
||||
title="Combos"
|
||||
subtitle="Bundle your favorites and save"
|
||||
products={combos}
|
||||
onProductPress={handleProductPress}
|
||||
/>
|
||||
|
||||
<OffersRow
|
||||
<OffersSection
|
||||
title="Offers"
|
||||
subtitle="Great prices on select items"
|
||||
products={offers}
|
||||
expanded={expandedOffers}
|
||||
onExpand={() => setExpandedOffers(true)}
|
||||
onProductPress={handleProductPress}
|
||||
/>
|
||||
|
||||
<OffersSection
|
||||
title="Combos"
|
||||
subtitle="Bundle your favorites and save"
|
||||
products={combos}
|
||||
expanded={expandedCombos}
|
||||
onExpand={() => setExpandedCombos(true)}
|
||||
onProductPress={handleProductPress}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import WebViewWrapper from "@/components/WebViewWrapper";
|
|||
import BackHandlerWrapper from "@/components/BackHandler";
|
||||
import AddToCartDialog from "@/src/components/AddToCartDialog";
|
||||
import React from "react";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import NotifChecker from "@/services/notif-service/notif-checker";
|
||||
|
||||
export default function RootLayout() {
|
||||
|
|
@ -47,6 +48,7 @@ export default function RootLayout() {
|
|||
}
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||
<MyStatusBar />
|
||||
<SafeAreaProvider>
|
||||
|
|
@ -90,5 +92,6 @@ export default function RootLayout() {
|
|||
</SafeAreaProvider>
|
||||
<Toast />
|
||||
</ThemeProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue