Compare commits

..

3 commits

Author SHA1 Message Date
shafi54
d9a61818de enh 2026-08-13 00:39:23 +05:30
shafi54
360289d3c6 enh 2026-08-13 00:16:27 +05:30
shafi54
1139bf0f46 enh 2026-08-12 23:59:38 +05:30
13 changed files with 307 additions and 336 deletions

View file

@ -302,7 +302,7 @@ export default function DeliverySequences() {
// Auto-select first slot if no slotId provided // Auto-select first slot if no slotId provided
useEffect(() => { useEffect(() => {
if (!slotId && slotsData?.slots && slotsData.slots.length > 0) { if (!slotId && slotsData?.slots && slotsData.slots.length > 0) {
router.replace(`/manage-orders/delivery-sequences?slotId=${slotsData.slots[0].id}`); router.replace(`/(drawer)/dashboard/manage-orders/delivery-sequences?slotId=${slotsData.slots[0].id}`);
} }
}, [slotId, slotsData, router]); }, [slotId, slotsData, router]);
@ -505,7 +505,7 @@ export default function DeliverySequences() {
value={selectedSlotId || ""} value={selectedSlotId || ""}
onValueChange={(val) => { onValueChange={(val) => {
if (val) { if (val) {
router.replace(`/manage-orders/delivery-sequences?slotId=${val}`); router.replace(`/(drawer)/dashboard/manage-orders/delivery-sequences?slotId=${val}`);
} }
}} }}
placeholder="Select slot" placeholder="Select slot"
@ -726,7 +726,7 @@ export default function DeliverySequences() {
}} }}
onViewDetails={() => { onViewDetails={() => {
if (selectedOrder) { if (selectedOrder) {
router.push(`/manage-orders/order-details/${selectedOrder.id}`); router.push(`/(drawer)/dashboard/manage-orders/order-details/${selectedOrder.id}`);
} }
setShowOrderMenu(false); setShowOrderMenu(false);
}} }}

View file

@ -105,12 +105,12 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation(); const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation();
const handleOrderPress = () => { const handleOrderPress = () => {
router.push(`/manage-orders/order-details/${order.orderId}` as any); router.push(`/(drawer)/dashboard/manage-orders/order-details/${order.orderId}` as any);
}; };
const handleMenuOption = () => { const handleMenuOption = () => {
setMenuOpen(false); setMenuOpen(false);
router.push(`/manage-orders/order-details/${order.orderId}` as any); router.push(`/(drawer)/dashboard/manage-orders/order-details/${order.orderId}` as any);
}; };
const handleMarkPackaged = (isPackaged: boolean) => { const handleMarkPackaged = (isPackaged: boolean) => {

View file

@ -419,7 +419,7 @@ export default function PricesOverview() {
<TouchableOpacity <TouchableOpacity
style={tw`flex-row items-center p-4 bg-gray-50 rounded-lg`} style={tw`flex-row items-center p-4 bg-gray-50 rounded-lg`}
onPress={() => { onPress={() => {
router.push('/rebalance-orders' as any); router.push('/(drawer)/dashboard/rebalance-orders' as any);
setShowMenu(false); setShowMenu(false);
}} }}
> >

View file

@ -95,11 +95,11 @@ export default function ProductTags() {
}; };
const handleAddNewTag = () => { const handleAddNewTag = () => {
router.push('/product-tags/add'); router.push('/(drawer)/dashboard/product-tags/add');
}; };
const handleOrderTags = () => { const handleOrderTags = () => {
router.push('/product-tags/order'); router.push('/(drawer)/dashboard/product-tags/order');
}; };

View file

@ -72,7 +72,7 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
</View> </View>
<View style={tw`flex-row items-center`}> <View style={tw`flex-row items-center`}>
<TouchableOpacity <TouchableOpacity
onPress={() => router.push(`/slots/edit/${slot.id}` as any)} onPress={() => router.push(`/(drawer)/dashboard/slots/edit/${slot.id}` as any)}
style={tw`px-3 py-1 rounded-full bg-pink2 mr-2`} style={tw`px-3 py-1 rounded-full bg-pink2 mr-2`}
> >
<View style={tw`flex-row items-center`}> <View style={tw`flex-row items-center`}>
@ -147,7 +147,7 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
setMenuOpen(false); setMenuOpen(false);
router.push(`/slots/add?baseslot=${slot.id}` as any); router.push(`/(drawer)/dashboard/slots/add?baseslot=${slot.id}` as any);
}} }}
style={tw`py-4 border-b border-gray-200`} style={tw`py-4 border-b border-gray-200`}
> >
@ -277,7 +277,7 @@ export default function Slots() {
<MyTouchableOpacity <MyTouchableOpacity
testID="add-slot-fab" testID="add-slot-fab"
accessibilityLabel="add-slot-fab" accessibilityLabel="add-slot-fab"
onPress={() => router.push('/slots/add' as any)} onPress={() => router.push('/(drawer)/dashboard/slots/add' as any)}
activeOpacity={0.95} activeOpacity={0.95}
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }} style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}
> >

View file

@ -198,7 +198,7 @@ export default function SlotDetails() {
{/* FAB for Edit Slot */} {/* FAB for Edit Slot */}
<MyTouchableOpacity <MyTouchableOpacity
onPress={() => router.push(`/slots/edit/${slot.id}` as any)} onPress={() => router.push(`/(drawer)/dashboard/slots/edit/${slot.id}` as any)}
activeOpacity={0.95} activeOpacity={0.95}
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }} style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}
> >

View file

@ -34,7 +34,7 @@ export const TagMenu: React.FC<TagMenuProps> = ({
const handleEditTag = () => { const handleEditTag = () => {
setIsOpen(false); setIsOpen(false);
router.push(`/product-tags/edit?tagId=${tagId}`); router.push(`/(drawer)/dashboard/product-tags/edit?tagId=${tagId}`);
}; };
const handleDeleteTag = () => { const handleDeleteTag = () => {

View file

@ -2,9 +2,16 @@ import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod'; import { z } from 'zod';
import { computeConstants } from '@/src/lib/const-store' import { computeConstants } from '@/src/lib/const-store'
import { CONST_KEYS } from '@/src/lib/const-keys' import { CONST_KEYS } from '@/src/lib/const-keys'
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
import { getAllConstants as getAllConstantsFromDb, upsertConstants as upsertConstantsInDb } from '@/src/dbService' import { getAllConstants as getAllConstantsFromDb, upsertConstants as upsertConstantsInDb } from '@/src/dbService'
import type { Constant, ConstantUpdateResult } from '@packages/shared' import type { Constant, ConstantUpdateResult } from '@packages/shared'
// Constants that are baked into the versioned cache files (products.json etc.)
// — changing them requires regenerating the cache so the user app picks it up.
const CACHE_AFFECTING_KEYS: string[] = [
CONST_KEYS.tagsOrder,
]
export const constRouter = router({ export const constRouter = router({
getConstants: protectedProcedure getConstants: protectedProcedure
.query(async (): Promise<Constant[]> => { .query(async (): Promise<Constant[]> => {
@ -64,6 +71,13 @@ export const constRouter = router({
// Refresh all constants in Redis after database update // Refresh all constants in Redis after database update
await computeConstants(); await computeConstants();
// Constants like tagsOrder are baked into the versioned cache files.
// Regenerate the cache (bumps cacheVersion) so the user app fetches the
// new data (e.g., the new tab order on the home page).
if (constants.some((c) => CACHE_AFFECTING_KEYS.includes(c.key))) {
await scheduleStoreInitialization()
}
return { return {
success: true, success: true,
updatedCount: constants.length, updatedCount: constants.length,

View file

@ -12,7 +12,7 @@ export default function HomeLayout() {
}} }}
> >
<Stack.Screen <Stack.Screen
name="product-detail/[id]" name="product-detail"
options={{ options={{
headerShown: false, headerShown: false,
title: "Product Details", title: "Product Details",

View file

@ -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 { View, Dimensions, Image, RefreshControl } from "react-native";
import { TabView, TabBar } from "react-native-tab-view"; import { ScrollView } from "react-native-gesture-handler";
import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { import {
theme, theme,
@ -37,14 +36,6 @@ const itemWidth = screenWidth * 0.45;
const heroItemWidth = (screenWidth - 72) / 3; const heroItemWidth = (screenWidth - 72) / 3;
const gridItemWidth = (screenWidth - 48) / 2; 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 formatTimeRange = (deliveryTime: string) => {
const time = dayjs(deliveryTime); const time = dayjs(deliveryTime);
const endTime = time.add(1, 'hour'); const endTime = time.add(1, 'hour');
@ -195,22 +186,21 @@ const SlotCard = memo(({ slot }: SlotCardProps) => {
); );
}); });
interface TagTabViewProps { interface TagTabStripProps {
dashboardTags: any[]; dashboardTags: any[];
activeTagId: number | null; activeTagId: number | null;
productsByTagId: Record<number, any[]>;
onSelectTag: (id: number) => void; 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, dashboardTags,
activeTagId, activeTagId,
productsByTagId,
onSelectTag, onSelectTag,
onProductPress, }: TagTabStripProps) => {
}: TagTabViewProps) => { const scrollRef = useRef<any>(null);
const [expandedByTagId, setExpandedByTagId] = useState<Record<number, boolean>>({}); const positions = useRef<number[]>([]);
const routes = useMemo( const routes = useMemo(
() => dashboardTags.map((tag) => ({ key: String(tag.id), title: tag.tagName })), () => dashboardTags.map((tag) => ({ key: String(tag.id), title: tag.tagName })),
@ -222,39 +212,86 @@ const TagTabView = memo(({
return idx < 0 ? 0 : idx; return idx < 0 ? 0 : idx;
}, [dashboardTags, activeTagId]); }, [dashboardTags, activeTagId]);
// Height: fit the active tag's visible product rows (default 6 per tag), useEffect(() => {
// plus room for the "Show More" button when it has more products. const x = positions.current[index];
const activeTagKey = routes[index]?.key; if (x != null) {
const sceneHeight = useMemo(() => { scrollRef.current?.scrollTo({ x: Math.max(0, x - 16), animated: true });
const tagId = Number(activeTagKey); }
const count = (productsByTagId[tagId]?.length) || 0; }, [index]);
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]);
const renderTabBar = useCallback((props: any) => ( return (
<TabBar <ScrollView
{...props} ref={scrollRef}
scrollEnabled horizontal
activeColor={theme.colors.brand600} showsHorizontalScrollIndicator={false}
inactiveColor="#64748B" // horizontal ScrollViews default to flexGrow: 1 — pin it to content height
indicatorStyle={{ // so it doesn't eat the page space below the tabs
backgroundColor: theme.colors.brand500, style={[tw`bg-transparent`, { flexGrow: 0, height: 48 }]}
height: 4, contentContainerStyle={tw`px-2 items-center`}
borderRadius: 999, >
{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`} onPress={() => onSelectTag(Number(route.key))}
style={tw`bg-transparent`} style={tw`h-12 px-3 justify-center`}
tabStyle={{ width: 'auto', paddingHorizontal: 8, minWidth: 0 }} activeOpacity={0.7}
indicatorContainerStyle={tw`mb-1`} >
<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 products = productsByTagId[tagId] || [];
const expanded = !!expandedByTagId[tagId]; const expanded = !!expandedByTagId[tagId];
const visible = expanded ? products : products.slice(0, 6); const visible = expanded ? products : products.slice(0, 6);
@ -279,26 +316,13 @@ const TagTabView = memo(({
<MyTouchableOpacity <MyTouchableOpacity
style={tw`self-center mt-1 mb-2 px-5 py-2.5 rounded-full bg-brand500 shadow-sm`} style={tw`self-center mt-1 mb-2 px-5 py-2.5 rounded-full bg-brand500 shadow-sm`}
activeOpacity={0.85} activeOpacity={0.85}
onPress={() => setExpandedByTagId((prev) => ({ ...prev, [tagId]: true }))} onPress={() => onToggleExpand(tagId)}
> >
<MyText style={tw`text-white text-sm font-semibold`}>Show More</MyText> <MyText style={tw`text-white text-sm font-semibold`}>Show More</MyText>
</MyTouchableOpacity> </MyTouchableOpacity>
)} )}
</View> </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 { interface ExploreProductItemProps {
@ -354,72 +378,58 @@ const ProductItem = memo(({ item, onPress }: ProductItemProps) => {
}); });
interface ListHeaderProps { interface ListHeaderProps {
gradientHeight: number;
onGradientLayout: (height: number) => void;
storesData: any;
sortedSlots: any[];
onProductPress: (id: number) => void;
dashboardTags: any[]; dashboardTags: any[];
activeTagId: number | null; activeTagId: number | null;
productsByTagId: Record<number, any[]>; productsByTagId: Record<number, any[]>;
expandedByTagId: Record<number, boolean>;
onToggleExpand: (tagId: number) => void;
onProductPress: (id: number) => void;
onSelectTag: (id: number) => void; onSelectTag: (id: number) => void;
storesData: any;
sortedSlots: any[];
} }
const ListHeader = memo(({ const ListHeader = memo(({
gradientHeight,
onGradientLayout,
storesData,
sortedSlots,
onProductPress,
dashboardTags, dashboardTags,
activeTagId, activeTagId,
productsByTagId, productsByTagId,
expandedByTagId,
onToggleExpand,
onProductPress,
onSelectTag, onSelectTag,
storesData,
sortedSlots,
}: ListHeaderProps) => { }: ListHeaderProps) => {
const handleLayout = useCallback((event: any) => {
const { y, height } = event.nativeEvent.layout;
onGradientLayout(y + height);
}, [onGradientLayout]);
const renderSlotItem = useCallback(({ item }: { item: any }) => ( const renderSlotItem = useCallback(({ item }: { item: any }) => (
<SlotItem item={item} /> <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 ( return (
<> <>
<View onLayout={handleLayout} style={{ backgroundColor: '#FFFFFF' }}> {dashboardTags.length > 0 && (
<LinearGradient <TagTabStrip
colors={['#FFFFFF', '#FFFFFF']} dashboardTags={dashboardTags}
start={{ x: 0, y: 0 }} activeTagId={activeTagId}
end={{ x: 0, y: 1 }} onSelectTag={onSelectTag}
style={gradientStyle}
/> />
)}
{dashboardTags.length > 0 && ( {dashboardTags.length > 0 && (
<View <View
style={[ 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 }, { backgroundColor: theme.colors.brand25 },
]} ]}
> >
<TagTabView <TagScene
dashboardTags={dashboardTags} dashboardTags={dashboardTags}
activeTagId={activeTagId} activeTagId={activeTagId}
productsByTagId={productsByTagId} productsByTagId={productsByTagId}
onSelectTag={onSelectTag} expandedByTagId={expandedByTagId}
onToggleExpand={onToggleExpand}
onProductPress={onProductPress} onProductPress={onProductPress}
/> />
</View> </View>
)} )}
</View>
<View <View
style={[ style={[
tw`rounded-t-3xl px-4`, tw`rounded-t-3xl px-4`,
@ -498,7 +508,6 @@ export default function Dashboard() {
const userDetails = useUserDetails(); const userDetails = useUserDetails();
const [inputQuery, setInputQuery] = useState(""); const [inputQuery, setInputQuery] = useState("");
const [isLoadingDialogOpen, setIsLoadingDialogOpen] = useState(false); const [isLoadingDialogOpen, setIsLoadingDialogOpen] = useState(false);
const [gradientHeight, setGradientHeight] = useState(0);
const [displayedProducts, setDisplayedProducts] = useState<any[]>([]); const [displayedProducts, setDisplayedProducts] = useState<any[]>([]);
const [visibleCount, setVisibleCount] = useState(21); const [visibleCount, setVisibleCount] = useState(21);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
@ -524,6 +533,10 @@ export default function Dashboard() {
const [selectedTagId, setSelectedTagId] = useState<number | null>(null); const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
const activeTagId = selectedTagId ?? dashboardTags[0]?.id ?? 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(() => { React.useEffect(() => {
@ -580,35 +593,14 @@ export default function Dashboard() {
productById.set(product.id, product); productById.set(product.id, product);
} }
// tag.productIds is already in the admin-curated order (backend sorts by sortOrder). // Respect the admin-curated order — tag.productIds is already ordered by
const orderedIds = (tag.productIds || []).filter((id: number) => productById.has(id)); // the tag's sortOrder (backend reorders it when building the cache).
const ordered: any[] = []; map[tag.id] = (tag.productIds || [])
const rest: any[] = []; .map((id: number) => productById.get(id))
for (const id of orderedIds) { .filter(Boolean) as any[];
const product = productById.get(id);
const isOutOfStock = Boolean(productSlotsMap[id]?.isOutOfStock) || !getQuickestSlot(id);
if (isOutOfStock) rest.push(product);
else ordered.push(product);
}
// Products in the tag's productIds but not in the curated order (added later) — availability sort.
const seen = new Set(orderedIds);
const extra = products
.filter((product: any) => (tag.productIds?.includes(product.id) ?? false) && !seen.has(product.id))
.sort((a: any, b: any) => {
const slotA = getQuickestSlot(a.id)
const slotB = getQuickestSlot(b.id)
const aOutOfStock = Boolean(productSlotsMap[a.id]?.isOutOfStock) || !slotA
const bOutOfStock = Boolean(productSlotsMap[b.id]?.isOutOfStock) || !slotB
if (aOutOfStock && !bOutOfStock) return 1
if (!aOutOfStock && bOutOfStock) return -1
return 0
});
map[tag.id] = [...ordered, ...rest, ...extra];
} }
return map; return map;
}, [dashboardTags, products, getQuickestSlot, productSlotsMap]); }, [dashboardTags, products]);
const handleRefresh = useCallback(async () => { const handleRefresh = useCallback(async () => {
setIsRefreshing(true); setIsRefreshing(true);
@ -642,10 +634,6 @@ export default function Dashboard() {
router.push(`/(drawer)/(tabs)/home/product-detail/${id}`); router.push(`/(drawer)/(tabs)/home/product-detail/${id}`);
}, [router]); }, [router]);
const handleGradientLayout = useCallback((height: number) => {
setGradientHeight(height);
}, []);
const handleSearchPress = useCallback(() => { const handleSearchPress = useCallback(() => {
router.push("/(drawer)/(tabs)/home/search-results"); router.push("/(drawer)/(tabs)/home/search-results");
}, [router]); }, [router]);
@ -657,17 +645,17 @@ export default function Dashboard() {
const listHeader = useMemo(() => ( const listHeader = useMemo(() => (
<ListHeader <ListHeader
gradientHeight={gradientHeight}
onGradientLayout={handleGradientLayout}
storesData={storesData}
sortedSlots={sortedSlots}
onProductPress={handleProductPress}
dashboardTags={dashboardTags} dashboardTags={dashboardTags}
activeTagId={activeTagId} activeTagId={activeTagId}
productsByTagId={productsByTagId} productsByTagId={productsByTagId}
expandedByTagId={expandedByTagId}
onToggleExpand={handleToggleExpand}
onProductPress={handleProductPress}
onSelectTag={setSelectedTagId} 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 pageTint = activeTagId == null ? '#FFFFFF' : getTagColor(activeTagId).pageBg;
const pageTintStyle = useMemo(() => [ const pageTintStyle = useMemo(() => [
@ -762,7 +750,9 @@ export default function Dashboard() {
<MyText style={tw`text-gray-500`}>No products available</MyText> <MyText style={tw`text-gray-500`}>No products available</MyText>
</View> </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} maxToRenderPerBatch={10}
windowSize={5} windowSize={5}
initialNumToRender={10} initialNumToRender={10}

View file

@ -1,14 +1,13 @@
import React from "react"; import React, { useState } from "react";
import { View, Dimensions, ScrollView } from "react-native"; import { View, Dimensions, ScrollView } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { import {
tw, tw,
useManualRefresh, useManualRefresh,
useMarkDataFetchers, useMarkDataFetchers,
AppContainer, AppContainer,
MyFlatList,
MyText, MyText,
MyTouchableOpacity,
} from "common-ui"; } from "common-ui";
import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import ProductCard from "@/components/ProductCard"; import ProductCard from "@/components/ProductCard";
@ -17,34 +16,35 @@ import FloatingCartBar from "@/components/floating-cart-bar";
import TabLayoutWrapper from "@/components/TabLayoutWrapper"; import TabLayoutWrapper from "@/components/TabLayoutWrapper";
const { width: screenWidth } = Dimensions.get("window"); 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 OffersSectionProps {
interface OffersRowProps {
title: string; title: string;
subtitle: string; subtitle: string;
products: any[]; products: any[];
expanded: boolean;
onExpand: () => void;
onProductPress: (id: number) => void; onProductPress: (id: number) => void;
} }
const OffersRow = ({ title, subtitle, products, onProductPress }: OffersRowProps) => { // Grid section (home page style): shows the first SHOW_MORE_STEP products
const renderItem = ({ item }: { item: any }) => ( // with a "Show More" button to reveal the rest.
<View style={tw`mr-4`}> const OffersSection = ({
<ProductCard title,
item={item} subtitle,
itemWidth={itemWidth} products,
onPress={() => onProductPress(item.id)} expanded,
showDeliveryInfo={false} onExpand,
useAddToCartDialog={true} onProductPress,
miniView={true} }: OffersSectionProps) => {
/> const visible = expanded ? products : products.slice(0, SHOW_MORE_STEP);
</View> const hasMore = products.length > SHOW_MORE_STEP && !expanded;
);
return ( return (
<View style={tw`bg-white`}> <View style={tw`pt-4`}>
<View style={tw`mb-2 pt-4 px-4`}> <View style={tw`mb-2 px-4`}>
<MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}> <MyText style={tw`text-2xl font-extrabold text-gray-900 tracking-tight`}>
{title} {title}
</MyText> </MyText>
@ -61,24 +61,32 @@ const OffersRow = ({ title, subtitle, products, onProductPress }: OffersRowProps
</MyText> </MyText>
</View> </View>
) : ( ) : (
<View style={tw`relative`}> <>
<MyFlatList <View style={tw`flex-row flex-wrap justify-between px-4`}>
data={products} {visible.map((item: any) => (
keyExtractor={(item) => item.id.toString()} <View key={item.id} style={tw`mb-3`}>
horizontal <ProductCard
showsHorizontalScrollIndicator={false} item={item}
contentContainerStyle={rowListContent} itemWidth={heroItemWidth}
renderItem={renderItem} onPress={() => onProductPress(item.id)}
removeClippedSubviews={true} showDeliveryInfo={false}
/> useAddToCartDialog={true}
<LinearGradient miniView={true}
colors={["transparent", "rgba(0,0,0,0.08)"]} variant="hero"
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> </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> </View>
); );
@ -86,6 +94,8 @@ const OffersRow = ({ title, subtitle, products, onProductPress }: OffersRowProps
export default function Offers() { export default function Offers() {
const router = useRouter(); const router = useRouter();
const [expandedOffers, setExpandedOffers] = useState(false);
const [expandedCombos, setExpandedCombos] = useState(false);
const { data, isLoading, error, refetch } = const { data, isLoading, error, refetch } =
trpc.user.product.getOffersPage.useQuery(); trpc.user.product.getOffersPage.useQuery();
@ -135,17 +145,21 @@ export default function Offers() {
return ( return (
<TabLayoutWrapper> <TabLayoutWrapper>
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-20`}> <ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-20`}>
<OffersRow <OffersSection
title="Combos"
subtitle="Bundle your favorites and save"
products={combos}
onProductPress={handleProductPress}
/>
<OffersRow
title="Offers" title="Offers"
subtitle="Great prices on select items" subtitle="Great prices on select items"
products={offers} 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} onProductPress={handleProductPress}
/> />
</ScrollView> </ScrollView>

View file

@ -28,6 +28,7 @@ import WebViewWrapper from "@/components/WebViewWrapper";
import BackHandlerWrapper from "@/components/BackHandler"; import BackHandlerWrapper from "@/components/BackHandler";
import AddToCartDialog from "@/src/components/AddToCartDialog"; import AddToCartDialog from "@/src/components/AddToCartDialog";
import React from "react"; import React from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import NotifChecker from "@/services/notif-service/notif-checker"; import NotifChecker from "@/services/notif-service/notif-checker";
export default function RootLayout() { export default function RootLayout() {
@ -47,6 +48,7 @@ export default function RootLayout() {
} }
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}>
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}> <ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<MyStatusBar /> <MyStatusBar />
<SafeAreaProvider> <SafeAreaProvider>
@ -90,5 +92,6 @@ export default function RootLayout() {
</SafeAreaProvider> </SafeAreaProvider>
<Toast /> <Toast />
</ThemeProvider> </ThemeProvider>
</GestureHandlerRootView>
); );
} }

View file

@ -6,82 +6,52 @@ import { AllProductsApiType, StoresApiType, SlotsApiType, EssentialConstsApiType
import { CACHE_FILENAMES } from "@packages/shared"; import { CACHE_FILENAMES } from "@packages/shared";
import { StorageServiceCasual } from 'common-ui'; import { StorageServiceCasual } from 'common-ui';
// Local useGetEssentialConsts hook // The only persisted payload: the small essential-consts config object
// (assetsDomain, cache versions, etc.). The big data payloads (products,
// stores, slots, availability, banners) are fetched fresh each session and
// kept in memory by react-query — they are NOT written to storage.
const ESSENTIAL_CONSTS_KEY = 'essential_consts'
export const useGetEssentialConsts = () => { export const useGetEssentialConsts = () => {
const query = trpc.common.essentialConsts.useQuery(undefined, { const [initialData, setInitialData] = useState<EssentialConstsApiType | undefined>(undefined)
refetchInterval: 60000,
})
return { ...query, refetch: query.refetch }
}
// ---------------------------------------------------------------------------
// Persisted cache helpers — store fetched JSON keyed by its versioned URL.
// Only refetch + repersist when the version (URL) changes.
// ---------------------------------------------------------------------------
interface PersistedCache<T> {
version: string
data: T
}
const CACHE_STORAGE_KEYS = {
products: 'cache_products',
stores: 'cache_stores',
slots: 'cache_slots',
banners: 'cache_banners',
availability: 'cache_availability',
storeProducts: (storeId: number) => `cache_store_${storeId}`,
} as const
async function readPersistedCache<T>(key: string): Promise<PersistedCache<T> | null> {
const raw = await StorageServiceCasual.getItem(key)
if (!raw) return null
try {
return JSON.parse(raw) as PersistedCache<T>
} catch {
return null
}
}
async function writePersistedCache<T>(key: string, version: string, data: T): Promise<void> {
await StorageServiceCasual.setItem(key, JSON.stringify({ version, data }))
}
/**
* Loads the persisted cache for `storageKey` and reports whether its version
* matches the current `version`. Returns { initialData, isReady }.
* - version matches serve persisted data, no network refetch needed.
* - version differs signal to refetch (caller's query fetches + repersists).
*/
function usePersistedCache<T>(storageKey: string, version: string | null) {
const [initialData, setInitialData] = useState<T | undefined>(undefined)
const [isReady, setIsReady] = useState(false) const [isReady, setIsReady] = useState(false)
// Load the last persisted response so config is available immediately.
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
setIsReady(false) StorageServiceCasual.getItem(ESSENTIAL_CONSTS_KEY).then((raw) => {
setInitialData(undefined)
if (!version) {
setIsReady(true)
return
}
readPersistedCache<T>(storageKey).then((persisted) => {
if (cancelled) return if (cancelled) return
if (persisted && persisted.version === version) { if (raw) {
setInitialData(persisted.data) try {
setInitialData(JSON.parse(raw))
} catch {
// ignore corrupt cache
}
} }
setIsReady(true) setIsReady(true)
}) })
return () => { cancelled = true } return () => { cancelled = true }
}, [storageKey, version]) }, [])
return { initialData, isReady } const query = trpc.common.essentialConsts.useQuery(undefined, {
refetchInterval: 60000,
enabled: isReady,
placeholderData: initialData,
})
// Persist the latest response.
useEffect(() => {
if (query.data) {
StorageServiceCasual.setItem(ESSENTIAL_CONSTS_KEY, JSON.stringify(query.data))
}
}, [query.data])
return { ...query, refetch: query.refetch }
} }
type ProductsResponse = AllProductsApiType; type ProductsResponse = AllProductsApiType;
type StoresResponse = StoresApiType; type StoresResponse = StoresApiType;
type SlotsResponse = SlotsApiType; type SlotsResponse = SlotsApiType;
type EssentialConstsResponse = EssentialConstsApiType;
type BannersResponse = BannersApiType; type BannersResponse = BannersApiType;
type StoreWithProductsResponse = StoreWithProductsApiType; type StoreWithProductsResponse = StoreWithProductsApiType;
type AvailabilityResponse = AvailabilityApiType; type AvailabilityResponse = AvailabilityApiType;
@ -142,7 +112,6 @@ export function useAllProducts() {
const cacheUrl = useCacheUrl(CACHE_FILENAMES.products) const cacheUrl = useCacheUrl(CACHE_FILENAMES.products)
const { data: availabilityData } = useAvailability() const { data: availabilityData } = useAvailability()
const version = cacheUrl ?? '' const version = cacheUrl ?? ''
const { initialData, isReady } = usePersistedCache<ProductsResponse>(CACHE_STORAGE_KEYS.products, version)
const productsQuery = useQuery<ProductsResponse>({ const productsQuery = useQuery<ProductsResponse>({
queryKey: ['all-products', version], queryKey: ['all-products', version],
@ -151,12 +120,10 @@ export function useAllProducts() {
throw new Error('Cache URL not available') throw new Error('Cache URL not available')
} }
const response = await axios.get<ProductsResponse>(cacheUrl) const response = await axios.get<ProductsResponse>(cacheUrl)
await writePersistedCache(CACHE_STORAGE_KEYS.products, version, response.data)
return response.data return response.data
}, },
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
enabled: !!cacheUrl && isReady, enabled: !!cacheUrl,
placeholderData: initialData,
}) })
const mergedProducts = React.useMemo(() => { const mergedProducts = React.useMemo(() => {
@ -197,7 +164,6 @@ export function useAllProducts() {
export function useAvailability() { export function useAvailability() {
const cacheUrl = useAvailabilityCacheUrl() const cacheUrl = useAvailabilityCacheUrl()
const version = cacheUrl ?? '' const version = cacheUrl ?? ''
const { initialData, isReady } = usePersistedCache<AvailabilityResponse>(CACHE_STORAGE_KEYS.availability, version)
return useQuery<AvailabilityResponse>({ return useQuery<AvailabilityResponse>({
queryKey: ['availability', version], queryKey: ['availability', version],
@ -206,19 +172,16 @@ export function useAvailability() {
throw new Error('Cache URL not available') throw new Error('Cache URL not available')
} }
const response = await axios.get<AvailabilityResponse>(cacheUrl) const response = await axios.get<AvailabilityResponse>(cacheUrl)
await writePersistedCache(CACHE_STORAGE_KEYS.availability, version, response.data)
return response.data return response.data
}, },
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
enabled: !!cacheUrl && isReady, enabled: !!cacheUrl,
placeholderData: initialData,
}) })
} }
export function useStores() { export function useStores() {
const cacheUrl = useCacheUrl(CACHE_FILENAMES.stores) const cacheUrl = useCacheUrl(CACHE_FILENAMES.stores)
const version = cacheUrl ?? '' const version = cacheUrl ?? ''
const { initialData, isReady } = usePersistedCache<StoresResponse>(CACHE_STORAGE_KEYS.stores, version)
return useQuery<StoresResponse>({ return useQuery<StoresResponse>({
queryKey: ['stores', version], queryKey: ['stores', version],
@ -227,19 +190,16 @@ export function useStores() {
throw new Error('Cache URL not available') throw new Error('Cache URL not available')
} }
const response = await axios.get<StoresResponse>(cacheUrl) const response = await axios.get<StoresResponse>(cacheUrl)
await writePersistedCache(CACHE_STORAGE_KEYS.stores, version, response.data)
return response.data return response.data
}, },
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
enabled: !!cacheUrl && isReady, enabled: !!cacheUrl,
placeholderData: initialData,
}) })
} }
export function useSlots() { export function useSlots() {
const cacheUrl = useSlotsCacheUrl() const cacheUrl = useSlotsCacheUrl()
const version = cacheUrl ?? '' const version = cacheUrl ?? ''
const { initialData, isReady } = usePersistedCache<SlotsResponse>(CACHE_STORAGE_KEYS.slots, version)
return useQuery<SlotsResponse>({ return useQuery<SlotsResponse>({
queryKey: ['slots', version], queryKey: ['slots', version],
@ -248,35 +208,28 @@ export function useSlots() {
throw new Error('Cache URL not available') throw new Error('Cache URL not available')
} }
const response = await axios.get<SlotsResponse>(cacheUrl + '?v=123') const response = await axios.get<SlotsResponse>(cacheUrl + '?v=123')
await writePersistedCache(CACHE_STORAGE_KEYS.slots, version, response.data)
return response.data return response.data
}, },
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
enabled: !!cacheUrl && isReady, enabled: !!cacheUrl,
placeholderData: initialData,
}) })
} }
export function useBanners() { export function useBanners() {
const cacheUrl = useCacheUrl(CACHE_FILENAMES.banners) const cacheUrl = useCacheUrl(CACHE_FILENAMES.banners)
const version = cacheUrl ?? '' const version = cacheUrl ?? ''
const { initialData, isReady } = usePersistedCache<BannersResponse>(CACHE_STORAGE_KEYS.banners, version)
return useQuery<BannersResponse>({ return useQuery<BannersResponse>({
queryKey: ['banners', version], queryKey: ['banners', version],
queryFn: async () => { queryFn: async () => {
if (!cacheUrl) { if (!cacheUrl) {
throw new Error('Cache URL not available') throw new Error('Cache URL not available')
} }
const response = await axios.get<BannersResponse>(cacheUrl) const response = await axios.get<BannersResponse>(cacheUrl)
await writePersistedCache(CACHE_STORAGE_KEYS.banners, version, response.data)
return response.data return response.data
}, },
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
enabled: !!cacheUrl && isReady, enabled: !!cacheUrl,
placeholderData: initialData,
}) })
} }
@ -291,7 +244,6 @@ export function useStoreWithProducts(storeId: number) {
? `${assetsDomain}${apiCacheKey}/v-${cacheVersion}/stores/${storeId}.json` ? `${assetsDomain}${apiCacheKey}/v-${cacheVersion}/stores/${storeId}.json`
: null : null
const version = cacheUrl ?? '' const version = cacheUrl ?? ''
const { initialData, isReady } = usePersistedCache<StoreWithProductsResponse>(CACHE_STORAGE_KEYS.storeProducts(storeId), version)
return useQuery<StoreWithProductsResponse>({ return useQuery<StoreWithProductsResponse>({
queryKey: ['store-with-products', storeId, version], queryKey: ['store-with-products', storeId, version],
@ -300,11 +252,9 @@ export function useStoreWithProducts(storeId: number) {
throw new Error('Cache URL not available') throw new Error('Cache URL not available')
} }
const response = await axios.get<StoreWithProductsResponse>(cacheUrl) const response = await axios.get<StoreWithProductsResponse>(cacheUrl)
await writePersistedCache(CACHE_STORAGE_KEYS.storeProducts(storeId), version, response.data)
return response.data return response.data
}, },
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
enabled: !!cacheUrl && isReady, enabled: !!cacheUrl,
placeholderData: initialData,
}) })
} }