Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions apps/expo/features/catalog/components/CatalogBrowserModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { useCatalogItemsCategories } from '../hooks/useCatalogItemsCategories';
import { usePopularCatalogItems } from '../hooks/usePopularCatalogItems';
import { useRecentlyUsedCatalogItems } from '../hooks/useRecentlyUsedCatalogItems';
import { useVectorSearch } from '../hooks/useVectorSearch';
import { groupCatalogItems } from '../lib/groupCatalogItems';
import type { CatalogItem } from '../types';

type CatalogBrowserModalProps = {
Expand Down Expand Up @@ -353,10 +354,17 @@ export function CatalogBrowserModal({
const isDefaultView = !isSearching && activeFilter === 'All';

const { data: categories } = useCatalogItemsCategories();
const { recentItems } = useRecentlyUsedCatalogItems();
const { recentItems: recentItemsRaw } = useRecentlyUsedCatalogItems();
const recentItems = useMemo(
() => groupCatalogItems(recentItemsRaw).map((g) => g.representative),
[recentItemsRaw],
);
const { data: popularData, isLoading: isPopularLoading } = usePopularCatalogItems(8);

const popularItems = popularData?.items ?? [];
const popularItems = useMemo(
() => groupCatalogItems(popularData?.items ?? []).map((g) => g.representative),
[popularData],
);

const {
data: paginatedData,
Expand All @@ -369,7 +377,7 @@ export function CatalogBrowserModal({
error: paginatedError,
} = useCatalogItemsInfinite({
category: activeFilter === 'All' ? undefined : activeFilter,
limit: 20,
limit: 80,
sort: { field: 'createdAt', order: 'desc' },
});

Expand All @@ -379,9 +387,15 @@ export function CatalogBrowserModal({
error: searchError,
} = useVectorSearch({ query: debouncedSearchValue, limit: 20 });

const paginatedRawItems = paginatedData?.pages.flatMap((page) => page.items) ?? [];
const groupedPaginatedItems = useMemo(
() => groupCatalogItems(paginatedRawItems),
[paginatedRawItems],
);
Comment on lines +390 to +394

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Unstable useMemo dependency causes unnecessary recomputation.

paginatedRawItems is computed inline on every render, creating a new array reference each time. This defeats useMemo for groupedPaginatedItems since the dependency always appears changed.

Memoize the flattened array or compute everything in a single useMemo:

Proposed fix
-  const paginatedRawItems = paginatedData?.pages.flatMap((page) => page.items) ?? [];
-  const groupedPaginatedItems = useMemo(
-    () => groupCatalogItems(paginatedRawItems),
-    [paginatedRawItems],
-  );
+  const groupedPaginatedItems = useMemo(
+    () => groupCatalogItems(paginatedData?.pages.flatMap((page) => page.items) ?? []),
+    [paginatedData],
+  );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const paginatedRawItems = paginatedData?.pages.flatMap((page) => page.items) ?? [];
const groupedPaginatedItems = useMemo(
() => groupCatalogItems(paginatedRawItems),
[paginatedRawItems],
);
const groupedPaginatedItems = useMemo(
() => groupCatalogItems(paginatedData?.pages.flatMap((page) => page.items) ?? []),
[paginatedData],
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/expo/features/catalog/components/CatalogBrowserModal.tsx` around lines
390 - 394, The flattened paginatedRawItems is recreated every render so
groupedPaginatedItems' useMemo always sees a new dependency; fix by memoizing
the flattened array (paginatedRawItems) or combining the flatten+group into one
useMemo. Concretely, move the flatMap call into a useMemo that depends on
paginatedData?.pages (e.g. create paginatedRawItems via useMemo(() =>
paginatedData?.pages.flatMap(p => p.items) ?? [], [paginatedData?.pages])) and
then pass that memoized paginatedRawItems into the existing useMemo that calls
groupCatalogItems, or collapse both steps into a single useMemo that computes
and returns groupCatalogItems(paginatedData?.pages.flatMap(...)). Ensure you
reference paginatedRawItems, groupedPaginatedItems, paginatedData?.pages, and
groupCatalogItems when making the change.


const items = isSearching
? searchResult?.items || []
: paginatedData?.pages.flatMap((page) => page.items) || [];
: groupedPaginatedItems.map((g) => g.representative);
const isLoading = isSearching ? isSearchLoading : isPaginatedLoading;
const error = isSearching ? searchError : paginatedError;

Expand Down Expand Up @@ -598,7 +612,7 @@ export function CatalogBrowserModal({
) : (
<FlatList
data={items}
keyExtractor={(item, index) => `${item.id}-${index}`}
keyExtractor={(item) => String(item.id)}
renderItem={renderItem}
contentContainerStyle={{ padding: 16 }}
ItemSeparatorComponent={ItemSeparatorComponent}
Expand Down
157 changes: 157 additions & 0 deletions apps/expo/features/catalog/components/ImageViewerModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { Icon } from 'expo-app/components/Icon';
import { StatusBar } from 'expo-status-bar';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Dimensions,
FlatList,
Image,
Modal,
Platform,
ScrollView,
TouchableOpacity,
View,
} from 'react-native';

const HEADERS = {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
Accept: 'image/webp,image/apng,image/*,*/*;q=0.8',
};

function ZoomablePage({ uri, width, height }: { uri: string; width: number; height: number }) {
const source = { uri, headers: HEADERS };

if (Platform.OS === 'ios') {
return (
<ScrollView
style={{ width, height }}
contentContainerStyle={{ width, height, alignItems: 'center', justifyContent: 'center' }}
maximumZoomScale={4}
minimumZoomScale={1}
centerContent
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
bouncesZoom
>
<Image source={source} style={{ width, height }} resizeMode="contain" />
</ScrollView>
);
}

return (
<View style={{ width, height, alignItems: 'center', justifyContent: 'center' }}>
<Image source={source} style={{ width, height }} resizeMode="contain" />
</View>
);
}

type Props = {
visible: boolean;
images: string[];
initialIndex?: number;
onClose: () => void;
};

export function ImageViewerModal({ visible, images, initialIndex = 0, onClose }: Props) {
const { width, height } = Dimensions.get('window');
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const listRef = useRef<FlatList>(null);

useEffect(() => {
if (visible) {
setCurrentIndex(initialIndex);
}
}, [visible, initialIndex]);

const handleViewableItemsChanged = useCallback(
({ viewableItems }: { viewableItems: { index: number | null }[] }) => {
if (viewableItems[0]?.index != null) {
setCurrentIndex(viewableItems[0].index);
}
},
[],
);

const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 50 });

const getItemLayout = useCallback(
(_: unknown, index: number) => ({ length: width, offset: width * index, index }),
[width],
);

if (images.length === 0) return null;

return (
<Modal
visible={visible}
animationType="fade"
transparent={false}
statusBarTranslucent
onRequestClose={onClose}
>
<StatusBar style="light" />
<View style={{ flex: 1, backgroundColor: '#000' }}>
<FlatList
ref={listRef}
data={images}
keyExtractor={(uri, i) => `${uri}-${i}`}
renderItem={({ item: uri }) => <ZoomablePage uri={uri} width={width} height={height} />}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onViewableItemsChanged={handleViewableItemsChanged}
viewabilityConfig={viewabilityConfig.current}
getItemLayout={getItemLayout}
initialScrollIndex={initialIndex}
removeClippedSubviews
/>

{/* Close button */}
<TouchableOpacity
onPress={onClose}
style={{
position: 'absolute',
top: 52,
right: 16,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(0,0,0,0.55)',
alignItems: 'center',
justifyContent: 'center',
}}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon name="close" size={20} color="#fff" />
</TouchableOpacity>

{/* Dot indicator */}
{images.length > 1 && (
<View
style={{
position: 'absolute',
bottom: 44,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'center',
gap: 6,
}}
>
{images.map((_, i) => (
<View
key={i}
style={{
height: 6,
width: i === currentIndex ? 20 : 6,
borderRadius: 3,
backgroundColor: i === currentIndex ? '#fff' : 'rgba(255,255,255,0.35)',
}}
/>
))}
</View>
)}
</View>
</Modal>
);
}
82 changes: 76 additions & 6 deletions apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,18 @@ import { LoadingSpinnerScreen } from 'expo-app/screens/LoadingSpinnerScreen';
import { NotFoundScreen } from 'expo-app/screens/NotFoundScreen';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useAtomValue } from 'jotai';
import { Linking, Pressable, Text as RNText, ScrollView, View } from 'react-native';
import { useState } from 'react';
import {
Linking,
Pressable,
Text as RNText,
ScrollView,
TouchableOpacity,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CatalogItemImage } from '../components/CatalogItemImage';
import { ImageViewerModal } from '../components/ImageViewerModal';
import { useCatalogItemDetails } from '../hooks';
import { normalizeDescription } from '../lib/normalizeDescription';
import type { CatalogItem } from '../types';
Expand All @@ -29,6 +38,11 @@ function VariantRow({ variant }: { variant: CatalogItem }) {
const label = [variant.size, variant.color].filter(Boolean).join(' · ');
return (
<View className="flex-row items-center justify-between border-b border-border py-3">
<CatalogItemImage
imageUrl={variant.images?.[0]}
className="h-12 w-12 rounded-lg shrink-0 mr-3"
resizeMode="cover"
/>
<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">
Expand Down Expand Up @@ -84,6 +98,8 @@ export function CatalogItemDetailScreen() {
const { t } = useTranslation();
const MATERIAL_LENGTH_THRESHOLD = 60;

const [viewerIndex, setViewerIndex] = useState<number | null>(null);

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.
Expand Down Expand Up @@ -125,12 +141,66 @@ export function CatalogItemDetailScreen() {

return (
<SafeAreaView className="flex-1 bg-background" edges={['bottom']}>
<ImageViewerModal
visible={viewerIndex !== null}
images={item.images ?? []}
initialIndex={viewerIndex ?? 0}
onClose={() => setViewerIndex(null)}
/>
<ScrollView>
<CatalogItemImage
imageUrl={item.images?.[0]}
resizeMode="contain"
className="h-64 w-full"
/>
{/* Hero image — tap to open full-screen viewer */}
<TouchableOpacity
activeOpacity={0.92}
onPress={() => (item.images?.length ?? 0) > 0 && setViewerIndex(0)}
>
<View>
<CatalogItemImage
imageUrl={item.images?.[0]}
resizeMode="contain"
className="h-64 w-full"
/>
{(item.images?.length ?? 0) > 0 && (
<View
style={{
position: 'absolute',
bottom: 8,
right: 8,
backgroundColor: 'rgba(0,0,0,0.45)',
borderRadius: 14,
padding: 6,
}}
>
<Icon name="fullscreen" size={16} color="#fff" />
</View>
)}
</View>
</TouchableOpacity>

{/* Thumbnail strip — shown when there are multiple images */}
{(item.images?.length ?? 0) > 1 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 6, paddingHorizontal: 16, paddingVertical: 8 }}
>
{item.images?.map((uri, i) => (
<TouchableOpacity
key={`${uri}-${i}`}
onPress={() => setViewerIndex(i)}
style={{
width: 56,
height: 56,
borderRadius: 8,
overflow: 'hidden',
borderWidth: 1.5,
borderColor: colors.grey4,
}}
>
<CatalogItemImage imageUrl={uri} resizeMode="cover" className="h-full w-full" />
</TouchableOpacity>
))}
</ScrollView>
)}

<View className="bg-background p-4">
<View className="mb-2">
Expand Down
2 changes: 1 addition & 1 deletion apps/expo/features/catalog/screens/CatalogItemsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ function CatalogItemsScreen() {
error: paginatedError,
} = useCatalogItemsInfinite({
category: activeFilter === 'All' ? undefined : activeFilter,
limit: 20,
limit: 80,
sort: { field: 'createdAt', order: 'desc' },
});

Expand Down
37 changes: 0 additions & 37 deletions neon/monitor-query-performance.md

This file was deleted.

Loading
Loading