296 lines
9.8 KiB
TypeScript
296 lines
9.8 KiB
TypeScript
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';
|
||
|
||
// ─── 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()}
|
||
>
|
||
<Text style={styles.backBtnText}>←</Text>
|
||
</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>
|
||
);
|
||
};
|