Compare commits
3 commits
e2c6a292a4
...
d9a61818de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9a61818de | ||
|
|
360289d3c6 | ||
|
|
1139bf0f46 |
13 changed files with 307 additions and 336 deletions
|
|
@ -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);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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 = () => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue