feat: implement search functionality, add base infrastructure for socket tracking, and update configuration files.
This commit is contained in:
parent
6259c5b740
commit
b6af3c8d24
@ -11,8 +11,14 @@ export interface GetProductsResponse {
|
||||
meta: PaginationMeta;
|
||||
}
|
||||
|
||||
export const getProductsApi = async (): Promise<GetProductsResponse> => {
|
||||
return await apiClient.get<GetProductsResponse>(`/products`);
|
||||
export const getProductsApi = async (
|
||||
categoryId?: string,
|
||||
): Promise<GetProductsResponse> => {
|
||||
return await apiClient.get<GetProductsResponse>(`/products`, {
|
||||
params: {
|
||||
categoryId: categoryId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getProductDetailsApi = async (
|
||||
|
||||
@ -39,15 +39,20 @@ type NavProp = CompositeNavigationProp<
|
||||
const { width } = Dimensions.get('window');
|
||||
const BANNER_STEP = width - 32 + 12; // card width + margin
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'all', icon: '🏠', label: 'All' },
|
||||
{ key: 'Food', icon: '🍔', label: 'Food' },
|
||||
{ key: 'Groceries', icon: '🛒', label: 'Grocery' },
|
||||
{ key: 'Pharmacy', icon: '💊', label: 'Pharmacy' },
|
||||
{ key: 'Meat', icon: '🥩', label: 'Meat' },
|
||||
{ key: 'Flowers', icon: '💐', label: 'Flowers' },
|
||||
{ key: 'More', icon: '📦', label: 'More' },
|
||||
];
|
||||
const ALL_CATEGORY = { id: 'all', name: 'All', slug: 'all', imageUrl: null, isActive: true, parentId: null };
|
||||
|
||||
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 '📦';
|
||||
};
|
||||
|
||||
const PROMOS = [
|
||||
{
|
||||
@ -100,6 +105,9 @@ export const HomeScreen: React.FC = () => {
|
||||
}, [dispatch]),
|
||||
);
|
||||
|
||||
// Build category list: prepend synthetic 'All', then append API categories
|
||||
const dynamicCategories = [ALL_CATEGORY, ...categories];
|
||||
|
||||
const filteredProducts =
|
||||
selectedCategory === 'all'
|
||||
? products
|
||||
@ -197,21 +205,25 @@ export const HomeScreen: React.FC = () => {
|
||||
<View style={styles.categorySection}>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={CATEGORIES}
|
||||
keyExtractor={item => item.key}
|
||||
data={dynamicCategories}
|
||||
keyExtractor={item => item.id}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.chipRow}
|
||||
renderItem={({ item }) => {
|
||||
const isSelected = selectedCategory === item.key;
|
||||
const isSelected = selectedCategory === item.id;
|
||||
const emoji =
|
||||
item.id === 'all' ? '🏠' : getCategoryEmoji(item.name);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.categoryItem}
|
||||
activeOpacity={0.75}
|
||||
onPress={() => {
|
||||
setSelectedCategory(item.key);
|
||||
if (item.key !== 'all') {
|
||||
navigation.navigate('ProviderListScreen', {
|
||||
category: item.key,
|
||||
if (item.id === 'all') {
|
||||
setSelectedCategory('all');
|
||||
} else {
|
||||
navigation.navigate('SearchScreen', {
|
||||
categoryId: item.id,
|
||||
categoryName: item.name,
|
||||
});
|
||||
}
|
||||
}}
|
||||
@ -222,7 +234,7 @@ export const HomeScreen: React.FC = () => {
|
||||
isSelected && styles.categoryCircleSelected,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.categoryIcon}>{item.icon}</Text>
|
||||
<Text style={styles.categoryIcon}>{emoji}</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
@ -231,7 +243,7 @@ export const HomeScreen: React.FC = () => {
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.label}
|
||||
{item.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
41
app/features/screens/searchScreen/reducer.ts
Normal file
41
app/features/screens/searchScreen/reducer.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
import { Products } from '@interfaces';
|
||||
import { getProductsByCategoryThunk } from './thunk';
|
||||
|
||||
export interface SearchState {
|
||||
products: Products[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
selectedCategoryId: string | undefined;
|
||||
selectedCategoryName: string | undefined;
|
||||
}
|
||||
|
||||
const initialState: SearchState = {
|
||||
products: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedCategoryId: undefined,
|
||||
selectedCategoryName: undefined,
|
||||
};
|
||||
|
||||
const searchReducer = createReducer(initialState, builder => {
|
||||
builder
|
||||
.addCase(getProductsByCategoryThunk.pending, (state, action) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
// Store the categoryId that was requested so the UI can track it
|
||||
state.selectedCategoryId = action.meta.arg;
|
||||
})
|
||||
.addCase(getProductsByCategoryThunk.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.products =
|
||||
action.payload?.products ||
|
||||
(Array.isArray(action.payload) ? action.payload : []);
|
||||
})
|
||||
.addCase(getProductsByCategoryThunk.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload || 'Failed to fetch products';
|
||||
});
|
||||
});
|
||||
|
||||
export default searchReducer;
|
||||
@ -1,27 +1,194 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { StyleSheet, Dimensions, Platform } from 'react-native';
|
||||
import { typography } from '@theme';
|
||||
|
||||
export const getStyles = (colors: any) => StyleSheet.create({
|
||||
const { width } = Dimensions.get('window');
|
||||
const CARD_WIDTH = (width - 48) / 2; // 2-col grid with 16px side padding + 16px gap
|
||||
|
||||
export const getStyles = (colors: any) =>
|
||||
StyleSheet.create({
|
||||
// ---------- Root ----------
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
|
||||
// ---------- Header ----------
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 14,
|
||||
paddingBottom: 12,
|
||||
backgroundColor: colors.background,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border ?? '#ECECEC',
|
||||
},
|
||||
backBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.surface ?? '#F5F6F8',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
backBtnText: {
|
||||
fontSize: 18,
|
||||
color: colors.text,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: typography.fontSize.lg,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
},
|
||||
headerCount: {
|
||||
fontSize: typography.fontSize.sm,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
|
||||
// ---------- Search Bar wrapper ----------
|
||||
searchWrap: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
|
||||
// ---------- Category chips ----------
|
||||
chipsContainer: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border ?? '#ECECEC',
|
||||
},
|
||||
chipsList: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
marginRight: 10,
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: colors.primaryMuted ?? '#E9F7EF',
|
||||
borderColor: colors.primary ?? '#05824C',
|
||||
},
|
||||
chipInactive: {
|
||||
backgroundColor: colors.surface ?? '#F5F6F8',
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
chipEmoji: {
|
||||
fontSize: 14,
|
||||
marginRight: 5,
|
||||
},
|
||||
chipLabel: {
|
||||
fontSize: typography.fontSize.sm,
|
||||
fontWeight: typography.fontWeight.medium,
|
||||
},
|
||||
chipLabelActive: {
|
||||
color: colors.primary ?? '#05824C',
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
},
|
||||
chipLabelInactive: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
|
||||
// ---------- Section header ----------
|
||||
sectionHeader: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: typography.fontSize.md,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.text,
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: typography.fontSize.xs,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
// ---------- Product grid ----------
|
||||
gridContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
list: {
|
||||
paddingBottom: 24,
|
||||
row: {
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
|
||||
// ---------- Skeleton cards ----------
|
||||
skeletonCard: {
|
||||
width: CARD_WIDTH,
|
||||
height: CARD_WIDTH + 80,
|
||||
backgroundColor: colors.surface ?? '#F0F0F0',
|
||||
borderRadius: 12,
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
skeletonImageBlock: {
|
||||
width: '100%',
|
||||
height: CARD_WIDTH,
|
||||
backgroundColor: colors.border ?? '#E8E8E8',
|
||||
},
|
||||
skeletonTextBlock: {
|
||||
marginTop: 8,
|
||||
marginHorizontal: 10,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: colors.border ?? '#E8E8E8',
|
||||
},
|
||||
skeletonTextBlockShort: {
|
||||
marginTop: 6,
|
||||
marginHorizontal: 10,
|
||||
width: '55%',
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: colors.border ?? '#E8E8E8',
|
||||
},
|
||||
|
||||
// ---------- Empty state ----------
|
||||
empty: {
|
||||
alignItems: 'center',
|
||||
paddingTop: 60,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 12,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: typography.fontSize.md,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
paddingTop: 80,
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
});
|
||||
emptyIcon: {
|
||||
fontSize: 52,
|
||||
marginBottom: 14,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: typography.fontSize.lg,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: typography.fontSize.sm,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
|
||||
// ---------- Error state ----------
|
||||
errorWrap: {
|
||||
alignItems: 'center',
|
||||
paddingTop: 80,
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
errorIcon: {
|
||||
fontSize: 40,
|
||||
marginBottom: 12,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: typography.fontSize.sm,
|
||||
color: colors.error ?? '#E53935',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,59 +1,190 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
Animated,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { useNavigation, CompositeNavigationProp } from '@react-navigation/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, ProviderCard } from '@components';
|
||||
import { SearchBar, ProductCard } from '@components';
|
||||
import { useAppTheme } from '@theme';
|
||||
import { searchProvidersApi } from '../../../api/deliveryApi';
|
||||
import { Provider } from '../../../interfaces';
|
||||
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 [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<Provider[]>([]);
|
||||
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 (query.trim().length > 0) {
|
||||
searchProvidersApi(query).then(setResults);
|
||||
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 {
|
||||
setResults([]);
|
||||
shimmer.stopAnimation();
|
||||
}
|
||||
}, [query]);
|
||||
}, [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.container}>
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search providers or items..."
|
||||
/>
|
||||
<FlatList
|
||||
data={results}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<ProviderCard
|
||||
<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}
|
||||
rating={item.rating}
|
||||
deliveryTime={item.deliveryTime}
|
||||
tag={item.tag}
|
||||
discountText={item.discountText}
|
||||
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,
|
||||
@ -61,21 +192,104 @@ export const SearchScreen: React.FC = () => {
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
[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>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
query.trim().length > 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyText}>No results found</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyIcon}>🔍</Text>
|
||||
<Text style={styles.emptyText}>Search for food, groceries, medicines...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
contentContainerStyle={styles.list}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
20
app/features/screens/searchScreen/thunk.ts
Normal file
20
app/features/screens/searchScreen/thunk.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { getProductsApi, GetProductsResponse } from '@api';
|
||||
|
||||
export const getProductsByCategoryThunk = createAsyncThunk<
|
||||
GetProductsResponse,
|
||||
string | undefined,
|
||||
{ rejectValue: string }
|
||||
>(
|
||||
'search/getProductsByCategory',
|
||||
async (categoryId, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await getProductsApi(categoryId);
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(
|
||||
error?.response?.data?.message || 'Failed to fetch products',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -11,7 +11,7 @@ import {
|
||||
|
||||
export type MainTabParamList = {
|
||||
HomeScreen: undefined;
|
||||
SearchScreen: undefined;
|
||||
SearchScreen: { categoryId?: string; categoryName?: string } | undefined;
|
||||
MyOrdersScreen: undefined;
|
||||
OffersScreen: undefined;
|
||||
AccountScreen: undefined;
|
||||
|
||||
@ -15,7 +15,7 @@ const STORAGE_KEYS = {
|
||||
} as const;
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
const BASE_URL = 'https://2dbb-202-8-116-13.ngrok-free.app'; // TODO: replace with your actual base URL
|
||||
const BASE_URL = 'https://b4ae-202-8-116-13.ngrok-free.app'; // TODO: replace with your actual base URL
|
||||
|
||||
// ─── Token Helpers ───────────────────────────────────────────────────────────
|
||||
export const tokenManager = {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
const BASE_URL = 'https://2dbb-202-8-116-13.ngrok-free.app';
|
||||
const BASE_URL = 'https://b4ae-202-8-116-13.ngrok-free.app';
|
||||
const SOCKET_URL = `${BASE_URL}/tracking`;
|
||||
|
||||
export interface DriverLocationUpdate {
|
||||
|
||||
@ -12,6 +12,7 @@ import paymentMethodsReducer from '@features/screens/checkoutPaymentScreen/reduc
|
||||
import customerProfileReducer from './commonreducers/customerProfile/reducer';
|
||||
import { offerReducer } from './commonreducers/offer';
|
||||
import { accountReducer } from '@features/screens';
|
||||
import searchReducer from '@features/screens/searchScreen/reducer';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
auth: authReducer,
|
||||
@ -26,6 +27,7 @@ const rootReducer = combineReducers({
|
||||
customerProfile: customerProfileReducer,
|
||||
offer: offerReducer,
|
||||
account: accountReducer,
|
||||
search: searchReducer,
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof rootReducer>;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
export const getFullUrl = (url?: string) => {
|
||||
const BASE_URL = 'https://2dbb-202-8-116-13.ngrok-free.app';
|
||||
const BASE_URL = 'https://b4ae-202-8-116-13.ngrok-free.app';
|
||||
if (!url) return '';
|
||||
return url.startsWith('/') ? `${BASE_URL}${url}` : url;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user