feat: implement search functionality, add base infrastructure for socket tracking, and update configuration files.

This commit is contained in:
Tamojit Biswas 2026-07-20 13:11:26 +05:30
parent 6259c5b740
commit b6af3c8d24
11 changed files with 559 additions and 97 deletions

View File

@ -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 (

View File

@ -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>
);

View 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;

View File

@ -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',
},
});

View File

@ -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>
);
};

View 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',
);
}
},
);

View File

@ -11,7 +11,7 @@ import {
export type MainTabParamList = {
HomeScreen: undefined;
SearchScreen: undefined;
SearchScreen: { categoryId?: string; categoryName?: string } | undefined;
MyOrdersScreen: undefined;
OffersScreen: undefined;
AccountScreen: undefined;

View File

@ -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 = {

View File

@ -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 {

View File

@ -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>;

View File

@ -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;
};