-
Notifications
You must be signed in to change notification settings - Fork 38
fix(catalog): stable pagination + variant grouping #2572
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a9d85a2
a0d0fd1
2f13c2f
e6b5007
5d1016d
8a46c67
dc75494
fcaedb2
1af579a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import type { CatalogItem } from 'expo-app/features/catalog/types'; | ||
| import { atom } from 'jotai'; | ||
|
|
||
| // Holds all variants of the catalog group the user tapped in the list. | ||
| // Set immediately before pushing to /catalog/[id] so the detail screen can | ||
| // show a variants list without any extra API calls. | ||
| export const catalogGroupVariantsAtom = atom<CatalogItem[]>([]); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,13 +11,15 @@ export function CategoriesFilter({ | |
| error, | ||
| retry, | ||
| className, | ||
| contentPaddingX = 0, | ||
| }: { | ||
| activeFilter: string; | ||
| onFilter: (filter: string) => void; | ||
| data: string[] | undefined; | ||
| error?: Error | null; | ||
| retry?: (() => void) | undefined; | ||
| className?: string; | ||
| contentPaddingX?: number; | ||
| }) { | ||
| const { t } = useTranslation(); | ||
|
|
||
|
|
@@ -57,7 +59,13 @@ export function CategoriesFilter({ | |
| </View> | ||
| </View> | ||
| ) : ( | ||
| <ScrollView horizontal showsHorizontalScrollIndicator={false} className="py-1"> | ||
| <ScrollView | ||
| horizontal | ||
| showsHorizontalScrollIndicator={false} | ||
| contentContainerStyle={ | ||
| contentPaddingX ? { paddingHorizontal: contentPaddingX } : undefined | ||
| } | ||
|
Comment on lines
+62
to
+67
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix falsy check for contentPaddingX. Line 66 treats 🐛 Proposed fix- contentContainerStyle={
- contentPaddingX ? { paddingHorizontal: contentPaddingX } : undefined
- }
+ contentContainerStyle={
+ contentPaddingX > 0 ? { paddingHorizontal: contentPaddingX } : undefined
+ }Or more explicitly: - contentContainerStyle={
- contentPaddingX ? { paddingHorizontal: contentPaddingX } : undefined
- }
+ contentContainerStyle={
+ typeof contentPaddingX === 'number' && contentPaddingX > 0
+ ? { paddingHorizontal: contentPaddingX }
+ : undefined
+ }🤖 Prompt for AI Agents |
||
| > | ||
| {!data | ||
| ? Array.from({ length: 10 }).map((_, i) => ( | ||
| <View | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import type { CatalogItem } from '../types'; | ||
|
|
||
| export type CatalogItemGroup = { | ||
| key: string; | ||
| representative: CatalogItem; | ||
| variants: CatalogItem[]; | ||
| }; | ||
|
|
||
| function groupKey(item: CatalogItem): string { | ||
| return `${(item.name ?? '').toLowerCase().trim()}:::${(item.brand ?? '').toLowerCase().trim()}`; | ||
| } | ||
|
|
||
| export function groupCatalogItems(items: CatalogItem[]): CatalogItemGroup[] { | ||
| const map = new Map<string, CatalogItemGroup>(); | ||
| for (const item of items) { | ||
| const key = groupKey(item); | ||
| const group = map.get(key); | ||
| if (group) { | ||
| group.variants.push(item); | ||
| } else { | ||
| map.set(key, { key, representative: item, variants: [item] }); | ||
| } | ||
| } | ||
| return Array.from(map.values()); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,6 @@ | ||||||||||||||||||||||||||||||||||||||
| import { Ionicons } from '@expo/vector-icons'; | ||||||||||||||||||||||||||||||||||||||
| import { Button, Text } from '@packrat/ui/nativewindui'; | ||||||||||||||||||||||||||||||||||||||
| import { catalogGroupVariantsAtom } from 'expo-app/atoms/catalogGroupAtom'; | ||||||||||||||||||||||||||||||||||||||
| import { Icon } from 'expo-app/components/Icon'; | ||||||||||||||||||||||||||||||||||||||
| import { Chip } from 'expo-app/components/initial/Chip'; | ||||||||||||||||||||||||||||||||||||||
| import { ExpandableText } from 'expo-app/components/initial/ExpandableText'; | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -14,11 +15,66 @@ import { ErrorScreen } from 'expo-app/screens/ErrorScreen'; | |||||||||||||||||||||||||||||||||||||
| import { LoadingSpinnerScreen } from 'expo-app/screens/LoadingSpinnerScreen'; | ||||||||||||||||||||||||||||||||||||||
| import { NotFoundScreen } from 'expo-app/screens/NotFoundScreen'; | ||||||||||||||||||||||||||||||||||||||
| import { useLocalSearchParams, useRouter } from 'expo-router'; | ||||||||||||||||||||||||||||||||||||||
| import { Linking, Text as RNText, ScrollView, View } from 'react-native'; | ||||||||||||||||||||||||||||||||||||||
| import { useAtomValue } from 'jotai'; | ||||||||||||||||||||||||||||||||||||||
| import { Linking, Pressable, Text as RNText, ScrollView, View } from 'react-native'; | ||||||||||||||||||||||||||||||||||||||
| import { SafeAreaView } from 'react-native-safe-area-context'; | ||||||||||||||||||||||||||||||||||||||
| import { CatalogItemImage } from '../components/CatalogItemImage'; | ||||||||||||||||||||||||||||||||||||||
| import { useCatalogItemDetails } from '../hooks'; | ||||||||||||||||||||||||||||||||||||||
| import { normalizeDescription } from '../lib/normalizeDescription'; | ||||||||||||||||||||||||||||||||||||||
| import type { CatalogItem } from '../types'; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| function VariantRow({ variant }: { variant: CatalogItem }) { | ||||||||||||||||||||||||||||||||||||||
| const { t } = useTranslation(); | ||||||||||||||||||||||||||||||||||||||
| const { colors } = useColorScheme(); | ||||||||||||||||||||||||||||||||||||||
| const label = [variant.size, variant.color].filter(Boolean).join(' · '); | ||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||
| <View className="flex-row items-center justify-between border-b border-border py-3"> | ||||||||||||||||||||||||||||||||||||||
| <View className="flex-1 gap-0.5"> | ||||||||||||||||||||||||||||||||||||||
| {label ? <Text className="text-sm font-medium text-foreground">{label}</Text> : null} | ||||||||||||||||||||||||||||||||||||||
| <View className="flex-row items-center gap-3"> | ||||||||||||||||||||||||||||||||||||||
| {variant.price != null && ( | ||||||||||||||||||||||||||||||||||||||
| <Text className="text-sm text-foreground"> | ||||||||||||||||||||||||||||||||||||||
| {variant.currency ?? '$'} | ||||||||||||||||||||||||||||||||||||||
| {variant.price.toFixed(2)} | ||||||||||||||||||||||||||||||||||||||
| </Text> | ||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||
| {variant.weight != null && ( | ||||||||||||||||||||||||||||||||||||||
| <Text className="text-xs text-muted-foreground"> | ||||||||||||||||||||||||||||||||||||||
| {variant.weight} {variant.weightUnit} | ||||||||||||||||||||||||||||||||||||||
| </Text> | ||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||
| {variant.availability && ( | ||||||||||||||||||||||||||||||||||||||
| <View className="flex-row items-center gap-0.5"> | ||||||||||||||||||||||||||||||||||||||
| <Ionicons | ||||||||||||||||||||||||||||||||||||||
| name={ | ||||||||||||||||||||||||||||||||||||||
| variant.availability === 'in_stock' | ||||||||||||||||||||||||||||||||||||||
| ? 'checkmark-circle-outline' | ||||||||||||||||||||||||||||||||||||||
| : 'close-circle-outline' | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| size={12} | ||||||||||||||||||||||||||||||||||||||
| color={variant.availability === 'in_stock' ? colors.green : colors.destructive} | ||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||
| <Text className="text-xs text-muted-foreground"> | ||||||||||||||||||||||||||||||||||||||
| {variant.availability === 'in_stock' | ||||||||||||||||||||||||||||||||||||||
| ? t('catalog.inStock') | ||||||||||||||||||||||||||||||||||||||
| : t('catalog.outOfStock')} | ||||||||||||||||||||||||||||||||||||||
| </Text> | ||||||||||||||||||||||||||||||||||||||
| </View> | ||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||
| </View> | ||||||||||||||||||||||||||||||||||||||
| </View> | ||||||||||||||||||||||||||||||||||||||
| {variant.productUrl ? ( | ||||||||||||||||||||||||||||||||||||||
| <Pressable | ||||||||||||||||||||||||||||||||||||||
| onPress={() => Linking.openURL(variant.productUrl as string)} | ||||||||||||||||||||||||||||||||||||||
| className="ml-3 flex-row items-center gap-1 rounded-md border border-border px-3 py-1.5" | ||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||
| <Text className="text-xs text-foreground">{t('catalog.viewOnRetailerSite')}</Text> | ||||||||||||||||||||||||||||||||||||||
| <Icon name="open-in-new" size={11} color={colors.foreground} /> | ||||||||||||||||||||||||||||||||||||||
| </Pressable> | ||||||||||||||||||||||||||||||||||||||
| ) : null} | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+66
to
+74
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win Remove unnecessary type cast. Line 67 casts ♻️ Proposed fix- onPress={() => Linking.openURL(variant.productUrl as string)}
+ onPress={() => Linking.openURL(variant.productUrl)}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| </View> | ||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export function CatalogItemDetailScreen() { | ||||||||||||||||||||||||||||||||||||||
| const router = useRouter(); | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -28,6 +84,12 @@ export function CatalogItemDetailScreen() { | |||||||||||||||||||||||||||||||||||||
| const { t } = useTranslation(); | ||||||||||||||||||||||||||||||||||||||
| const MATERIAL_LENGTH_THRESHOLD = 60; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const groupVariants = useAtomValue(catalogGroupVariantsAtom); | ||||||||||||||||||||||||||||||||||||||
| // Show the variants section only when there are multiple variants for this | ||||||||||||||||||||||||||||||||||||||
| // group — i.e. when the user navigated from the grouped catalog list. | ||||||||||||||||||||||||||||||||||||||
| const otherVariants = | ||||||||||||||||||||||||||||||||||||||
| groupVariants.length > 1 ? groupVariants.filter((v) => v.id !== Number(id)) : []; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const handleAddToPack = () => { | ||||||||||||||||||||||||||||||||||||||
| router.push({ | ||||||||||||||||||||||||||||||||||||||
| pathname: '/catalog/add-to-pack', | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -199,16 +261,29 @@ export function CatalogItemDetailScreen() { | |||||||||||||||||||||||||||||||||||||
| onPress={() => Linking.openURL(item.productUrl as string)} | ||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||
| <Text className="text-foreground">{t('catalog.viewOnRetailerSite')}</Text> | ||||||||||||||||||||||||||||||||||||||
| <Icon name="open-in-new" size={14} color={colors.foreground} /> | ||||||||||||||||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||||||||||||||||
| </View> | ||||||||||||||||||||||||||||||||||||||
| </View> | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| {/* Variants Section */} | ||||||||||||||||||||||||||||||||||||||
| {otherVariants.length > 0 && ( | ||||||||||||||||||||||||||||||||||||||
| <View className="mt-8"> | ||||||||||||||||||||||||||||||||||||||
| <Text variant="callout">{t('catalog.variantsSection')}</Text> | ||||||||||||||||||||||||||||||||||||||
| <View> | ||||||||||||||||||||||||||||||||||||||
| {otherVariants.map((variant) => ( | ||||||||||||||||||||||||||||||||||||||
| <VariantRow key={variant.id} variant={variant} /> | ||||||||||||||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||||||||||||||
| </View> | ||||||||||||||||||||||||||||||||||||||
| </View> | ||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| {item.techs && Object.keys(item.techs).length > 0 && ( | ||||||||||||||||||||||||||||||||||||||
| <View className="mt-8"> | ||||||||||||||||||||||||||||||||||||||
| <Text variant="callout" className="mb-2"> | ||||||||||||||||||||||||||||||||||||||
| {t('catalog.specifications')} | ||||||||||||||||||||||||||||||||||||||
| </Text> | ||||||||||||||||||||||||||||||||||||||
| <View className="rounded-lg p-3 gap-4"> | ||||||||||||||||||||||||||||||||||||||
| <View className="rounded-lg p-3 py-0 gap-4"> | ||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | 💤 Low value Simplify redundant padding class. The class string Consider using ♻️ Proposed fix- <View className="rounded-lg p-3 py-0 gap-4">
+ <View className="rounded-lg px-3 gap-4">📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| {Object.entries(item.techs).map(([key, value]) => ( | ||||||||||||||||||||||||||||||||||||||
| <View key={key} className="gap-1"> | ||||||||||||||||||||||||||||||||||||||
| <Text className="text-xs text-muted-foreground uppercase">{key}</Text> | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import { LargeTitleHeader, type LargeTitleSearchBarMethods, Text } from '@packrat/ui/nativewindui'; | ||
| import { catalogGroupVariantsAtom } from 'expo-app/atoms/catalogGroupAtom'; | ||
| import { searchValueAtom } from 'expo-app/atoms/itemListAtoms'; | ||
| import { AndroidTabBarInsetFix } from 'expo-app/components/AndroidTabBarInsetFix'; | ||
| import { CategoriesFilter } from 'expo-app/components/CategoriesFilter'; | ||
|
|
@@ -11,7 +12,7 @@ import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; | |
| import { testIds } from 'expo-app/lib/testIds'; | ||
| import { asNonNullableRef } from 'expo-app/lib/utils/asNonNullableRef'; | ||
| import { useRouter } from 'expo-router'; | ||
| import { useAtom } from 'jotai'; | ||
| import { useAtom, useSetAtom } from 'jotai'; | ||
| import { useMemo, useRef, useState } from 'react'; | ||
| import { | ||
| ActivityIndicator, | ||
|
|
@@ -27,6 +28,7 @@ import { CatalogItemCard } from '../components/CatalogItemCard'; | |
| import { useCatalogItemsInfinite } from '../hooks'; | ||
| import { useCatalogItemsCategories } from '../hooks/useCatalogItemsCategories'; | ||
| import { useVectorSearch } from '../hooks/useVectorSearch'; | ||
| import { type CatalogItemGroup, groupCatalogItems } from '../lib/groupCatalogItems'; | ||
| import type { CatalogItem } from '../types'; | ||
|
|
||
| function CatalogItemsScreen() { | ||
|
|
@@ -75,15 +77,14 @@ function CatalogItemsScreen() { | |
| Boolean(item?.id), | ||
| ); | ||
|
|
||
| const totalItems = paginatedData?.pages[0]?.totalCount ?? 0; | ||
| const groupedItems = useMemo(() => groupCatalogItems(paginatedItems), [paginatedItems]); | ||
|
|
||
| const totalItemsText = `${Number(totalItems).toLocaleString()} ${ | ||
| totalItems === 1 ? t('catalog.item') : t('catalog.items') | ||
| }`; | ||
| const showingText = t('catalog.showingItems', { | ||
| current: paginatedItems.length, | ||
| total: Number(totalItems).toLocaleString(), | ||
| }); | ||
| const setGroupVariants = useSetAtom(catalogGroupVariantsAtom); | ||
|
|
||
| const handleGroupPress = (group: CatalogItemGroup) => { | ||
| setGroupVariants(group.variants); | ||
| router.push({ pathname: '/catalog/[id]', params: { id: group.representative.id } }); | ||
| }; | ||
|
Comment on lines
+80
to
+87
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win Verify atom cleanup on back navigation. The atom is set before navigation (line 85) but never explicitly cleared. If the user navigates back from the detail screen and then selects a different group, the atom will be overwritten correctly. However, if the user navigates to a detail screen via deep link or direct URL entry, the atom will be empty, which is the intended behavior per the design. Consider documenting this behavior in a comment near the atom definition or in the 🤖 Prompt for AI Agents |
||
|
|
||
| const handleItemPress = (item: CatalogItem) => { | ||
| router.push({ pathname: '/catalog/[id]', params: { id: item.id } }); | ||
|
|
@@ -114,32 +115,12 @@ function CatalogItemsScreen() { | |
| activeFilter={activeFilter} | ||
| error={categoriesError} | ||
| retry={refetchCategories} | ||
| className="px-4 py-2" | ||
| className="py-4" | ||
| contentPaddingX={16} | ||
| /> | ||
|
|
||
| <View className="mb-4 px-4"> | ||
| <View className="flex-row items-center justify-between"> | ||
| <Text testID={testIds.catalog.totalItemsCount} className="text-muted-foreground"> | ||
| {totalItemsText} | ||
| </Text> | ||
| </View> | ||
|
|
||
| {paginatedItems.length > 0 && ( | ||
| <Text className="mt-1 text-xs text-muted-foreground">{showingText}</Text> | ||
| )} | ||
| </View> | ||
| </> | ||
| ); | ||
| }, [ | ||
| isSearching, | ||
| categories, | ||
| activeFilter, | ||
| categoriesError, | ||
| totalItemsText, | ||
| paginatedItems.length, | ||
| showingText, | ||
| refetchCategories, | ||
| ]); | ||
| }, [isSearching, categories, activeFilter, categoriesError, refetchCategories]); | ||
|
|
||
| return ( | ||
| <> | ||
|
|
@@ -220,17 +201,19 @@ function CatalogItemsScreen() { | |
| /> | ||
|
|
||
| <FlatList | ||
| data={paginatedItems} | ||
| keyExtractor={(item) => item.id.toString()} | ||
| renderItem={({ item }) => ( | ||
| <CatalogItemCard item={item} onPress={() => handleItemPress(item)} /> | ||
| data={groupedItems} | ||
| keyExtractor={(group) => group.key} | ||
| renderItem={({ item: group }) => ( | ||
| <View className="px-4"> | ||
| <CatalogItemCard item={group.representative} onPress={() => handleGroupPress(group)} /> | ||
| </View> | ||
| )} | ||
| ItemSeparatorComponent={ItemSeparatorComponent} | ||
| ListHeaderComponent={listHeader} | ||
| refreshControl={<RefreshControl refreshing={isManualRefresh} onRefresh={handleRefresh} />} | ||
| onEndReached={loadMore} | ||
| onEndReachedThreshold={0.5} | ||
| contentContainerStyle={{ flexGrow: 1, padding: 16 }} | ||
| contentContainerStyle={{ flexGrow: 1, paddingBottom: 16 }} | ||
| contentInsetAdjustmentBehavior="automatic" | ||
| ListFooterComponent={ | ||
| <> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial | ⚡ Quick win
Consider relocating this atom into the catalog feature module.
The atom is tightly coupled to the catalog feature and is only consumed by catalog screens. Per the feature module structure guidelines, catalog-specific state should live within
apps/expo/features/catalog/(e.g.,features/catalog/atoms.tsorfeatures/catalog/state.ts).🤖 Prompt for AI Agents
Source: Coding guidelines