diff --git a/app/api/productApi.ts b/app/api/productApi.ts index 2218636..37bd106 100644 --- a/app/api/productApi.ts +++ b/app/api/productApi.ts @@ -11,8 +11,14 @@ export interface GetProductsResponse { meta: PaginationMeta; } -export const getProductsApi = async (): Promise => { - return await apiClient.get(`/products`); +export const getProductsApi = async ( + categoryId?: string, +): Promise => { + return await apiClient.get(`/products`, { + params: { + categoryId: categoryId, + }, + }); }; export const getProductDetailsApi = async ( diff --git a/app/features/screens/homeScreen/homeScreen.tsx b/app/features/screens/homeScreen/homeScreen.tsx index 8cbcd04..1ef0f32 100644 --- a/app/features/screens/homeScreen/homeScreen.tsx +++ b/app/features/screens/homeScreen/homeScreen.tsx @@ -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 = () => { 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 ( { - 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, ]} > - {item.icon} + {emoji} { ]} numberOfLines={1} > - {item.label} + {item.name} ); diff --git a/app/features/screens/searchScreen/reducer.ts b/app/features/screens/searchScreen/reducer.ts new file mode 100644 index 0000000..dbf5c92 --- /dev/null +++ b/app/features/screens/searchScreen/reducer.ts @@ -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; diff --git a/app/features/screens/searchScreen/searchScreen.styles.ts b/app/features/screens/searchScreen/searchScreen.styles.ts index 849ec49..f7c9ff4 100644 --- a/app/features/screens/searchScreen/searchScreen.styles.ts +++ b/app/features/screens/searchScreen/searchScreen.styles.ts @@ -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({ - container: { - flex: 1, - backgroundColor: colors.background, - paddingTop: 8, - }, - list: { - paddingBottom: 24, - }, - empty: { - alignItems: 'center', - paddingTop: 60, - }, - emptyIcon: { - fontSize: 48, - marginBottom: 12, - }, - emptyText: { - fontSize: typography.fontSize.md, - color: colors.textSecondary, - textAlign: 'center', - paddingHorizontal: 40, - }, -}); +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, + }, + 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: 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', + }, + }); diff --git a/app/features/screens/searchScreen/searchScreen.tsx b/app/features/screens/searchScreen/searchScreen.tsx index 53e2ab1..67e1ff8 100644 --- a/app/features/screens/searchScreen/searchScreen.tsx +++ b/app/features/screens/searchScreen/searchScreen.tsx @@ -1,81 +1,295 @@ -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, StackNavigationProp >; +type SearchRouteProp = RouteProp; + +// ─── 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(); + const route = useRoute(); - const [query, setQuery] = useState(''); - const [results, setResults] = useState([]); + 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( + 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 = () => ( + + {[0, 1, 2, 3].map(i => ( + + + + + + ))} + + ); + + const renderEmpty = useCallback(() => { + if (isLoading) return null; + return ( + + 🔍 + No products found + + {searchQuery.trim().length > 0 + ? `No results for "${searchQuery}" in ${headerCategoryName}` + : `No products available in ${headerCategoryName} right now`} + + + ); + }, [isLoading, searchQuery, headerCategoryName, styles]); + + const renderError = () => ( + + ⚠️ + {error} + + ); + + const renderProductItem = useCallback( + ({ item }: { item: Products }) => ( + + navigation.navigate('ProviderDetailsScreen', { + providerId: item.id, + providerName: item.name, + }) + } + /> + ), + [navigation], + ); + + // ─── Render ──────────────────────────────────────────────────────────────── return ( - - item.id} - renderItem={({ item }) => ( - - navigation.navigate('ProviderDetailsScreen', { - providerId: item.id, - providerName: item.name, - }) - } - /> + {/* Header */} + + navigation.goBack()} + > + + + + {headerCategoryName} + + {!isLoading && ( + + {filteredProducts.length} item{filteredProducts.length !== 1 ? 's' : ''} + )} - ListEmptyComponent={ - query.trim().length > 0 ? ( - - No results found - - ) : ( - - 🔍 - Search for food, groceries, medicines... - - ) - } - contentContainerStyle={styles.list} - /> + + + {/* Search Bar */} + + + + + {/* Category chips */} + {categories.length > 0 && ( + + + {chipCategories.map(cat => { + const isActive = + cat.id === 'all' + ? activeCategoryId === undefined || activeCategoryId === 'all' + : activeCategoryId === cat.id; + const emoji = cat.id === 'all' ? '🏠' : getCategoryEmoji(cat.name); + return ( + { + setSearchQuery(''); + setActiveCategoryId(cat.id === 'all' ? undefined : cat.id); + }} + > + {emoji} + + {cat.name} + + + ); + })} + + + )} + + {/* Error */} + {error && !isLoading && renderError()} + + {/* Loading skeleton */} + {isLoading && renderSkeletons()} + + {/* Product grid */} + {!isLoading && !error && ( + item.id} + numColumns={2} + columnWrapperStyle={styles.row} + renderItem={renderProductItem} + ListEmptyComponent={renderEmpty} + contentContainerStyle={styles.gridContent} + showsVerticalScrollIndicator={false} + initialNumToRender={8} + maxToRenderPerBatch={10} + windowSize={5} + /> + )} ); }; diff --git a/app/features/screens/searchScreen/thunk.ts b/app/features/screens/searchScreen/thunk.ts new file mode 100644 index 0000000..aa78aba --- /dev/null +++ b/app/features/screens/searchScreen/thunk.ts @@ -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', + ); + } + }, +); diff --git a/app/navigation/mainTabNavigator.tsx b/app/navigation/mainTabNavigator.tsx index 3931ecb..81b06a0 100644 --- a/app/navigation/mainTabNavigator.tsx +++ b/app/navigation/mainTabNavigator.tsx @@ -11,7 +11,7 @@ import { export type MainTabParamList = { HomeScreen: undefined; - SearchScreen: undefined; + SearchScreen: { categoryId?: string; categoryName?: string } | undefined; MyOrdersScreen: undefined; OffersScreen: undefined; AccountScreen: undefined; diff --git a/app/services/apiClient.ts b/app/services/apiClient.ts index 4bc15fd..3699a6a 100644 --- a/app/services/apiClient.ts +++ b/app/services/apiClient.ts @@ -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 = { diff --git a/app/services/socketService.ts b/app/services/socketService.ts index 5189e5a..002a9ee 100644 --- a/app/services/socketService.ts +++ b/app/services/socketService.ts @@ -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 { diff --git a/app/store/rootReducer.ts b/app/store/rootReducer.ts index bbbb08f..ba33e4f 100644 --- a/app/store/rootReducer.ts +++ b/app/store/rootReducer.ts @@ -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; diff --git a/app/utils/helper.ts b/app/utils/helper.ts index eb9a59c..91e7659 100644 --- a/app/utils/helper.ts +++ b/app/utils/helper.ts @@ -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; };