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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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