297 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
Animated,
ScrollView,
} from 'react-native';
import {
useNavigation,
useRoute,
RouteProp,
CompositeNavigationProp,
} from '@react-navigation/native';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import { StackNavigationProp } from '@react-navigation/stack';
import { getStyles } from './searchScreen.styles';
import { SearchBar, ProductCard } from '@components';
import { useAppTheme } from '@theme';
import { AppStackParamList } from '../../../navigation/appStack';
import { MainTabParamList } from '../../../navigation/mainTabNavigator';
import { useAppDispatch, useAppSelector } from '@store';
import { getProductsByCategoryThunk } from './thunk';
import { Products } from '@interfaces';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
// ─── Types ───────────────────────────────────────────────────────────────────
type NavProp = CompositeNavigationProp<
BottomTabNavigationProp<MainTabParamList, 'SearchScreen'>,
StackNavigationProp<AppStackParamList>
>;
type SearchRouteProp = RouteProp<MainTabParamList, 'SearchScreen'>;
// ─── Helpers ─────────────────────────────────────────────────────────────────
const getCategoryEmoji = (name: string): string => {
const n = name.toLowerCase();
if (n.includes('food')) return '🍔';
if (n.includes('grocer')) return '🛒';
if (n.includes('pharma') || n.includes('medic')) return '💊';
if (n.includes('meat')) return '🥩';
if (n.includes('flower')) return '💐';
if (n.includes('electronic')) return '📱';
if (n.includes('cloth') || n.includes('fashion')) return '👗';
if (n.includes('bakery') || n.includes('cake')) return '🎂';
return '📦';
};
// ─── Component ───────────────────────────────────────────────────────────────
export const SearchScreen: React.FC = () => {
const { colors } = useAppTheme();
const styles = getStyles(colors);
const navigation = useNavigation<NavProp>();
const route = useRoute<SearchRouteProp>();
const dispatch = useAppDispatch();
// Redux state
const { products, isLoading, error } = useAppSelector(
state => state.search,
);
const { categories } = useAppSelector(state => state.home);
// Route params (set when navigating from HomeScreen)
const routeCategoryId = route.params?.categoryId;
const routeCategoryName = route.params?.categoryName;
// Local state
const [activeCategoryId, setActiveCategoryId] = useState<string | undefined>(
routeCategoryId,
);
const [searchQuery, setSearchQuery] = useState('');
// Skeleton shimmer animation
const shimmer = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (isLoading) {
Animated.loop(
Animated.sequence([
Animated.timing(shimmer, {
toValue: 1,
duration: 900,
useNativeDriver: true,
}),
Animated.timing(shimmer, {
toValue: 0,
duration: 900,
useNativeDriver: true,
}),
]),
).start();
} else {
shimmer.stopAnimation();
}
}, [isLoading, shimmer]);
// Sync when route params change (e.g. user taps a different chip on HomeScreen)
useEffect(() => {
setActiveCategoryId(routeCategoryId);
setSearchQuery('');
}, [routeCategoryId]);
// Fetch products whenever active category changes
useEffect(() => {
dispatch(getProductsByCategoryThunk(activeCategoryId));
}, [activeCategoryId, dispatch]);
// Client-side text filter on top of the fetched product set
const filteredProducts: Products[] = products.filter(p =>
searchQuery.trim().length === 0
? true
: p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(p.brand ?? '').toLowerCase().includes(searchQuery.toLowerCase()),
);
// Category chips: prepend "All"
const allChip = {
id: 'all' as const,
name: 'All',
slug: 'all',
imageUrl: null,
isActive: true,
parentId: null,
};
const chipCategories = [allChip, ...categories];
const headerCategoryName =
activeCategoryId === undefined || activeCategoryId === 'all'
? 'All Products'
: routeCategoryName ?? 'Products';
// ─── Sub-renders ───────────────────────────────────────────────────────────
const shimmerOpacity = shimmer.interpolate({
inputRange: [0, 1],
outputRange: [0.4, 1],
});
const renderSkeletons = () => (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: 8 }}>
{[0, 1, 2, 3].map(i => (
<Animated.View key={i} style={[styles.skeletonCard, { opacity: shimmerOpacity }]}>
<View style={styles.skeletonImageBlock} />
<View style={styles.skeletonTextBlock} />
<View style={styles.skeletonTextBlockShort} />
</Animated.View>
))}
</View>
);
const renderEmpty = useCallback(() => {
if (isLoading) return null;
return (
<View style={styles.empty}>
<Text style={styles.emptyIcon}>🔍</Text>
<Text style={styles.emptyTitle}>No products found</Text>
<Text style={styles.emptyText}>
{searchQuery.trim().length > 0
? `No results for "${searchQuery}" in ${headerCategoryName}`
: `No products available in ${headerCategoryName} right now`}
</Text>
</View>
);
}, [isLoading, searchQuery, headerCategoryName, styles]);
const renderError = () => (
<View style={styles.errorWrap}>
<Text style={styles.errorIcon}></Text>
<Text style={styles.errorText}>{error}</Text>
</View>
);
const renderProductItem = useCallback(
({ item }: { item: Products }) => (
<ProductCard
key={item.id}
id={item.id}
imageUrl={item.imageUrl}
name={item.name}
price={item.price}
compareAtPrice={item.compareAtPrice}
brand={item.brand || item.merchant?.name}
currency={item.currency === 'INR' ? '₹' : item.currency}
onPress={() =>
navigation.navigate('ProviderDetailsScreen', {
providerId: item.id,
providerName: item.name,
})
}
/>
),
[navigation],
);
// ─── Render ────────────────────────────────────────────────────────────────
return (
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
style={styles.backBtn}
activeOpacity={0.7}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="arrow-back" size={24} color="black" />
</TouchableOpacity>
<Text style={styles.headerTitle} numberOfLines={1}>
{headerCategoryName}
</Text>
{!isLoading && (
<Text style={styles.headerCount}>
{filteredProducts.length} item{filteredProducts.length !== 1 ? 's' : ''}
</Text>
)}
</View>
{/* Search Bar */}
<View style={styles.searchWrap}>
<SearchBar
value={searchQuery}
onChangeText={setSearchQuery}
placeholder={`Search in ${headerCategoryName}...`}
/>
</View>
{/* Category chips */}
{categories.length > 0 && (
<View style={styles.chipsContainer}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.chipsList}
>
{chipCategories.map(cat => {
const isActive =
cat.id === 'all'
? activeCategoryId === undefined || activeCategoryId === 'all'
: activeCategoryId === cat.id;
const emoji = cat.id === 'all' ? '🏠' : getCategoryEmoji(cat.name);
return (
<TouchableOpacity
key={cat.id}
style={[styles.chip, isActive ? styles.chipActive : styles.chipInactive]}
activeOpacity={0.75}
onPress={() => {
setSearchQuery('');
setActiveCategoryId(cat.id === 'all' ? undefined : cat.id);
}}
>
<Text style={styles.chipEmoji}>{emoji}</Text>
<Text
style={[
styles.chipLabel,
isActive ? styles.chipLabelActive : styles.chipLabelInactive,
]}
>
{cat.name}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
</View>
)}
{/* Error */}
{error && !isLoading && renderError()}
{/* Loading skeleton */}
{isLoading && renderSkeletons()}
{/* Product grid */}
{!isLoading && !error && (
<FlatList
data={filteredProducts}
keyExtractor={item => item.id}
numColumns={2}
columnWrapperStyle={styles.row}
renderItem={renderProductItem}
ListEmptyComponent={renderEmpty}
contentContainerStyle={styles.gridContent}
showsVerticalScrollIndicator={false}
initialNumToRender={8}
maxToRenderPerBatch={10}
windowSize={5}
/>
)}
</View>
);
};