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;
|
meta: PaginationMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getProductsApi = async (): Promise<GetProductsResponse> => {
|
export const getProductsApi = async (
|
||||||
return await apiClient.get<GetProductsResponse>(`/products`);
|
categoryId?: string,
|
||||||
|
): Promise<GetProductsResponse> => {
|
||||||
|
return await apiClient.get<GetProductsResponse>(`/products`, {
|
||||||
|
params: {
|
||||||
|
categoryId: categoryId,
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getProductDetailsApi = async (
|
export const getProductDetailsApi = async (
|
||||||
|
|||||||
@ -39,15 +39,20 @@ type NavProp = CompositeNavigationProp<
|
|||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const BANNER_STEP = width - 32 + 12; // card width + margin
|
const BANNER_STEP = width - 32 + 12; // card width + margin
|
||||||
|
|
||||||
const CATEGORIES = [
|
const ALL_CATEGORY = { id: 'all', name: 'All', slug: 'all', imageUrl: null, isActive: true, parentId: null };
|
||||||
{ key: 'all', icon: '🏠', label: 'All' },
|
|
||||||
{ key: 'Food', icon: '🍔', label: 'Food' },
|
const getCategoryEmoji = (name: string): string => {
|
||||||
{ key: 'Groceries', icon: '🛒', label: 'Grocery' },
|
const n = name.toLowerCase();
|
||||||
{ key: 'Pharmacy', icon: '💊', label: 'Pharmacy' },
|
if (n.includes('food')) return '🍔';
|
||||||
{ key: 'Meat', icon: '🥩', label: 'Meat' },
|
if (n.includes('grocer')) return '🛒';
|
||||||
{ key: 'Flowers', icon: '💐', label: 'Flowers' },
|
if (n.includes('pharma') || n.includes('medic')) return '💊';
|
||||||
{ key: 'More', icon: '📦', label: 'More' },
|
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 = [
|
const PROMOS = [
|
||||||
{
|
{
|
||||||
@ -100,6 +105,9 @@ export const HomeScreen: React.FC = () => {
|
|||||||
}, [dispatch]),
|
}, [dispatch]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Build category list: prepend synthetic 'All', then append API categories
|
||||||
|
const dynamicCategories = [ALL_CATEGORY, ...categories];
|
||||||
|
|
||||||
const filteredProducts =
|
const filteredProducts =
|
||||||
selectedCategory === 'all'
|
selectedCategory === 'all'
|
||||||
? products
|
? products
|
||||||
@ -197,21 +205,25 @@ export const HomeScreen: React.FC = () => {
|
|||||||
<View style={styles.categorySection}>
|
<View style={styles.categorySection}>
|
||||||
<FlatList
|
<FlatList
|
||||||
horizontal
|
horizontal
|
||||||
data={CATEGORIES}
|
data={dynamicCategories}
|
||||||
keyExtractor={item => item.key}
|
keyExtractor={item => item.id}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.chipRow}
|
contentContainerStyle={styles.chipRow}
|
||||||
renderItem={({ item }) => {
|
renderItem={({ item }) => {
|
||||||
const isSelected = selectedCategory === item.key;
|
const isSelected = selectedCategory === item.id;
|
||||||
|
const emoji =
|
||||||
|
item.id === 'all' ? '🏠' : getCategoryEmoji(item.name);
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.categoryItem}
|
style={styles.categoryItem}
|
||||||
activeOpacity={0.75}
|
activeOpacity={0.75}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setSelectedCategory(item.key);
|
if (item.id === 'all') {
|
||||||
if (item.key !== 'all') {
|
setSelectedCategory('all');
|
||||||
navigation.navigate('ProviderListScreen', {
|
} else {
|
||||||
category: item.key,
|
navigation.navigate('SearchScreen', {
|
||||||
|
categoryId: item.id,
|
||||||
|
categoryName: item.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -222,7 +234,7 @@ export const HomeScreen: React.FC = () => {
|
|||||||
isSelected && styles.categoryCircleSelected,
|
isSelected && styles.categoryCircleSelected,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.categoryIcon}>{item.icon}</Text>
|
<Text style={styles.categoryIcon}>{emoji}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@ -231,7 +243,7 @@ export const HomeScreen: React.FC = () => {
|
|||||||
]}
|
]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.name}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</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';
|
import { typography } from '@theme';
|
||||||
|
|
||||||
export const getStyles = (colors: any) => StyleSheet.create({
|
const { width } = Dimensions.get('window');
|
||||||
container: {
|
const CARD_WIDTH = (width - 48) / 2; // 2-col grid with 16px side padding + 16px gap
|
||||||
flex: 1,
|
|
||||||
backgroundColor: colors.background,
|
export const getStyles = (colors: any) =>
|
||||||
paddingTop: 8,
|
StyleSheet.create({
|
||||||
},
|
// ---------- Root ----------
|
||||||
list: {
|
container: {
|
||||||
paddingBottom: 24,
|
flex: 1,
|
||||||
},
|
backgroundColor: colors.background,
|
||||||
empty: {
|
},
|
||||||
alignItems: 'center',
|
|
||||||
paddingTop: 60,
|
// ---------- Header ----------
|
||||||
},
|
header: {
|
||||||
emptyIcon: {
|
flexDirection: 'row',
|
||||||
fontSize: 48,
|
alignItems: 'center',
|
||||||
marginBottom: 12,
|
paddingHorizontal: 16,
|
||||||
},
|
paddingTop: 14,
|
||||||
emptyText: {
|
paddingBottom: 12,
|
||||||
fontSize: typography.fontSize.md,
|
backgroundColor: colors.background,
|
||||||
color: colors.textSecondary,
|
borderBottomWidth: 1,
|
||||||
textAlign: 'center',
|
borderBottomColor: colors.border ?? '#ECECEC',
|
||||||
paddingHorizontal: 40,
|
},
|
||||||
},
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@ -1,81 +1,295 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
FlatList,
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
Animated,
|
||||||
|
ScrollView,
|
||||||
} from 'react-native';
|
} 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 { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
import { getStyles } from './searchScreen.styles';
|
import { getStyles } from './searchScreen.styles';
|
||||||
import { SearchBar, ProviderCard } from '@components';
|
import { SearchBar, ProductCard } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { searchProvidersApi } from '../../../api/deliveryApi';
|
|
||||||
import { Provider } from '../../../interfaces';
|
|
||||||
import { AppStackParamList } from '../../../navigation/appStack';
|
import { AppStackParamList } from '../../../navigation/appStack';
|
||||||
import { MainTabParamList } from '../../../navigation/mainTabNavigator';
|
import { MainTabParamList } from '../../../navigation/mainTabNavigator';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@store';
|
||||||
|
import { getProductsByCategoryThunk } from './thunk';
|
||||||
|
import { Products } from '@interfaces';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type NavProp = CompositeNavigationProp<
|
type NavProp = CompositeNavigationProp<
|
||||||
BottomTabNavigationProp<MainTabParamList, 'SearchScreen'>,
|
BottomTabNavigationProp<MainTabParamList, 'SearchScreen'>,
|
||||||
StackNavigationProp<AppStackParamList>
|
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 = () => {
|
export const SearchScreen: React.FC = () => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
const styles = getStyles(colors);
|
const styles = getStyles(colors);
|
||||||
const navigation = useNavigation<NavProp>();
|
const navigation = useNavigation<NavProp>();
|
||||||
|
const route = useRoute<SearchRouteProp>();
|
||||||
|
|
||||||
const [query, setQuery] = useState('');
|
const dispatch = useAppDispatch();
|
||||||
const [results, setResults] = useState<Provider[]>([]);
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (query.trim().length > 0) {
|
if (isLoading) {
|
||||||
searchProvidersApi(query).then(setResults);
|
Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(shimmer, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 900,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(shimmer, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 900,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
).start();
|
||||||
} else {
|
} 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.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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<SearchBar
|
{/* Header */}
|
||||||
value={query}
|
<View style={styles.header}>
|
||||||
onChangeText={setQuery}
|
<TouchableOpacity
|
||||||
placeholder="Search providers or items..."
|
style={styles.backBtn}
|
||||||
/>
|
activeOpacity={0.7}
|
||||||
<FlatList
|
onPress={() => navigation.goBack()}
|
||||||
data={results}
|
>
|
||||||
keyExtractor={(item) => item.id}
|
<Text style={styles.backBtnText}>←</Text>
|
||||||
renderItem={({ item }) => (
|
</TouchableOpacity>
|
||||||
<ProviderCard
|
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||||
imageUrl={item.imageUrl}
|
{headerCategoryName}
|
||||||
name={item.name}
|
</Text>
|
||||||
rating={item.rating}
|
{!isLoading && (
|
||||||
deliveryTime={item.deliveryTime}
|
<Text style={styles.headerCount}>
|
||||||
tag={item.tag}
|
{filteredProducts.length} item{filteredProducts.length !== 1 ? 's' : ''}
|
||||||
discountText={item.discountText}
|
</Text>
|
||||||
onPress={() =>
|
|
||||||
navigation.navigate('ProviderDetailsScreen', {
|
|
||||||
providerId: item.id,
|
|
||||||
providerName: item.name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
ListEmptyComponent={
|
</View>
|
||||||
query.trim().length > 0 ? (
|
|
||||||
<View style={styles.empty}>
|
{/* Search Bar */}
|
||||||
<Text style={styles.emptyText}>No results found</Text>
|
<View style={styles.searchWrap}>
|
||||||
</View>
|
<SearchBar
|
||||||
) : (
|
value={searchQuery}
|
||||||
<View style={styles.empty}>
|
onChangeText={setSearchQuery}
|
||||||
<Text style={styles.emptyIcon}>🔍</Text>
|
placeholder={`Search in ${headerCategoryName}...`}
|
||||||
<Text style={styles.emptyText}>Search for food, groceries, medicines...</Text>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
|
||||||
}
|
{/* Category chips */}
|
||||||
contentContainerStyle={styles.list}
|
{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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
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 = {
|
export type MainTabParamList = {
|
||||||
HomeScreen: undefined;
|
HomeScreen: undefined;
|
||||||
SearchScreen: undefined;
|
SearchScreen: { categoryId?: string; categoryName?: string } | undefined;
|
||||||
MyOrdersScreen: undefined;
|
MyOrdersScreen: undefined;
|
||||||
OffersScreen: undefined;
|
OffersScreen: undefined;
|
||||||
AccountScreen: undefined;
|
AccountScreen: undefined;
|
||||||
|
|||||||
@ -15,7 +15,7 @@ const STORAGE_KEYS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
// ─── 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 ───────────────────────────────────────────────────────────
|
// ─── Token Helpers ───────────────────────────────────────────────────────────
|
||||||
export const tokenManager = {
|
export const tokenManager = {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { io, Socket } from 'socket.io-client';
|
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`;
|
const SOCKET_URL = `${BASE_URL}/tracking`;
|
||||||
|
|
||||||
export interface DriverLocationUpdate {
|
export interface DriverLocationUpdate {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import paymentMethodsReducer from '@features/screens/checkoutPaymentScreen/reduc
|
|||||||
import customerProfileReducer from './commonreducers/customerProfile/reducer';
|
import customerProfileReducer from './commonreducers/customerProfile/reducer';
|
||||||
import { offerReducer } from './commonreducers/offer';
|
import { offerReducer } from './commonreducers/offer';
|
||||||
import { accountReducer } from '@features/screens';
|
import { accountReducer } from '@features/screens';
|
||||||
|
import searchReducer from '@features/screens/searchScreen/reducer';
|
||||||
|
|
||||||
const rootReducer = combineReducers({
|
const rootReducer = combineReducers({
|
||||||
auth: authReducer,
|
auth: authReducer,
|
||||||
@ -26,6 +27,7 @@ const rootReducer = combineReducers({
|
|||||||
customerProfile: customerProfileReducer,
|
customerProfile: customerProfileReducer,
|
||||||
offer: offerReducer,
|
offer: offerReducer,
|
||||||
account: accountReducer,
|
account: accountReducer,
|
||||||
|
search: searchReducer,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RootState = ReturnType<typeof rootReducer>;
|
export type RootState = ReturnType<typeof rootReducer>;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
export const getFullUrl = (url?: string) => {
|
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 '';
|
if (!url) return '';
|
||||||
return url.startsWith('/') ? `${BASE_URL}${url}` : url;
|
return url.startsWith('/') ? `${BASE_URL}${url}` : url;
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user