diff --git a/apps/expo/atoms/catalogGroupAtom.ts b/apps/expo/atoms/catalogGroupAtom.ts new file mode 100644 index 0000000000..14c7f3f4d7 --- /dev/null +++ b/apps/expo/atoms/catalogGroupAtom.ts @@ -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([]); diff --git a/apps/expo/components/CategoriesFilter.tsx b/apps/expo/components/CategoriesFilter.tsx index b70144247a..2aaee65198 100644 --- a/apps/expo/components/CategoriesFilter.tsx +++ b/apps/expo/components/CategoriesFilter.tsx @@ -11,6 +11,7 @@ export function CategoriesFilter({ error, retry, className, + contentPaddingX = 0, }: { activeFilter: string; onFilter: (filter: string) => void; @@ -18,6 +19,7 @@ export function CategoriesFilter({ error?: Error | null; retry?: (() => void) | undefined; className?: string; + contentPaddingX?: number; }) { const { t } = useTranslation(); @@ -57,7 +59,13 @@ export function CategoriesFilter({ ) : ( - + {!data ? Array.from({ length: 10 }).map((_, i) => ( = ({ data={Array(3).fill(null)} renderItem={() => } keyExtractor={(_, index) => `loading-${index}`} + style={{ marginHorizontal: -16 }} contentContainerStyle={{ paddingHorizontal: 16 }} /> @@ -127,6 +128,7 @@ export const SimilarItems: React.FC = ({ data={data.items} renderItem={({ item }) => } keyExtractor={(item) => item.id.toString()} + style={{ marginHorizontal: -16 }} contentContainerStyle={{ paddingHorizontal: 16 }} /> diff --git a/apps/expo/features/catalog/lib/groupCatalogItems.ts b/apps/expo/features/catalog/lib/groupCatalogItems.ts new file mode 100644 index 0000000000..151376850b --- /dev/null +++ b/apps/expo/features/catalog/lib/groupCatalogItems.ts @@ -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(); + 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()); +} diff --git a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx index 82c09368ff..4559e2c31e 100644 --- a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx +++ b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx @@ -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 ( + + + {label ? {label} : null} + + {variant.price != null && ( + + {variant.currency ?? '$'} + {variant.price.toFixed(2)} + + )} + {variant.weight != null && ( + + {variant.weight} {variant.weightUnit} + + )} + {variant.availability && ( + + + + {variant.availability === 'in_stock' + ? t('catalog.inStock') + : t('catalog.outOfStock')} + + + )} + + + {variant.productUrl ? ( + 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" + > + {t('catalog.viewOnRetailerSite')} + + + ) : null} + + ); +} 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)} > {t('catalog.viewOnRetailerSite')} + + {/* Variants Section */} + {otherVariants.length > 0 && ( + + {t('catalog.variantsSection')} + + {otherVariants.map((variant) => ( + + ))} + + + )} + {item.techs && Object.keys(item.techs).length > 0 && ( {t('catalog.specifications')} - + {Object.entries(item.techs).map(([key, value]) => ( {key} diff --git a/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx b/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx index 768253ee6a..0f8652ff41 100644 --- a/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx +++ b/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx @@ -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 } }); + }; 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} /> - - - - - {totalItemsText} - - - - {paginatedItems.length > 0 && ( - {showingText} - )} - ); - }, [ - isSearching, - categories, - activeFilter, - categoriesError, - totalItemsText, - paginatedItems.length, - showingText, - refetchCategories, - ]); + }, [isSearching, categories, activeFilter, categoriesError, refetchCategories]); return ( <> @@ -220,17 +201,19 @@ function CatalogItemsScreen() { /> item.id.toString()} - renderItem={({ item }) => ( - handleItemPress(item)} /> + data={groupedItems} + keyExtractor={(group) => group.key} + renderItem={({ item: group }) => ( + + handleGroupPress(group)} /> + )} ItemSeparatorComponent={ItemSeparatorComponent} ListHeaderComponent={listHeader} refreshControl={} onEndReached={loadMore} onEndReachedThreshold={0.5} - contentContainerStyle={{ flexGrow: 1, padding: 16 }} + contentContainerStyle={{ flexGrow: 1, paddingBottom: 16 }} contentInsetAdjustmentBehavior="automatic" ListFooterComponent={ <> diff --git a/apps/expo/lib/i18n/locales/en.json b/apps/expo/lib/i18n/locales/en.json index 8f960aee78..3c69f275de 100644 --- a/apps/expo/lib/i18n/locales/en.json +++ b/apps/expo/lib/i18n/locales/en.json @@ -160,7 +160,7 @@ "loginFailed": "Login Failed", "invalidEmailOrPassword": "Invalid email or password", "resumeSync": "Resume Sync", - "syncPaused": "Sync paused \u2014 please sign in again." + "syncPaused": "Sync paused — please sign in again." }, "profile": { "profile": "Profile", @@ -249,10 +249,10 @@ "lastUpdatedShort": "Last updated", "itemsCount": "{{count}} items", "sharingBenefits": "Sharing Benefits", - "distributeGroupGear": "\u2022 Distribute group gear among members to reduce individual pack weight", - "sharingBenefit1": "\u2022 Coordinate who brings shared items like tent, stove, and water filter", - "sharingBenefit2": "\u2022 See real-time updates when members modify the pack", - "sharingBenefit3": "\u2022 Plan together and ensure nothing essential is forgotten", + "distributeGroupGear": "• Distribute group gear among members to reduce individual pack weight", + "sharingBenefit1": "• Coordinate who brings shared items like tent, stove, and water filter", + "sharingBenefit2": "• See real-time updates when members modify the pack", + "sharingBenefit3": "• Plan together and ensure nothing essential is forgotten", "itemsInInventory": "{{count}} items in your inventory", "all": "All", "byCategory": "By Category", @@ -514,7 +514,7 @@ "outOfStock": "Out of Stock", "preorder": "Pre-order", "specifications": "Specifications", - "viewOnRetailerSite": "View on Retailer Site", + "viewOnRetailerSite": "Visit Site", "addToPack": "Add to Pack", "errorFetchingItem": "Error fetching item", "pleaseTryAgain": "Please try again.", @@ -562,7 +562,10 @@ "recentlyUsed": "Recently Used", "selectedItemsQuantity": "Selected Items ({{count}})", "noRecentlyUsedItems": "No recently used items yet", - "quantityFor": "Qty for {{name}}" + "quantityFor": "Qty for {{name}}", + "variantsSection": "Variants", + "variantSize": "Size", + "variantColor": "Color" }, "welcome": { "features": { diff --git a/packages/api/src/services/catalogService.ts b/packages/api/src/services/catalogService.ts index 3a3c4e4e74..009db15ad1 100644 --- a/packages/api/src/services/catalogService.ts +++ b/packages/api/src/services/catalogService.ts @@ -116,22 +116,29 @@ export class CatalogService { let orderBy = [desc(sql`COALESCE(pack_item_counts.count, 0)`), desc(catalogItems.id)]; // default ordering by usage if (sort) { const { field, order } = sort; + // All branches include `desc(catalogItems.id)` as a tiebreaker so that + // LIMIT/OFFSET pagination is stable. Without it, rows sharing the same + // sort-key value (e.g. all ETL-imported items have identical created_at) + // are ordered non-deterministically by the planner, causing the same item + // to appear on multiple pages and other items to be skipped entirely. if (field === 'category') { orderBy = [ order === 'desc' ? desc(sql`jsonb_array_elements_text(${catalogItems.categories})[0]`) : asc(sql`jsonb_array_elements_text(${catalogItems.categories})[0]`), + desc(catalogItems.id), ]; } else if (field === 'usage') { orderBy = [ order === 'desc' ? desc(sql`COALESCE(pack_item_counts.count, 0)`) : asc(sql`COALESCE(pack_item_counts.count, 0)`), + desc(catalogItems.id), ]; } else { const sortColumn = catalogItems[field]; if (sortColumn) { - orderBy = [order === 'desc' ? desc(sortColumn) : asc(sortColumn)]; + orderBy = [order === 'desc' ? desc(sortColumn) : asc(sortColumn), desc(catalogItems.id)]; } } }