Skip to content

Commit 0c96419

Browse files
Add catalog browser enhancements: Popular Items, Recently Used, per-item quantity adjustment
Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
1 parent 6c51b65 commit 0c96419

File tree

8 files changed

+302
-41
lines changed

8 files changed

+302
-41
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { CatalogItem } from 'expo-app/features/catalog/types';
2+
import { atomWithAsyncStorage } from './atomWithAsyncStorage';
3+
4+
const MAX_RECENTLY_USED = 10;
5+
6+
export const recentlyUsedCatalogItemsAtom = atomWithAsyncStorage<CatalogItem[]>(
7+
'recentlyUsedCatalogItems',
8+
[],
9+
);
10+
11+
export function buildUpdatedRecentlyUsed(
12+
current: CatalogItem[],
13+
added: CatalogItem[],
14+
): CatalogItem[] {
15+
const addedIds = new Set(added.map((item) => item.id));
16+
const merged = [...added, ...current.filter((item) => !addedIds.has(item.id))];
17+
return merged.slice(0, MAX_RECENTLY_USED);
18+
}

apps/expo/features/catalog/components/CatalogBrowserModal.tsx

Lines changed: 224 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,22 @@ import { HorizontalCatalogItemCard } from 'expo-app/features/packs/components/Ho
66
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
77
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
88
import { useAtom } from 'jotai';
9-
import { useMemo, useState } from 'react';
9+
import { useCallback, useMemo, useState } from 'react';
1010
import {
1111
ActivityIndicator,
1212
FlatList,
1313
Modal,
1414
RefreshControl,
1515
SafeAreaView,
16+
ScrollView,
1617
TouchableOpacity,
1718
View,
1819
} from 'react-native';
1920
import { useDebounce } from 'use-debounce';
2021
import { useCatalogItemsInfinite } from '../hooks';
2122
import { useCatalogItemsCategories } from '../hooks/useCatalogItemsCategories';
23+
import { usePopularCatalogItems } from '../hooks/usePopularCatalogItems';
24+
import { useRecentlyUsedCatalogItems } from '../hooks/useRecentlyUsedCatalogItems';
2225
import { useVectorSearch } from '../hooks/useVectorSearch';
2326
import type { CatalogItem } from '../types';
2427

@@ -28,6 +31,118 @@ type CatalogBrowserModalProps = {
2831
onItemsSelected: (items: CatalogItem[]) => void;
2932
};
3033

34+
function QuickAccessSection({
35+
title,
36+
items,
37+
selectedItems,
38+
onItemToggle,
39+
emptyMessage,
40+
}: {
41+
title: string;
42+
items: CatalogItem[];
43+
selectedItems: Set<number>;
44+
onItemToggle: (item: CatalogItem) => void;
45+
emptyMessage?: string;
46+
}) {
47+
if (items.length === 0) {
48+
if (!emptyMessage) return null;
49+
return (
50+
<View className="mb-4">
51+
<Text className="mb-2 text-sm font-semibold text-foreground">{title}</Text>
52+
<Text className="text-xs text-muted-foreground">{emptyMessage}</Text>
53+
</View>
54+
);
55+
}
56+
57+
return (
58+
<View className="mb-4">
59+
<Text className="mb-2 text-sm font-semibold text-foreground">{title}</Text>
60+
<ScrollView
61+
horizontal
62+
showsHorizontalScrollIndicator={false}
63+
contentContainerStyle={{ gap: 8 }}
64+
>
65+
{items.map((item) => (
66+
<View key={item.id} className="w-56">
67+
<HorizontalCatalogItemCard
68+
item={item}
69+
selected={selectedItems.has(item.id)}
70+
onSelect={onItemToggle}
71+
/>
72+
</View>
73+
))}
74+
</ScrollView>
75+
</View>
76+
);
77+
}
78+
79+
function SelectedItemsQuantityPanel({
80+
items,
81+
quantities,
82+
onQuantityChange,
83+
onClear,
84+
onAdd,
85+
}: {
86+
items: CatalogItem[];
87+
quantities: Map<number, number>;
88+
onQuantityChange: (itemId: number, delta: number) => void;
89+
onClear: () => void;
90+
onAdd: () => void;
91+
}) {
92+
const { t } = useTranslation();
93+
const { colors } = useColorScheme();
94+
95+
return (
96+
<View className="border-t border-border bg-card">
97+
<ScrollView style={{ maxHeight: 180 }} contentContainerStyle={{ padding: 12, gap: 8 }}>
98+
{items.map((item) => {
99+
const qty = quantities.get(item.id) ?? 1;
100+
return (
101+
<View key={item.id} className="flex-row items-center justify-between">
102+
<Text className="flex-1 text-sm text-foreground" numberOfLines={1}>
103+
{item.name}
104+
</Text>
105+
<View className="flex-row items-center gap-2 ml-2">
106+
<TouchableOpacity
107+
onPress={() => onQuantityChange(item.id, -1)}
108+
disabled={qty <= 1}
109+
className="h-7 w-7 items-center justify-center rounded-full border border-border"
110+
>
111+
<Icon
112+
name="minus"
113+
size={14}
114+
color={qty <= 1 ? colors.grey2 : colors.foreground}
115+
/>
116+
</TouchableOpacity>
117+
<Text className="w-6 text-center text-sm font-medium text-foreground">{qty}</Text>
118+
<TouchableOpacity
119+
onPress={() => onQuantityChange(item.id, 1)}
120+
className="h-7 w-7 items-center justify-center rounded-full border border-border"
121+
>
122+
<Icon name="plus" size={14} color={colors.foreground} />
123+
</TouchableOpacity>
124+
</View>
125+
</View>
126+
);
127+
})}
128+
</ScrollView>
129+
<View className="flex-row gap-3 items-center justify-end border-t border-border px-4 py-3">
130+
<Button variant="secondary" className="mb-1" onPress={onClear}>
131+
<Text>{t('catalog.clearSelection')}</Text>
132+
</Button>
133+
<Button onPress={onAdd} className="mb-1" variant="tonal">
134+
<Text>
135+
{t('catalog.addItems', {
136+
count: items.length,
137+
unit: items.length > 1 ? t('catalog.items') : t('catalog.item'),
138+
})}
139+
</Text>
140+
</Button>
141+
</View>
142+
</View>
143+
);
144+
}
145+
31146
export function CatalogBrowserModal({
32147
visible,
33148
onClose,
@@ -38,11 +153,17 @@ export function CatalogBrowserModal({
38153
const [searchValue, setSearchValue] = useAtom(searchValueAtom);
39154
const [activeFilter, setActiveFilter] = useState<'All' | string>('All');
40155
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
156+
const [itemQuantities, setItemQuantities] = useState<Map<number, number>>(new Map());
41157
const [debouncedSearchValue] = useDebounce(searchValue, 400);
42158

43159
const isSearching = debouncedSearchValue.length > 0;
160+
const isDefaultView = !isSearching && activeFilter === 'All';
44161

45162
const { data: categories } = useCatalogItemsCategories();
163+
const { recentItems } = useRecentlyUsedCatalogItems();
164+
const { data: popularData, isLoading: isPopularLoading } = usePopularCatalogItems(8);
165+
166+
const popularItems = popularData?.items ?? [];
46167

47168
const {
48169
data: paginatedData,
@@ -71,32 +192,65 @@ export function CatalogBrowserModal({
71192
const isLoading = isSearching ? isSearchLoading : isPaginatedLoading;
72193
const error = isSearching ? searchError : paginatedError;
73194

74-
const handleItemToggle = (item: CatalogItem) => {
75-
const newSelected = new Set(selectedItems);
76-
if (newSelected.has(item.id)) {
77-
newSelected.delete(item.id);
78-
} else {
79-
newSelected.add(item.id);
80-
}
81-
setSelectedItems(newSelected);
82-
};
195+
// All unique items available (main list + popular + recent) for lookup
196+
const allAvailableItems = useMemo(() => {
197+
const map = new Map<number, CatalogItem>();
198+
for (const item of items) map.set(item.id, item);
199+
for (const item of popularItems) map.set(item.id, item);
200+
for (const item of recentItems) map.set(item.id, item);
201+
return map;
202+
}, [items, popularItems, recentItems]);
203+
204+
const handleItemToggle = useCallback(
205+
(item: CatalogItem) => {
206+
const newSelected = new Set(selectedItems);
207+
if (newSelected.has(item.id)) {
208+
newSelected.delete(item.id);
209+
setItemQuantities((prev) => {
210+
const next = new Map(prev);
211+
next.delete(item.id);
212+
return next;
213+
});
214+
} else {
215+
newSelected.add(item.id);
216+
setItemQuantities((prev) => new Map(prev).set(item.id, 1));
217+
}
218+
setSelectedItems(newSelected);
219+
},
220+
[selectedItems],
221+
);
222+
223+
const handleQuantityChange = useCallback((itemId: number, delta: number) => {
224+
setItemQuantities((prev) => {
225+
const current = prev.get(itemId) ?? 1;
226+
const next = Math.max(1, current + delta);
227+
return new Map(prev).set(itemId, next);
228+
});
229+
}, []);
83230

84231
const handleAddSelected = () => {
85-
const selectedCatalogItems = items.filter((item: CatalogItem) => selectedItems.has(item.id));
232+
const selectedCatalogItems = Array.from(selectedItems)
233+
.map((id) => allAvailableItems.get(id))
234+
.filter((item): item is CatalogItem => item !== undefined)
235+
.map((item) => ({ ...item, quantity: itemQuantities.get(item.id) ?? 1 }));
86236
onItemsSelected(selectedCatalogItems);
87-
setSelectedItems(new Set());
237+
resetSelection();
88238
onClose();
89239
};
90240

91-
const handleClose = () => {
241+
const resetSelection = () => {
92242
setSelectedItems(new Set());
243+
setItemQuantities(new Map());
244+
};
245+
246+
const handleClose = () => {
247+
resetSelection();
93248
setSearchValue('');
94249
onClose();
95250
};
96251

97252
const handleRefresh = () => {
98253
if (isSearching) {
99-
// For search, we can't really refresh, so just clear search
100254
setSearchValue('');
101255
} else {
102256
refetch();
@@ -109,6 +263,14 @@ export function CatalogBrowserModal({
109263
}
110264
};
111265

266+
const selectedItemsList = useMemo(
267+
() =>
268+
Array.from(selectedItems)
269+
.map((id) => allAvailableItems.get(id))
270+
.filter((item): item is CatalogItem => item !== undefined),
271+
[selectedItems, allAvailableItems],
272+
);
273+
112274
const renderItem = ({ item }: { item: CatalogItem }) => (
113275
<HorizontalCatalogItemCard
114276
item={item}
@@ -119,6 +281,44 @@ export function CatalogBrowserModal({
119281

120282
const ItemSeparatorComponent = useMemo(() => () => <View className="h-2" />, []);
121283

284+
const ListHeaderComponent = useMemo(() => {
285+
if (!isDefaultView) return null;
286+
const showPopular = popularItems.length > 0 || isPopularLoading;
287+
const showRecent = recentItems.length > 0;
288+
if (!showPopular && !showRecent) return null;
289+
return (
290+
<View className="pb-2">
291+
{showRecent && (
292+
<QuickAccessSection
293+
title={t('catalog.recentlyUsed')}
294+
items={recentItems}
295+
selectedItems={selectedItems}
296+
onItemToggle={handleItemToggle}
297+
/>
298+
)}
299+
{showPopular && (
300+
<QuickAccessSection
301+
title={t('catalog.popularItems')}
302+
items={isPopularLoading ? [] : popularItems}
303+
selectedItems={selectedItems}
304+
onItemToggle={handleItemToggle}
305+
/>
306+
)}
307+
<Text className="mb-2 text-sm font-semibold text-foreground">
308+
{t('catalog.browseCatalog')}
309+
</Text>
310+
</View>
311+
);
312+
}, [
313+
isDefaultView,
314+
popularItems,
315+
isPopularLoading,
316+
recentItems,
317+
selectedItems,
318+
handleItemToggle,
319+
t,
320+
]);
321+
122322
return (
123323
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet">
124324
<SafeAreaView className="flex-1 bg-background">
@@ -171,7 +371,7 @@ export function CatalogBrowserModal({
171371
<Text>{t('catalog.tryAgain')}</Text>
172372
</Button>
173373
</View>
174-
) : items.length === 0 ? (
374+
) : items.length === 0 && !isDefaultView ? (
175375
<View className="flex-1 items-center justify-center p-4">
176376
<Icon name="magnify" size={48} color={colors.grey2} />
177377
<Text className="mt-2 text-center font-semibold">{t('catalog.noItemsFound')}</Text>
@@ -186,6 +386,7 @@ export function CatalogBrowserModal({
186386
renderItem={renderItem}
187387
contentContainerStyle={{ padding: 16 }}
188388
ItemSeparatorComponent={ItemSeparatorComponent}
389+
ListHeaderComponent={ListHeaderComponent}
189390
refreshControl={
190391
<RefreshControl
191392
refreshing={isRefetching}
@@ -206,27 +407,15 @@ export function CatalogBrowserModal({
206407
)}
207408
</View>
208409

209-
{/* Bottom Actions */}
410+
{/* Bottom Actions with per-item quantity */}
210411
{selectedItems.size > 0 && (
211-
<View className="border-t border-border bg-card p-4">
212-
<View className="flex-row gap-3 items-center justify-end">
213-
<Button
214-
variant="secondary"
215-
className="mb-1"
216-
onPress={() => setSelectedItems(new Set())}
217-
>
218-
<Text>{t('catalog.clearSelection')}</Text>
219-
</Button>
220-
<Button onPress={handleAddSelected} className="mb-1" variant="tonal">
221-
<Text>
222-
{t('catalog.addItems', {
223-
count: selectedItems.size,
224-
unit: selectedItems.size > 1 ? t('catalog.items') : t('catalog.item'),
225-
})}
226-
</Text>
227-
</Button>
228-
</View>
229-
</View>
412+
<SelectedItemsQuantityPanel
413+
items={selectedItemsList}
414+
quantities={itemQuantities}
415+
onQuantityChange={handleQuantityChange}
416+
onClear={resetSelection}
417+
onAdd={handleAddSelected}
418+
/>
230419
)}
231420
</SafeAreaView>
232421
</Modal>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export * from '../../packs/hooks/useBulkAddCatalogItems';
22
export * from './useCatalogItemDetails';
33
export * from './useCatalogItems';
4+
export * from './usePopularCatalogItems';
5+
export * from './useRecentlyUsedCatalogItems';
46
export * from './useSimilarItems';
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
import { getCatalogItems } from './useCatalogItems';
3+
4+
/**
5+
* Fetches the most popular catalog items, sorted by usage count (number of times
6+
* an item has been added to packs across all users).
7+
*/
8+
export function usePopularCatalogItems(limit = 10) {
9+
return useQuery({
10+
queryKey: ['catalogItems', 'popular', limit],
11+
queryFn: () =>
12+
getCatalogItems({
13+
limit,
14+
sort: { field: 'usage', order: 'desc' },
15+
}),
16+
staleTime: 5 * 60 * 1000, // 5 minutes
17+
});
18+
}

0 commit comments

Comments
 (0)