feat: implement core feature screens, navigation structure, and redux store state management
This commit is contained in:
parent
7a0fd59892
commit
0806289a81
16
app/api/cartApi.ts
Normal file
16
app/api/cartApi.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { AddToCartRequest, CartResponse } from '@interfaces/cart';
|
||||
import { apiClient } from '@services';
|
||||
|
||||
export const addToCartApi = async (addToCartRequest: AddToCartRequest) => {
|
||||
return apiClient.post<CartResponse>('/cart/items', addToCartRequest);
|
||||
};
|
||||
|
||||
export const getCartApi = async () => {
|
||||
return apiClient.get<CartResponse>('/cart');
|
||||
};
|
||||
|
||||
export const updateCartItem = async (productId: string, quantity: number) => {
|
||||
return apiClient.patch<CartResponse>(`/cart/items/${productId}`, {
|
||||
quantity,
|
||||
});
|
||||
};
|
||||
@ -1,2 +1,5 @@
|
||||
export * from './authApi';
|
||||
export * from './deliveryApi';
|
||||
export * from './onboardApi';
|
||||
export * from './productApi';
|
||||
export * from './cartApi';
|
||||
|
||||
12
app/api/onboardApi.ts
Normal file
12
app/api/onboardApi.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Category, onBoardPayload, UserProfile } from '@interfaces/onboard';
|
||||
import { apiClient } from '@services';
|
||||
|
||||
export const getCatagories = async (): Promise<Category[]> => {
|
||||
return await apiClient.get('/categories');
|
||||
};
|
||||
|
||||
export const onBoardComplete = async (
|
||||
payload: onBoardPayload,
|
||||
): Promise<UserProfile> => {
|
||||
return await apiClient.post('/auth/onboard', payload);
|
||||
};
|
||||
17
app/api/productApi.ts
Normal file
17
app/api/productApi.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Products, PaginationMeta, Product } from '@interfaces';
|
||||
import { apiClient } from '@services';
|
||||
|
||||
export interface GetProductsResponse {
|
||||
products: Products[];
|
||||
meta: PaginationMeta;
|
||||
}
|
||||
|
||||
export const getProductsApi = async (): Promise<GetProductsResponse> => {
|
||||
return await apiClient.get<GetProductsResponse>(`/products`);
|
||||
};
|
||||
|
||||
export const getProductDetailsApi = async (
|
||||
productId: string,
|
||||
): Promise<Product> => {
|
||||
return await apiClient.get<Product>(`/products/${productId}`);
|
||||
};
|
||||
0
app/api/reviewApi.ts
Normal file
0
app/api/reviewApi.ts
Normal file
@ -4,6 +4,7 @@ export * from './SocialButton';
|
||||
export * from './header';
|
||||
export * from './searchBar';
|
||||
export * from './providerCard';
|
||||
export * from './productCard';
|
||||
export * from './catalogItemRow';
|
||||
export * from './stepProgress';
|
||||
export * from './badge';
|
||||
|
||||
1
app/components/productCard/index.ts
Normal file
1
app/components/productCard/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './productCard';
|
||||
109
app/components/productCard/productCard.styles.ts
Normal file
109
app/components/productCard/productCard.styles.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { StyleSheet, Dimensions, Platform } from 'react-native';
|
||||
import { typography } from '@theme';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
// Screen padding = 16 * 2 = 32
|
||||
// Gap between cards = 16
|
||||
// Total cards width = width - 32 - 16 = width - 48
|
||||
const CARD_WIDTH = (width - 48) / 2;
|
||||
|
||||
export const getStyles = (colors: any) =>
|
||||
StyleSheet.create({
|
||||
cardContainer: {
|
||||
width: CARD_WIDTH,
|
||||
backgroundColor: colors.surface ?? '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
android: { elevation: 2 },
|
||||
}),
|
||||
},
|
||||
imageContainer: {
|
||||
width: '100%',
|
||||
height: CARD_WIDTH, // Square image
|
||||
backgroundColor: colors.background ?? '#F8F9FA',
|
||||
position: 'relative',
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
discountBadge: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
backgroundColor: colors.error ?? '#E53935',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
discountText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 10,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
},
|
||||
favoriteButton: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
infoContainer: {
|
||||
padding: 10,
|
||||
},
|
||||
brandText: {
|
||||
fontSize: 10,
|
||||
color: colors.textSecondary,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 2,
|
||||
fontWeight: typography.fontWeight.semibold,
|
||||
},
|
||||
titleText: {
|
||||
fontSize: 13,
|
||||
color: colors.text,
|
||||
fontWeight: typography.fontWeight.medium,
|
||||
marginBottom: 4,
|
||||
},
|
||||
priceRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
priceText: {
|
||||
fontSize: 14,
|
||||
color: colors.text,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
marginRight: 6,
|
||||
},
|
||||
comparePriceText: {
|
||||
fontSize: 11,
|
||||
color: colors.textSecondary,
|
||||
textDecorationLine: 'line-through',
|
||||
},
|
||||
addButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.primaryMuted ?? '#E9F7EF',
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
},
|
||||
addButtonText: {
|
||||
color: colors.primary ?? '#05824C',
|
||||
fontSize: 12,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
},
|
||||
});
|
||||
112
app/components/productCard/productCard.tsx
Normal file
112
app/components/productCard/productCard.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
import { getStyles } from './productCard.styles';
|
||||
import { useAppTheme } from '@theme';
|
||||
|
||||
export interface ProductCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
price: string;
|
||||
compareAtPrice?: string;
|
||||
brand?: string | null;
|
||||
currency?: string;
|
||||
onPress?: () => void;
|
||||
onAddPress?: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export const getDiscountPercentage = (price: string, comparePrice?: string) => {
|
||||
if (!comparePrice || !price) return null;
|
||||
const p = parseFloat(price);
|
||||
const c = parseFloat(comparePrice);
|
||||
if (c <= p || c <= 0) return null;
|
||||
const discount = Math.round(((c - p) / c) * 100);
|
||||
return discount > 0 ? `${discount}% OFF` : null;
|
||||
};
|
||||
|
||||
export const formatPrice = (price: string, currency: string = '₹') => {
|
||||
const num = parseFloat(price);
|
||||
return isNaN(num) ? price : `${currency}${num.toFixed(0)}`;
|
||||
};
|
||||
|
||||
export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
id,
|
||||
name,
|
||||
imageUrl,
|
||||
price,
|
||||
compareAtPrice,
|
||||
brand,
|
||||
currency = '₹',
|
||||
onPress,
|
||||
onAddPress,
|
||||
style,
|
||||
}) => {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = getStyles(colors);
|
||||
|
||||
const discountText = getDiscountPercentage(price, compareAtPrice);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.cardContainer, style]}
|
||||
activeOpacity={0.7}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.imageContainer}>
|
||||
<Image
|
||||
source={{
|
||||
uri: imageUrl?.startsWith('/')
|
||||
? `https://f7ee-202-8-116-13.ngrok-free.app${imageUrl}`
|
||||
: imageUrl,
|
||||
}}
|
||||
style={styles.image}
|
||||
// fallback source can be handled here if needed
|
||||
/>
|
||||
{discountText && (
|
||||
<View style={styles.discountBadge}>
|
||||
<Text style={styles.discountText}>{discountText}</Text>
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity style={styles.favoriteButton}>
|
||||
<Text style={{ fontSize: 14 }}>🤍</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoContainer}>
|
||||
{brand && (
|
||||
<Text style={styles.brandText} numberOfLines={1}>
|
||||
{brand}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.titleText} numberOfLines={2}>
|
||||
{name}
|
||||
</Text>
|
||||
|
||||
<View style={styles.priceRow}>
|
||||
<Text style={styles.priceText}>{formatPrice(price, currency)}</Text>
|
||||
{compareAtPrice && parseFloat(compareAtPrice) > parseFloat(price) && (
|
||||
<Text style={styles.comparePriceText}>
|
||||
{formatPrice(compareAtPrice, currency)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
onPress={onAddPress || onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.addButtonText}>+ ADD</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@ -1,2 +1 @@
|
||||
export * from './loginScreen';
|
||||
export * from './loginScreen.styles';
|
||||
|
||||
@ -1,24 +1,19 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, ScrollView, TouchableOpacity, Image } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { getStyles } from './cartScreen.styles';
|
||||
import { Header, QuantitySelector, PrimaryButton } from '@components';
|
||||
import { useAppTheme } from '@theme';
|
||||
import {
|
||||
removeItem,
|
||||
applyCoupon,
|
||||
removeCoupon,
|
||||
selectCartTotal,
|
||||
getCartThunk,
|
||||
updateCartItemThunk,
|
||||
} from '../../../store/commonreducers/cart';
|
||||
import { AppStackParamList } from '../../../navigation/appStack';
|
||||
import { useAppDispatch, useAppSelector } from '@store';
|
||||
import { getFullUrl } from '../../../utils/helper';
|
||||
// import { getFullUrl } from '@utils/helperr';
|
||||
|
||||
type CartScreenNavProp = StackNavigationProp<AppStackParamList, 'CartScreen'>;
|
||||
|
||||
@ -27,20 +22,14 @@ export const CartScreen: React.FC = () => {
|
||||
const styles = getStyles(colors);
|
||||
const dispatch = useAppDispatch();
|
||||
const navigation = useNavigation<CartScreenNavProp>();
|
||||
const { items, couponCode } = useAppSelector(state => state.cart);
|
||||
const { items, isLoading } = useAppSelector(state => state.cart);
|
||||
const totals = useAppSelector(selectCartTotal);
|
||||
const [couponInput, setCouponInput] = useState('');
|
||||
|
||||
const handleCouponPress = () => {
|
||||
if (couponCode) {
|
||||
dispatch(removeCoupon());
|
||||
setCouponInput('');
|
||||
} else if (couponInput) {
|
||||
dispatch(applyCoupon(couponInput));
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
dispatch(getCartThunk());
|
||||
}, [dispatch]);
|
||||
|
||||
if (items.length === 0) {
|
||||
if (!isLoading && items.length === 0) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Header title="Cart" onBack={() => navigation.goBack()} />
|
||||
@ -85,19 +74,36 @@ export const CartScreen: React.FC = () => {
|
||||
index < items.length - 1 && styles.cartItemDivider,
|
||||
]}
|
||||
>
|
||||
<View style={styles.itemThumb}>
|
||||
<Text style={styles.itemThumbEmoji}>🍕</Text>
|
||||
</View>
|
||||
<Image
|
||||
style={styles.itemThumb}
|
||||
source={{ uri: getFullUrl(item.product.imageUrl) }}
|
||||
/>
|
||||
<View style={styles.itemInfo}>
|
||||
<Text style={styles.itemTitle} numberOfLines={2}>
|
||||
{item.item.title}
|
||||
{item.product.name}
|
||||
</Text>
|
||||
<Text style={styles.itemPrice}>₹{item.item.price}</Text>
|
||||
<Text style={styles.itemPrice}>₹{item.product.price}</Text>
|
||||
</View>
|
||||
<QuantitySelector
|
||||
value={item.quantity}
|
||||
onIncrement={() => {}}
|
||||
onDecrement={() => dispatch(removeItem(item.item.id))}
|
||||
onIncrement={() =>
|
||||
dispatch(
|
||||
updateCartItemThunk({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity + 1,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onDecrement={() => {
|
||||
if (item.quantity >= 1) {
|
||||
dispatch(
|
||||
updateCartItemThunk({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity - 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
@ -113,48 +119,12 @@ export const CartScreen: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.footer}>
|
||||
{/* Coupon logic removed/commented out for now as requested
|
||||
<Text style={styles.sectionTitle}>Apply Coupon</Text>
|
||||
<View style={styles.couponCard}>
|
||||
{couponCode ? (
|
||||
<View style={styles.couponAppliedRow}>
|
||||
<View style={styles.couponAppliedLeft}>
|
||||
<View style={styles.couponCheckBadge}>
|
||||
<Text style={styles.couponCheckIcon}>✓</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.couponAppliedCode}>{couponCode}</Text>
|
||||
<Text style={styles.couponAppliedSub}>Coupon applied</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={handleCouponPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.couponRemoveText}>Remove</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.couponRow}>
|
||||
<Text style={styles.couponIcon}>🏷️</Text>
|
||||
<TextInput
|
||||
style={styles.couponInput}
|
||||
placeholder="Enter coupon code"
|
||||
placeholderTextColor={colors.placeholder}
|
||||
value={couponInput}
|
||||
onChangeText={setCouponInput}
|
||||
autoCapitalize="characters"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.applyButton}
|
||||
onPress={handleCouponPress}
|
||||
activeOpacity={0.7}
|
||||
disabled={!couponInput}
|
||||
>
|
||||
<Text style={styles.applyButtonText}>Apply</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
...
|
||||
</View>
|
||||
*/}
|
||||
|
||||
<Text style={styles.sectionTitle}>Bill Details</Text>
|
||||
<View style={styles.billCard}>
|
||||
|
||||
@ -12,12 +12,15 @@ import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { getStyles } from './completeProfileScreen.styles';
|
||||
import { CustomInput, PrimaryButton } from '@components';
|
||||
import { useAppTheme } from '@theme';
|
||||
|
||||
import { completeProfile } from '../../../store/commonreducers/auth';
|
||||
import { AuthStackParamList } from '../../../navigation/authStack';
|
||||
import { useAppDispatch } from '@store';
|
||||
import { saveProfileData } from './reducer';
|
||||
import { OnboardingStackParamList } from '@navigation/onboardingStack';
|
||||
|
||||
type NavProp = StackNavigationProp<AuthStackParamList, 'CompleteProfileScreen'>;
|
||||
type NavProp = StackNavigationProp<
|
||||
OnboardingStackParamList,
|
||||
'CompleteProfileScreen'
|
||||
>;
|
||||
|
||||
const LOCATION_LABELS = ['Home', 'Work', 'Other'];
|
||||
|
||||
@ -29,10 +32,34 @@ export const CompleteProfileScreen: React.FC = () => {
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [gender, setGender] = useState<'MALE' | 'FEMALE' | 'OTHER'>('MALE');
|
||||
|
||||
const [addressLine1, setAddressLine1] = useState('');
|
||||
const [houseNumber, setHouseNumber] = useState('');
|
||||
const [landmark, setLandmark] = useState('');
|
||||
const [city, setCity] = useState('');
|
||||
const [state, setState] = useState('');
|
||||
const [postalCode, setPostalCode] = useState('');
|
||||
const [addressPhone, setAddressPhone] = useState('');
|
||||
|
||||
const [selectedLabel, setSelectedLabel] = useState('Home');
|
||||
|
||||
const handleSave = () => {
|
||||
dispatch(completeProfile({ name, email, locationLabels: [selectedLabel] }));
|
||||
dispatch(
|
||||
saveProfileData({
|
||||
name,
|
||||
email,
|
||||
gender,
|
||||
addressLine1,
|
||||
houseNumber,
|
||||
landmark,
|
||||
city,
|
||||
state,
|
||||
postalCode,
|
||||
addressPhone,
|
||||
addressLabel: selectedLabel,
|
||||
}),
|
||||
);
|
||||
navigation.navigate('PreferencesScreen');
|
||||
};
|
||||
|
||||
@ -41,26 +68,115 @@ export const CompleteProfileScreen: React.FC = () => {
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.scrollContainer}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Text style={styles.title}>Complete Profile</Text>
|
||||
<Text style={styles.subtitle}>Tell us about yourself</Text>
|
||||
|
||||
<View style={styles.form}>
|
||||
{/* Personal Information */}
|
||||
|
||||
<CustomInput
|
||||
label="Name"
|
||||
label="Full Name"
|
||||
placeholder="John Doe"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="Email"
|
||||
placeholder="john@example.com"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
|
||||
{/* Gender */}
|
||||
|
||||
<Text style={styles.labelText}>Gender</Text>
|
||||
|
||||
<View style={styles.labelRow}>
|
||||
{['MALE', 'FEMALE', 'OTHER'].map(item => (
|
||||
<TouchableOpacity
|
||||
key={item}
|
||||
style={[
|
||||
styles.labelChip,
|
||||
gender === item && styles.labelChipSelected,
|
||||
]}
|
||||
onPress={() => setGender(item as 'MALE' | 'FEMALE' | 'OTHER')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.labelChipText,
|
||||
gender === item && styles.labelChipTextSelected,
|
||||
]}
|
||||
>
|
||||
{item.charAt(0) + item.slice(1).toLowerCase()}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Address */}
|
||||
|
||||
<CustomInput
|
||||
label="Address Line 1"
|
||||
placeholder="123 Main Street"
|
||||
value={addressLine1}
|
||||
onChangeText={setAddressLine1}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="House / Flat Number"
|
||||
placeholder="Apt 4B"
|
||||
value={houseNumber}
|
||||
onChangeText={setHouseNumber}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="Landmark"
|
||||
placeholder="Near Central Park"
|
||||
value={landmark}
|
||||
onChangeText={setLandmark}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="City"
|
||||
placeholder="New York"
|
||||
value={city}
|
||||
onChangeText={setCity}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="State"
|
||||
placeholder="NY"
|
||||
value={state}
|
||||
onChangeText={setState}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="Postal Code"
|
||||
placeholder="10001"
|
||||
keyboardType="number-pad"
|
||||
value={postalCode}
|
||||
onChangeText={setPostalCode}
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label="Phone Number"
|
||||
placeholder="+1 9999999999"
|
||||
keyboardType="phone-pad"
|
||||
value={addressPhone}
|
||||
onChangeText={setAddressPhone}
|
||||
/>
|
||||
|
||||
{/* Location Label */}
|
||||
|
||||
<Text style={styles.labelText}>Location Label</Text>
|
||||
|
||||
<View style={styles.labelRow}>
|
||||
{LOCATION_LABELS.map(label => (
|
||||
<TouchableOpacity
|
||||
@ -70,7 +186,6 @@ export const CompleteProfileScreen: React.FC = () => {
|
||||
selectedLabel === label && styles.labelChipSelected,
|
||||
]}
|
||||
onPress={() => setSelectedLabel(label)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from './completeProfileScreen';
|
||||
export * from './reducer';
|
||||
|
||||
71
app/features/screens/completeProfileScreen/reducer.ts
Normal file
71
app/features/screens/completeProfileScreen/reducer.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { createReducer, createAction } from '@reduxjs/toolkit';
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const saveProfileData = createAction<{
|
||||
name: string;
|
||||
email: string;
|
||||
gender: 'MALE' | 'FEMALE' | 'OTHER';
|
||||
addressLine1: string;
|
||||
houseNumber: string;
|
||||
landmark: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
addressPhone: string;
|
||||
addressLabel: string;
|
||||
}>('completeProfile/saveProfileData');
|
||||
|
||||
export const clearProfileData = createAction('completeProfile/clearProfileData');
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CompleteProfileState {
|
||||
name: string;
|
||||
email: string;
|
||||
gender: 'MALE' | 'FEMALE' | 'OTHER';
|
||||
addressLine1: string;
|
||||
houseNumber: string;
|
||||
landmark: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
addressPhone: string;
|
||||
addressLabel: string;
|
||||
}
|
||||
|
||||
const initialState: CompleteProfileState = {
|
||||
name: '',
|
||||
email: '',
|
||||
gender: 'MALE',
|
||||
addressLine1: '',
|
||||
houseNumber: '',
|
||||
landmark: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postalCode: '',
|
||||
addressPhone: '',
|
||||
addressLabel: 'Home',
|
||||
};
|
||||
|
||||
// ─── Reducer ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const completeProfileReducer = createReducer(initialState, builder => {
|
||||
builder
|
||||
.addCase(saveProfileData, (state, action) => {
|
||||
state.name = action.payload.name;
|
||||
state.email = action.payload.email;
|
||||
state.gender = action.payload.gender;
|
||||
state.addressLine1 = action.payload.addressLine1;
|
||||
state.houseNumber = action.payload.houseNumber;
|
||||
state.landmark = action.payload.landmark;
|
||||
state.city = action.payload.city;
|
||||
state.state = action.payload.state;
|
||||
state.postalCode = action.payload.postalCode;
|
||||
state.addressPhone = action.payload.addressPhone;
|
||||
state.addressLabel = action.payload.addressLabel;
|
||||
})
|
||||
.addCase(clearProfileData, () => initialState);
|
||||
});
|
||||
|
||||
export default completeProfileReducer;
|
||||
0
app/features/screens/completeProfileScreen/thunk.ts
Normal file
0
app/features/screens/completeProfileScreen/thunk.ts
Normal file
@ -250,9 +250,12 @@ export const getStyles = (colors: any) =>
|
||||
color: colors.primary ?? '#05824C',
|
||||
},
|
||||
|
||||
// ---------- Provider cards ----------
|
||||
// ---------- Product Grid ----------
|
||||
providerList: {
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
providerCardWrap: {
|
||||
backgroundColor: colors.surface ?? '#FFFFFF',
|
||||
|
||||
@ -17,12 +17,13 @@ import {
|
||||
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { getStyles } from './homeScreen.styles';
|
||||
import { ProviderCard, SearchBar } from '@components';
|
||||
import { ProductCard, SearchBar } from '@components';
|
||||
import { useAppTheme } from '@theme';
|
||||
import { getProvidersApi } from '../../../api/deliveryApi';
|
||||
import { Provider } from '../../../interfaces';
|
||||
import { Product } from '../../../interfaces';
|
||||
import { AppStackParamList } from '../../../navigation/appStack';
|
||||
import { MainTabParamList } from '../../../navigation/mainTabNavigator';
|
||||
import { useAppDispatch, useAppSelector } from '@store';
|
||||
import { getAllProductsThunk } from './thunk';
|
||||
|
||||
type NavProp = CompositeNavigationProp<
|
||||
BottomTabNavigationProp<MainTabParamList, 'HomeScreen'>,
|
||||
@ -74,18 +75,20 @@ export const HomeScreen: React.FC = () => {
|
||||
const styles = getStyles(colors);
|
||||
const navigation = useNavigation<NavProp>();
|
||||
|
||||
const [providers, setProviders] = useState<Provider[]>([]);
|
||||
const dispatch = useAppDispatch();
|
||||
const { products, isLoading } = useAppSelector(state => state.home);
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [activeBanner, setActiveBanner] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
getProvidersApi().then(setProviders);
|
||||
}, []);
|
||||
dispatch(getAllProductsThunk());
|
||||
}, [dispatch]);
|
||||
|
||||
const filteredProviders =
|
||||
const filteredProducts =
|
||||
selectedCategory === 'all'
|
||||
? providers
|
||||
: providers.filter(p => p.tag === selectedCategory);
|
||||
? products
|
||||
: products.filter(p => p.category?.name === selectedCategory);
|
||||
|
||||
const handleSearchFocus = useCallback(() => {
|
||||
navigation.navigate('SearchScreen');
|
||||
@ -118,8 +121,12 @@ export const HomeScreen: React.FC = () => {
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.avatarButton} activeOpacity={0.7}>
|
||||
<Text style={styles.avatarEmoji}>🔔</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.avatarButton}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => navigation.navigate('CartScreen')}
|
||||
>
|
||||
<Text style={styles.avatarEmoji}>🧺</Text>
|
||||
<View style={styles.notifDot} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@ -224,8 +231,8 @@ export const HomeScreen: React.FC = () => {
|
||||
{selectedCategory === 'all' ? 'Popular near you' : selectedCategory}
|
||||
</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
{filteredProviders.length} place
|
||||
{filteredProviders.length === 1 ? '' : 's'} delivering to you
|
||||
{filteredProducts.length} product
|
||||
{filteredProducts.length === 1 ? '' : 's'} available
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity activeOpacity={0.7}>
|
||||
@ -233,31 +240,31 @@ export const HomeScreen: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Provider list */}
|
||||
{/* Product list */}
|
||||
<View style={styles.providerList}>
|
||||
{filteredProviders.length === 0 && (
|
||||
<View style={styles.emptyWrap}>
|
||||
<Text style={styles.emptyEmoji}>🍽️</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
No providers in this category yet
|
||||
</Text>
|
||||
{filteredProducts.length === 0 && (
|
||||
<View style={[styles.emptyWrap, { width: '100%' }]}>
|
||||
<Text style={styles.emptyEmoji}>🛍️</Text>
|
||||
<Text style={styles.emptyText}>No products found</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{filteredProviders.map(provider => (
|
||||
<ProviderCard
|
||||
key={provider.id}
|
||||
imageUrl={provider.imageUrl}
|
||||
name={provider.name}
|
||||
rating={provider.rating}
|
||||
deliveryTime={provider.deliveryTime}
|
||||
tag={provider.tag}
|
||||
discountText={provider.discountText}
|
||||
onPress={() =>
|
||||
{filteredProducts.map(product => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
id={product.id}
|
||||
imageUrl={product.imageUrl}
|
||||
name={product.name}
|
||||
price={product.price}
|
||||
compareAtPrice={product.compareAtPrice}
|
||||
brand={product.brand || product.merchant?.name}
|
||||
currency={product.currency === 'INR' ? '₹' : product.currency}
|
||||
onPress={() => {
|
||||
navigation.navigate('ProviderDetailsScreen', {
|
||||
providerId: provider.id,
|
||||
})
|
||||
}
|
||||
providerId: product.id,
|
||||
providerName: product.name,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@ -1 +1,3 @@
|
||||
export * from './homeScreen';
|
||||
export { default as homeReducer } from './reducer';
|
||||
export * from './thunk';
|
||||
|
||||
36
app/features/screens/homeScreen/reducer.ts
Normal file
36
app/features/screens/homeScreen/reducer.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
import { Product, Products } from '@interfaces';
|
||||
import { getAllProductsThunk } from './thunk';
|
||||
|
||||
export interface HomeState {
|
||||
products: Products[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: HomeState = {
|
||||
products: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const homeReducer = createReducer(initialState, builder => {
|
||||
builder
|
||||
.addCase(getAllProductsThunk.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(getAllProductsThunk.fulfilled, (state, action) => {
|
||||
// Handle the API returning { products, meta }
|
||||
state.products =
|
||||
action.payload?.products ||
|
||||
(Array.isArray(action.payload) ? action.payload : []);
|
||||
state.isLoading = false;
|
||||
})
|
||||
.addCase(getAllProductsThunk.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload || 'Failed to fetch products';
|
||||
});
|
||||
});
|
||||
|
||||
export default homeReducer;
|
||||
17
app/features/screens/homeScreen/thunk.ts
Normal file
17
app/features/screens/homeScreen/thunk.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { getProductsApi, GetProductsResponse } from '@api';
|
||||
|
||||
export const getAllProductsThunk = createAsyncThunk<
|
||||
GetProductsResponse,
|
||||
void,
|
||||
{ rejectValue: string }
|
||||
>('home/getAllProducts', async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await getProductsApi();
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(
|
||||
error.response?.data?.message || 'Failed to fetch products',
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -19,3 +19,4 @@ export * from './myOrdersScreen';
|
||||
export * from './offersScreen';
|
||||
export * from './helpSupportScreen';
|
||||
export * from './accountScreen';
|
||||
export * from './writeReviewScreen';
|
||||
|
||||
@ -4,8 +4,8 @@ import { getStyles } from './onboardingCompleteScreen.styles';
|
||||
import { PrimaryButton } from '@components';
|
||||
import { useAppTheme } from '@theme';
|
||||
|
||||
import { completeOnboarding } from '../../../store/commonreducers/auth';
|
||||
import { useAppDispatch } from '@store';
|
||||
import { completeOnboarding } from '@store/commonreducers/auth';
|
||||
|
||||
export const OnboardingCompleteScreen: React.FC = () => {
|
||||
const { colors } = useAppTheme();
|
||||
@ -13,6 +13,9 @@ export const OnboardingCompleteScreen: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleExplore = () => {
|
||||
// Dispatching completeOnboarding sets user.status = 'ACTIVE' in Redux.
|
||||
// RootNavigator watches this value and automatically swaps OnboardingStack
|
||||
// → AppStack. No direct navigation call needed or possible here.
|
||||
dispatch(completeOnboarding());
|
||||
};
|
||||
|
||||
|
||||
@ -1 +1,3 @@
|
||||
export * from './preferencesScreen';
|
||||
export * from './thunk';
|
||||
export * from './reducer';
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
Switch,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
@ -12,60 +14,138 @@ import { getStyles } from './preferencesScreen.styles';
|
||||
import { PrimaryButton } from '@components';
|
||||
import { useAppTheme } from '@theme';
|
||||
import { AuthStackParamList } from '../../../navigation/authStack';
|
||||
import { useAppDispatch, useAppSelector } from '@store';
|
||||
import { fetchCategories, completeOnboard } from './thunk';
|
||||
import { OnboardingStackParamList } from '@navigation/onboardingStack';
|
||||
|
||||
type NavProp = StackNavigationProp<AuthStackParamList, 'PreferencesScreen'>;
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'food', icon: '🍔', label: 'Food' },
|
||||
{ key: 'groceries', icon: '🛒', label: 'Groceries' },
|
||||
{ key: 'pharmacy', icon: '💊', label: 'Pharmacy' },
|
||||
{ key: 'others', icon: '📦', label: 'Others' },
|
||||
];
|
||||
type NavProp = StackNavigationProp<
|
||||
OnboardingStackParamList,
|
||||
'PreferencesScreen'
|
||||
>;
|
||||
|
||||
export const PreferencesScreen: React.FC = () => {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = getStyles(colors);
|
||||
const navigation = useNavigation<NavProp>();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>(['food']);
|
||||
// ─── Redux state ──────────────────────────────────────────────────────────
|
||||
const { categories, isLoading, error, isSubmitting, submitError } =
|
||||
useAppSelector(state => state.preferences);
|
||||
|
||||
const locationData = useAppSelector(state => state.setLocation);
|
||||
const profileData = useAppSelector(state => state.completeProfile);
|
||||
|
||||
// ─── Local state ──────────────────────────────────────────────────────────
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
|
||||
|
||||
const toggleCategory = (key: string) => {
|
||||
setSelectedCategories((prev) =>
|
||||
prev.includes(key)
|
||||
? prev.filter((c) => c !== key)
|
||||
: [...prev, key],
|
||||
// ─── Fetch categories on mount ────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
dispatch(fetchCategories());
|
||||
}, [dispatch]);
|
||||
|
||||
const toggleCategory = (id: string) => {
|
||||
setSelectedCategories(prev =>
|
||||
prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Continue → call onboard API ─────────────────────────────────────────
|
||||
const handleContinue = async () => {
|
||||
if (!locationData.latitude || !locationData.longitude) {
|
||||
Alert.alert('Location missing', 'Please set your location first.');
|
||||
return;
|
||||
}
|
||||
if (!profileData.name || !profileData.email) {
|
||||
Alert.alert('Profile incomplete', 'Please complete your profile first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: profileData.name,
|
||||
email: profileData.email,
|
||||
gender: profileData.gender,
|
||||
latitude: locationData.latitude,
|
||||
longitude: locationData.longitude,
|
||||
addressLabel: profileData.addressLabel,
|
||||
addressLine1: profileData.addressLine1,
|
||||
mapAddress: locationData.mapAddress,
|
||||
houseNumber: profileData.houseNumber,
|
||||
landmark: profileData.landmark,
|
||||
city: profileData.city,
|
||||
state: profileData.state,
|
||||
postalCode: profileData.postalCode,
|
||||
addressPhone: profileData.addressPhone,
|
||||
categoryPreferences: selectedCategories,
|
||||
};
|
||||
|
||||
const result = await dispatch(completeOnboard(payload));
|
||||
|
||||
if (completeOnboard.fulfilled.match(result)) {
|
||||
navigation.navigate('OnboardingCompleteScreen');
|
||||
} else {
|
||||
Alert.alert(
|
||||
'Onboarding failed',
|
||||
(result.payload as string) ?? 'Something went wrong. Please try again.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.title}>Preferences</Text>
|
||||
<Text style={styles.subtitle}>Select your interests</Text>
|
||||
|
||||
<View style={styles.grid}>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<TouchableOpacity
|
||||
key={cat.key}
|
||||
style={[
|
||||
styles.gridItem,
|
||||
selectedCategories.includes(cat.key) && styles.gridItemSelected,
|
||||
]}
|
||||
onPress={() => toggleCategory(cat.key)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.gridIcon}>{cat.icon}</Text>
|
||||
<Text
|
||||
{isLoading && (
|
||||
<ActivityIndicator
|
||||
size="large"
|
||||
color={colors.primary}
|
||||
style={{ marginVertical: 32 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!error && (
|
||||
<Text
|
||||
style={{
|
||||
color: colors.error ?? 'red',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!submitError && (
|
||||
<Text
|
||||
style={{
|
||||
color: colors.error ?? 'red',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{submitError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<View style={styles.grid}>
|
||||
{(categories || []).map(cat => (
|
||||
<TouchableOpacity
|
||||
key={cat.id}
|
||||
style={[
|
||||
styles.gridLabel,
|
||||
selectedCategories.includes(cat.key) && styles.gridLabelSelected,
|
||||
styles.gridItem,
|
||||
selectedCategories.includes(cat.id) && styles.gridItemSelected,
|
||||
]}
|
||||
onPress={() => toggleCategory(cat.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{cat.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<Text style={styles.gridLabel}>{cat.name}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.toggleRow}>
|
||||
<View>
|
||||
@ -81,8 +161,9 @@ export const PreferencesScreen: React.FC = () => {
|
||||
</View>
|
||||
|
||||
<PrimaryButton
|
||||
title="Continue"
|
||||
onPress={() => navigation.navigate('OnboardingCompleteScreen')}
|
||||
title={isSubmitting ? 'Please wait…' : 'Continue'}
|
||||
onPress={handleContinue}
|
||||
disabled={isSubmitting}
|
||||
style={{ marginTop: 32 }}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
59
app/features/screens/preferencesScreen/reducer.ts
Normal file
59
app/features/screens/preferencesScreen/reducer.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
import { Category } from '@interfaces';
|
||||
import { fetchCategories, completeOnboard } from './thunk';
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PreferencesState {
|
||||
categories: Category[];
|
||||
selectedCategories: string[];
|
||||
isLoading: boolean;
|
||||
isSubmitting: boolean;
|
||||
error: string | null;
|
||||
submitError: string | null;
|
||||
}
|
||||
|
||||
const initialState: PreferencesState = {
|
||||
categories: [],
|
||||
selectedCategories: [],
|
||||
isLoading: false,
|
||||
isSubmitting: false,
|
||||
error: null,
|
||||
submitError: null,
|
||||
};
|
||||
|
||||
// ─── Reducer ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const preferencesReducer = createReducer(initialState, builder => {
|
||||
builder
|
||||
// Fetch categories
|
||||
.addCase(fetchCategories.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchCategories.fulfilled, (state, action) => {
|
||||
state.categories = action.payload;
|
||||
state.isLoading = false;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchCategories.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
|
||||
// Complete onboard
|
||||
.addCase(completeOnboard.pending, state => {
|
||||
state.isSubmitting = true;
|
||||
state.submitError = null;
|
||||
})
|
||||
.addCase(completeOnboard.fulfilled, state => {
|
||||
state.isSubmitting = false;
|
||||
state.submitError = null;
|
||||
})
|
||||
.addCase(completeOnboard.rejected, (state, action) => {
|
||||
state.isSubmitting = false;
|
||||
state.submitError = action.payload as string;
|
||||
});
|
||||
});
|
||||
|
||||
export default preferencesReducer;
|
||||
33
app/features/screens/preferencesScreen/thunk.ts
Normal file
33
app/features/screens/preferencesScreen/thunk.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { getCatagories, onBoardComplete } from '../../../api/onboardApi';
|
||||
import { onBoardPayload } from '@interfaces/onboard';
|
||||
|
||||
// ─── Fetch Categories ─────────────────────────────────────────────────────────
|
||||
|
||||
export const fetchCategories = createAsyncThunk(
|
||||
'preferences/fetchCategories',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await getCatagories();
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to fetch categories';
|
||||
return rejectWithValue(message);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const completeOnboard = createAsyncThunk(
|
||||
'preferences/completeOnboard',
|
||||
async (payload: onBoardPayload, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await onBoardComplete(payload);
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to complete onboard';
|
||||
return rejectWithValue(message);
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -1 +1,3 @@
|
||||
export * from './providerDetailsScreen';
|
||||
export { default as providerDetailsReducer } from './reducer';
|
||||
export * from './thunk';
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { StyleSheet, Dimensions, Platform } from 'react-native';
|
||||
import { typography } from '@theme';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const HERO_HEIGHT = 220;
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const HERO_HEIGHT = height * 0.45;
|
||||
|
||||
export const getStyles = (colors: any) =>
|
||||
StyleSheet.create({
|
||||
@ -14,36 +14,49 @@ export const getStyles = (colors: any) =>
|
||||
flex: 1,
|
||||
},
|
||||
contentBody: {
|
||||
paddingBottom: 32,
|
||||
paddingBottom: 100, // Space for sticky bottom bar
|
||||
},
|
||||
|
||||
// ---------- Hero ----------
|
||||
heroWrap: {
|
||||
// ---------- Image Carousel ----------
|
||||
carouselWrap: {
|
||||
width,
|
||||
height: HERO_HEIGHT,
|
||||
backgroundColor: colors.inputBg,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
heroImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
carouselImage: {
|
||||
width,
|
||||
height: HERO_HEIGHT,
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
heroFallback: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
paginationDots: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
dot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: 3,
|
||||
},
|
||||
dotActive: {
|
||||
width: 14,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
fallbackIconWrap: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.inputBg,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
heroFallbackEmoji: {
|
||||
fontSize: 56,
|
||||
},
|
||||
heroOverlay: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 90,
|
||||
backgroundColor: 'rgba(0,0,0,0.28)',
|
||||
fallbackIcon: {
|
||||
fontSize: 60,
|
||||
},
|
||||
|
||||
// Floating top icon row (back / share / favorite)
|
||||
@ -55,40 +68,42 @@ export const getStyles = (colors: any) =>
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
iconButton: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 19,
|
||||
backgroundColor: 'rgba(255,255,255,0.92)',
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 21,
|
||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOffset: { width: 0, height: 3 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 4,
|
||||
shadowRadius: 5,
|
||||
},
|
||||
android: { elevation: 3 },
|
||||
android: { elevation: 4 },
|
||||
}),
|
||||
},
|
||||
iconButtonText: {
|
||||
fontSize: 16,
|
||||
fontSize: 18,
|
||||
color: colors.text,
|
||||
},
|
||||
iconButtonGroup: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
|
||||
// ---------- Info card ----------
|
||||
infoCard: {
|
||||
// ---------- Product Info Box ----------
|
||||
infoBox: {
|
||||
backgroundColor: colors.background,
|
||||
marginTop: -20,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
paddingTop: 20,
|
||||
paddingTop: 24,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 16,
|
||||
paddingBottom: 20,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
@ -99,247 +114,389 @@ export const getStyles = (colors: any) =>
|
||||
android: { elevation: 4 },
|
||||
}),
|
||||
},
|
||||
infoTopRow: {
|
||||
brandRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
heroName: {
|
||||
fontSize: typography.fontSize.xl,
|
||||
brandText: {
|
||||
fontSize: typography.fontSize.xs,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
color: colors.textSecondary,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
ratingBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 5,
|
||||
backgroundColor: colors.primaryMuted ?? '#E9F7EF',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
ratingBadgeText: {
|
||||
fontSize: typography.fontSize.sm,
|
||||
fontSize: typography.fontSize.xs,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: '#FFFFFF',
|
||||
marginLeft: 3,
|
||||
color: colors.primary,
|
||||
marginLeft: 4,
|
||||
},
|
||||
cuisineText: {
|
||||
titleText: {
|
||||
fontSize: 24,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.text,
|
||||
marginBottom: 12,
|
||||
lineHeight: 32,
|
||||
},
|
||||
priceRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
marginBottom: 8,
|
||||
},
|
||||
priceText: {
|
||||
fontSize: 28,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.text,
|
||||
marginRight: 10,
|
||||
},
|
||||
comparePriceText: {
|
||||
fontSize: 16,
|
||||
color: colors.textSecondary,
|
||||
textDecorationLine: 'line-through',
|
||||
marginBottom: 4,
|
||||
},
|
||||
discountBadgeWrap: {
|
||||
marginLeft: 10,
|
||||
marginBottom: 6,
|
||||
backgroundColor: colors.error ?? '#E53935',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 6,
|
||||
},
|
||||
discountBadgeText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: typography.fontSize.xs,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
},
|
||||
taxText: {
|
||||
fontSize: typography.fontSize.sm,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 4,
|
||||
},
|
||||
|
||||
metaRow: {
|
||||
// ---------- Section Divider ----------
|
||||
sectionDivider: {
|
||||
height: 8,
|
||||
backgroundColor: colors.surface ?? '#F5F6F8',
|
||||
},
|
||||
|
||||
// ---------- Details Section ----------
|
||||
detailsSection: {
|
||||
padding: 20,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.text,
|
||||
marginBottom: 12,
|
||||
},
|
||||
descriptionText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 20,
|
||||
},
|
||||
metaGrid: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
flexWrap: 'wrap',
|
||||
backgroundColor: colors.surface ?? '#F5F6F8',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
},
|
||||
metaItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
width: '50%',
|
||||
marginBottom: 12,
|
||||
},
|
||||
metaIcon: {
|
||||
fontSize: 13,
|
||||
marginRight: 4,
|
||||
},
|
||||
metaText: {
|
||||
fontSize: typography.fontSize.sm,
|
||||
color: colors.text,
|
||||
fontWeight: typography.fontWeight.medium,
|
||||
},
|
||||
metaDivider: {
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: 1.5,
|
||||
backgroundColor: colors.textSecondary,
|
||||
marginHorizontal: 10,
|
||||
opacity: 0.5,
|
||||
},
|
||||
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 10,
|
||||
},
|
||||
statusDot: {
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 3.5,
|
||||
backgroundColor: colors.primary,
|
||||
marginRight: 6,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: typography.fontSize.xs,
|
||||
color: colors.primary,
|
||||
fontWeight: typography.fontWeight.semibold,
|
||||
},
|
||||
statusTextMuted: {
|
||||
fontSize: typography.fontSize.xs,
|
||||
metaLabel: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
metaValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: typography.fontWeight.semibold,
|
||||
color: colors.text,
|
||||
},
|
||||
|
||||
// ---------- Offers ----------
|
||||
offersSection: {
|
||||
marginTop: 18,
|
||||
},
|
||||
offersScrollContent: {
|
||||
// ---------- Sticky Bottom Bar ----------
|
||||
bottomBarWrap: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: colors.background,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
paddingBottom: Platform.OS === 'ios' ? 34 : 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border ?? '#ECECEC',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
offerChip: {
|
||||
qtySelector: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primaryMuted ?? '#E9F7EF',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
marginRight: 10,
|
||||
},
|
||||
offerIcon: {
|
||||
fontSize: 15,
|
||||
marginRight: 6,
|
||||
},
|
||||
offerText: {
|
||||
fontSize: typography.fontSize.xs,
|
||||
fontWeight: typography.fontWeight.semibold,
|
||||
color: colors.text,
|
||||
},
|
||||
|
||||
// ---------- Divider ----------
|
||||
sectionDivider: {
|
||||
height: 8,
|
||||
backgroundColor: colors.inputBg,
|
||||
marginTop: 18,
|
||||
},
|
||||
|
||||
// ---------- Menu search ----------
|
||||
menuHeaderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 18,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
menuTitle: {
|
||||
fontSize: typography.fontSize.lg,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.text,
|
||||
},
|
||||
menuCount: {
|
||||
fontSize: typography.fontSize.sm,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
|
||||
// ---------- Category tabs ----------
|
||||
categoryRow: {
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
flexGrow: 0,
|
||||
},
|
||||
categoryTab: {
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 9,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.border,
|
||||
marginRight: 10,
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: 12,
|
||||
marginRight: 16,
|
||||
},
|
||||
categoryTabActive: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primary,
|
||||
qtyBtn: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
categoryTabText: {
|
||||
fontSize: typography.fontSize.sm,
|
||||
color: colors.textSecondary,
|
||||
qtyBtnText: {
|
||||
fontSize: 20,
|
||||
color: colors.primary,
|
||||
fontWeight: typography.fontWeight.medium,
|
||||
},
|
||||
categoryTabTextActive: {
|
||||
color: '#FFFFFF',
|
||||
qtyValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.text,
|
||||
width: 30,
|
||||
textAlign: 'center',
|
||||
},
|
||||
addBtn: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.primary,
|
||||
height: 48,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: colors.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
android: { elevation: 6 },
|
||||
}),
|
||||
},
|
||||
addBtnText: {
|
||||
fontSize: 16,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
|
||||
// ---------- Menu list ----------
|
||||
menuList: {
|
||||
paddingHorizontal: 20,
|
||||
// Skeleton loaders
|
||||
skeletonTitle: {
|
||||
width: '80%',
|
||||
height: 32,
|
||||
backgroundColor: colors.surface ?? '#EEEEEE',
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
},
|
||||
emptyMenuWrap: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 40,
|
||||
skeletonPrice: {
|
||||
width: '40%',
|
||||
height: 28,
|
||||
backgroundColor: colors.surface ?? '#EEEEEE',
|
||||
borderRadius: 6,
|
||||
marginBottom: 10,
|
||||
},
|
||||
emptyMenuEmoji: {
|
||||
fontSize: 34,
|
||||
skeletonText: {
|
||||
width: '100%',
|
||||
height: 16,
|
||||
backgroundColor: colors.surface ?? '#EEEEEE',
|
||||
borderRadius: 4,
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyMenuText: {
|
||||
color: colors.textSecondary,
|
||||
|
||||
// ================= Ratings & Reviews =================
|
||||
ratingsSection: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 24,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
ratingsHeaderRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
seeAllText: {
|
||||
fontSize: typography.fontSize.sm,
|
||||
fontWeight: typography.fontWeight.semibold,
|
||||
color: colors.primary,
|
||||
},
|
||||
|
||||
// ---------- Cart strip ----------
|
||||
cartStripWrap: {
|
||||
position: 'absolute',
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: Platform.OS === 'ios' ? 28 : 16,
|
||||
},
|
||||
cartStrip: {
|
||||
// Summary card (big number + stars + optional breakdown bars)
|
||||
ratingsSummaryCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 14,
|
||||
backgroundColor: colors.surface ?? '#F5F6F8',
|
||||
borderRadius: 16,
|
||||
padding: 18,
|
||||
marginBottom: 16,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 6,
|
||||
},
|
||||
android: { elevation: 8 },
|
||||
android: { elevation: 1 },
|
||||
}),
|
||||
},
|
||||
cartStripLeft: {
|
||||
ratingsSummaryLeft: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 76,
|
||||
},
|
||||
ratingsSummaryLeftWithDivider: {
|
||||
marginRight: 20,
|
||||
paddingRight: 20,
|
||||
borderRightWidth: StyleSheet.hairlineWidth,
|
||||
borderRightColor: colors.border ?? '#E0E0E0',
|
||||
},
|
||||
avgRatingNumber: {
|
||||
fontSize: 34,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.text,
|
||||
marginBottom: 4,
|
||||
},
|
||||
starsRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 6,
|
||||
},
|
||||
starIcon: {
|
||||
marginRight: 1,
|
||||
},
|
||||
totalRatingsText: {
|
||||
fontSize: typography.fontSize.xs,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
ratingsBarsWrap: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
barRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
cartBagIcon: {
|
||||
fontSize: 18,
|
||||
barLabel: {
|
||||
width: 26,
|
||||
fontSize: typography.fontSize.xs,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'right',
|
||||
marginRight: 8,
|
||||
},
|
||||
cartStripTextWrap: {},
|
||||
cartStripCount: {
|
||||
fontSize: typography.fontSize.xs,
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
barTrack: {
|
||||
flex: 1,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: colors.border ?? '#E5E5EA',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cartStripPrice: {
|
||||
fontSize: typography.fontSize.md,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: '#FFFFFF',
|
||||
barFill: {
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
backgroundColor: colors.warning ?? '#F5A623',
|
||||
},
|
||||
viewCartButton: {
|
||||
|
||||
// Empty state — styled as a card with a CTA, not bare text
|
||||
emptyRatingsCard: {
|
||||
backgroundColor: colors.surface ?? '#F5F6F8',
|
||||
borderRadius: 16,
|
||||
paddingVertical: 28,
|
||||
paddingHorizontal: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyRatingsIcon: {
|
||||
fontSize: 30,
|
||||
marginBottom: 10,
|
||||
},
|
||||
emptyRatingsTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: typography.fontWeight.semibold,
|
||||
color: colors.text,
|
||||
marginBottom: 4,
|
||||
},
|
||||
emptyRatingsSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
rateProductBtn: {
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.primary,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
rateProductBtnText: {
|
||||
fontSize: 14,
|
||||
fontWeight: typography.fontWeight.semibold,
|
||||
color: colors.primary,
|
||||
},
|
||||
|
||||
// Recent reviews list
|
||||
reviewsList: {
|
||||
marginTop: 4,
|
||||
},
|
||||
reviewCard: {
|
||||
backgroundColor: colors.background,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border ?? '#ECECEC',
|
||||
borderRadius: 14,
|
||||
padding: 14,
|
||||
marginBottom: 12,
|
||||
},
|
||||
reviewCardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 9,
|
||||
borderRadius: 10,
|
||||
marginBottom: 10,
|
||||
},
|
||||
viewCartText: {
|
||||
fontSize: typography.fontSize.sm,
|
||||
reviewerAvatar: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.primaryMuted ?? '#E9F7EF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 10,
|
||||
},
|
||||
reviewerAvatarText: {
|
||||
color: colors.primary,
|
||||
fontSize: 15,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.primary,
|
||||
marginRight: 4,
|
||||
},
|
||||
viewCartArrow: {
|
||||
reviewerName: {
|
||||
fontSize: 13,
|
||||
color: colors.primary,
|
||||
fontWeight: typography.fontWeight.semibold,
|
||||
color: colors.text,
|
||||
marginBottom: 3,
|
||||
},
|
||||
reviewDate: {
|
||||
fontSize: 11,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
reviewComment: {
|
||||
fontSize: 13,
|
||||
lineHeight: 19,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
|
||||
skeletonBlock: {
|
||||
height: 90,
|
||||
borderRadius: 16,
|
||||
backgroundColor: colors.surface ?? '#EEEEEE',
|
||||
marginBottom: 12,
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,25 +1,26 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Image,
|
||||
ImageBackground,
|
||||
Dimensions,
|
||||
NativeSyntheticEvent,
|
||||
NativeScrollEvent,
|
||||
} from 'react-native';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { getStyles } from './providerDetailsScreen.styles';
|
||||
import { CatalogItemRow } from '@components';
|
||||
import { useAppTheme } from '@theme';
|
||||
import { addItem } from '../../../store/commonreducers/cart';
|
||||
import {
|
||||
getProviderCatalogApi,
|
||||
getProvidersApi,
|
||||
} from '../../../api/deliveryApi';
|
||||
import { CatalogItem, Provider } from '../../../interfaces';
|
||||
addToCartThunk,
|
||||
updateCartItemThunk,
|
||||
} from '../../../store/commonreducers/cart';
|
||||
import { AppStackParamList } from '../../../navigation/appStack';
|
||||
import { useAppDispatch, useAppSelector } from '@store';
|
||||
import { getProductDetailsThunk } from './thunk';
|
||||
import { getDiscountPercentage, formatPrice } from '@components';
|
||||
|
||||
type ProviderDetailsNavProp = StackNavigationProp<
|
||||
AppStackParamList,
|
||||
@ -30,215 +31,25 @@ type ProviderDetailsRouteProp = RouteProp<
|
||||
'ProviderDetailsScreen'
|
||||
>;
|
||||
|
||||
const FALLBACK_CATEGORIES = ['Pizza', 'Sides', 'Beverages'];
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
const OFFERS = [
|
||||
{ key: 'o1', icon: '🏷️', label: '50% OFF up to ₹100' },
|
||||
{ key: 'o2', icon: '🚚', label: 'Free delivery above ₹299' },
|
||||
{ key: 'o3', icon: '🎁', label: 'Buy 1 Get 1 on combos' },
|
||||
];
|
||||
const BASE_URL = 'https://2e1b-202-8-116-13.ngrok-free.app';
|
||||
const getFullUrl = (url?: string) => {
|
||||
if (!url) return '';
|
||||
return url.startsWith('/') ? `${BASE_URL}${url}` : url;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Presentational subcomponents
|
||||
// Kept local to this screen since none are reused elsewhere yet. Promote to
|
||||
// @components if a second screen needs them.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HeroSectionProps {
|
||||
styles: ReturnType<typeof getStyles>;
|
||||
imageUrl?: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const HeroSection: React.FC<HeroSectionProps> = ({
|
||||
styles,
|
||||
imageUrl,
|
||||
onBack,
|
||||
}) => (
|
||||
<View style={styles.heroWrap}>
|
||||
{imageUrl ? (
|
||||
<ImageBackground source={{ uri: imageUrl }} style={styles.heroImage}>
|
||||
<View style={styles.heroOverlay} />
|
||||
</ImageBackground>
|
||||
) : (
|
||||
<View style={styles.heroFallback}>
|
||||
<Text style={styles.heroFallbackEmoji}>🍽️</Text>
|
||||
<View style={styles.heroOverlay} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.topIconRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
activeOpacity={0.75}
|
||||
onPress={onBack}
|
||||
>
|
||||
<Text style={styles.iconButtonText}>←</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.iconButtonGroup}>
|
||||
<TouchableOpacity
|
||||
style={[styles.iconButton, { marginRight: 10 }]}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<Text style={styles.iconButtonText}>🔗</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.iconButton} activeOpacity={0.75}>
|
||||
<Text style={styles.iconButtonText}>🤍</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
interface InfoCardProps {
|
||||
styles: ReturnType<typeof getStyles>;
|
||||
name: string;
|
||||
// TODO: move these into your shared Product type once the backend
|
||||
// response includes them.
|
||||
interface ProductReview {
|
||||
id: string;
|
||||
userName?: string;
|
||||
rating: number;
|
||||
deliveryTime: string;
|
||||
tag: string;
|
||||
comment?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
const InfoCard: React.FC<InfoCardProps> = ({
|
||||
styles,
|
||||
name,
|
||||
rating,
|
||||
deliveryTime,
|
||||
tag,
|
||||
}) => (
|
||||
<View style={styles.infoCard}>
|
||||
<View style={styles.infoTopRow}>
|
||||
<Text style={styles.heroName} numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
<View style={styles.ratingBadge}>
|
||||
<Text style={{ fontSize: 11 }}>⭐</Text>
|
||||
<Text style={styles.ratingBadgeText}>{rating.toFixed(1)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.cuisineText}>{tag} • Multi-cuisine</Text>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaIcon}>🕐</Text>
|
||||
<Text style={styles.metaText}>{deliveryTime}</Text>
|
||||
</View>
|
||||
<View style={styles.metaDivider} />
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaIcon}>📍</Text>
|
||||
<Text style={styles.metaText}>2.4 km away</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
<View style={styles.statusDot} />
|
||||
<Text style={styles.statusText}>Open now</Text>
|
||||
<Text style={styles.statusTextMuted}> • Closes 11:30 PM</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
interface OffersRowProps {
|
||||
styles: ReturnType<typeof getStyles>;
|
||||
}
|
||||
|
||||
const OffersRow: React.FC<OffersRowProps> = ({ styles }) => (
|
||||
<View style={styles.offersSection}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.offersScrollContent}
|
||||
>
|
||||
{OFFERS.map(offer => (
|
||||
<View key={offer.key} style={styles.offerChip}>
|
||||
<Text style={styles.offerIcon}>{offer.icon}</Text>
|
||||
<Text style={styles.offerText}>{offer.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
interface CategoryTabsProps {
|
||||
styles: ReturnType<typeof getStyles>;
|
||||
categories: string[];
|
||||
activeCategory: string;
|
||||
onSelect: (category: string) => void;
|
||||
}
|
||||
|
||||
const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||
styles,
|
||||
categories,
|
||||
activeCategory,
|
||||
onSelect,
|
||||
}) => (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.categoryRow}
|
||||
>
|
||||
{categories.map(cat => {
|
||||
const isActive = activeCategory === cat;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={cat}
|
||||
style={[styles.categoryTab, isActive && styles.categoryTabActive]}
|
||||
onPress={() => onSelect(cat)}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.categoryTabText,
|
||||
isActive && styles.categoryTabTextActive,
|
||||
]}
|
||||
>
|
||||
{cat}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
interface CartStripProps {
|
||||
styles: ReturnType<typeof getStyles>;
|
||||
totalItems: number;
|
||||
totalPrice: number;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const CartStrip: React.FC<CartStripProps> = ({
|
||||
styles,
|
||||
totalItems,
|
||||
totalPrice,
|
||||
onPress,
|
||||
}) => (
|
||||
<View style={styles.cartStripWrap}>
|
||||
<TouchableOpacity
|
||||
style={styles.cartStrip}
|
||||
activeOpacity={0.85}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.cartStripLeft}>
|
||||
<Text style={styles.cartBagIcon}>🛍️</Text>
|
||||
<View style={styles.cartStripTextWrap}>
|
||||
<Text style={styles.cartStripCount}>
|
||||
{totalItems} item{totalItems === 1 ? '' : 's'}
|
||||
</Text>
|
||||
<Text style={styles.cartStripPrice}>₹{totalPrice}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.viewCartButton}>
|
||||
<Text style={styles.viewCartText}>View Cart</Text>
|
||||
<Text style={styles.viewCartArrow}>→</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Screen
|
||||
// ---------------------------------------------------------------------------
|
||||
type RatingBreakdown = Partial<Record<1 | 2 | 3 | 4 | 5, number>>;
|
||||
|
||||
export const ProviderDetailsScreen: React.FC = () => {
|
||||
const { colors } = useAppTheme();
|
||||
@ -246,65 +57,314 @@ export const ProviderDetailsScreen: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigation = useNavigation<ProviderDetailsNavProp>();
|
||||
const route = useRoute<ProviderDetailsRouteProp>();
|
||||
const { providerId, providerName } = route.params;
|
||||
|
||||
// Notice we still use providerId param name to avoid breaking routing everywhere
|
||||
const { providerId } = route.params;
|
||||
|
||||
const cartItems = useAppSelector(state => state.cart.items);
|
||||
const { product, isLoading } = useAppSelector(state => state.providerDetails);
|
||||
|
||||
const [provider, setProvider] = useState<Provider | null>(null);
|
||||
const [catalog, setCatalog] = useState<CatalogItem[]>([]);
|
||||
const [activeCategory, setActiveCategory] = useState(FALLBACK_CATEGORIES[0]);
|
||||
// Find this product in the cart (if already added)
|
||||
const cartItem = product
|
||||
? cartItems.find(i => i.productId === product.id)
|
||||
: undefined;
|
||||
const isInCart = !!cartItem;
|
||||
|
||||
const resolvedProviderId = providerId || 'p1';
|
||||
const resolvedProviderName = providerName || 'Pizza Planet';
|
||||
const [activeImageIndex, setActiveImageIndex] = useState(0);
|
||||
const [localQty, setLocalQty] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
getProviderCatalogApi(resolvedProviderId).then(setCatalog);
|
||||
}, [resolvedProviderId]);
|
||||
|
||||
useEffect(() => {
|
||||
getProvidersApi().then(providers => {
|
||||
const match = providers.find(p => p.id === resolvedProviderId);
|
||||
if (match) setProvider(match);
|
||||
});
|
||||
}, [resolvedProviderId]);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const fromCatalog = Array.from(new Set(catalog.map(item => item.category)));
|
||||
return fromCatalog.length > 0 ? fromCatalog : FALLBACK_CATEGORIES;
|
||||
}, [catalog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!categories.includes(activeCategory)) {
|
||||
setActiveCategory(categories[0]);
|
||||
if (providerId) {
|
||||
dispatch(getProductDetailsThunk(providerId));
|
||||
}
|
||||
}, [categories, activeCategory]);
|
||||
}, [providerId, dispatch]);
|
||||
|
||||
const filteredItems = useMemo(
|
||||
() => catalog.filter(item => item.category === activeCategory),
|
||||
[catalog, activeCategory],
|
||||
);
|
||||
// Sync localQty with cart quantity when cart loads or product changes
|
||||
useEffect(() => {
|
||||
if (cartItem) {
|
||||
setLocalQty(cartItem.quantity);
|
||||
} else {
|
||||
setLocalQty(0);
|
||||
}
|
||||
}, [cartItem?.quantity, cartItem?.productId]);
|
||||
|
||||
const getItemQuantity = (itemId: string) => {
|
||||
const match = cartItems.find(i => i.item.id === itemId);
|
||||
return match ? match.quantity : 0;
|
||||
const handleScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const slide = Math.round(e.nativeEvent.contentOffset.x / width);
|
||||
setActiveImageIndex(slide);
|
||||
};
|
||||
|
||||
const totalItems = cartItems.reduce((sum, i) => sum + i.quantity, 0);
|
||||
const totalPrice = cartItems.reduce(
|
||||
(sum, i) => sum + i.item.price * i.quantity,
|
||||
0,
|
||||
);
|
||||
|
||||
const handleAddItem = (item: CatalogItem) => {
|
||||
const handleAddToCart = () => {
|
||||
if (!product) return;
|
||||
dispatch(
|
||||
addItem({
|
||||
providerId: resolvedProviderId,
|
||||
providerName: resolvedProviderName,
|
||||
item,
|
||||
addToCartThunk({
|
||||
productId: product.id,
|
||||
quantity: Math.max(1, localQty), // always add at least 1
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleIncrement = () => {
|
||||
if (!product) return;
|
||||
const newQty = localQty + 1;
|
||||
setLocalQty(newQty);
|
||||
dispatch(updateCartItemThunk({ productId: product.id, quantity: newQty }));
|
||||
};
|
||||
|
||||
const handleDecrement = () => {
|
||||
if (!product || localQty <= 1) return;
|
||||
const newQty = localQty - 1;
|
||||
setLocalQty(newQty);
|
||||
dispatch(updateCartItemThunk({ productId: product.id, quantity: newQty }));
|
||||
};
|
||||
|
||||
const handleRateProduct = () => {
|
||||
navigation.navigate('WriteReviewScreen', { productId: product?.id ?? '' });
|
||||
};
|
||||
|
||||
const renderStars = (value: number, size = 14) => {
|
||||
const rounded = Math.round(value);
|
||||
const starColor = (colors as any).warning ?? '#F5A623';
|
||||
return (
|
||||
<View style={styles.starsRow}>
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<Text
|
||||
key={i}
|
||||
style={[styles.starIcon, { fontSize: size, color: starColor }]}
|
||||
>
|
||||
{i <= rounded ? '★' : '☆'}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderImages = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.carouselWrap}>
|
||||
<View
|
||||
style={[
|
||||
styles.fallbackIconWrap,
|
||||
{ backgroundColor: (colors as any).surface ?? colors.cardBg },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const images = product?.media?.length
|
||||
? product.media
|
||||
: [
|
||||
{
|
||||
id: 'fallback',
|
||||
url: product?.imageUrl || '',
|
||||
mediaType: 'IMAGE',
|
||||
sortOrder: 0,
|
||||
productId: '',
|
||||
createdAt: '',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={styles.carouselWrap}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onMomentumScrollEnd={handleScroll}
|
||||
>
|
||||
{images.map(img => (
|
||||
<Image
|
||||
key={img.id}
|
||||
source={{ uri: getFullUrl(img.url) }}
|
||||
style={styles.carouselImage}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
{images.length > 1 && (
|
||||
<View style={styles.paginationDots}>
|
||||
{images.map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[styles.dot, i === activeImageIndex && styles.dotActive]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderProductInfo = () => {
|
||||
if (isLoading || !product) {
|
||||
return (
|
||||
<View style={styles.infoBox}>
|
||||
<View style={styles.skeletonTitle} />
|
||||
<View style={styles.skeletonPrice} />
|
||||
<View style={styles.skeletonText} />
|
||||
<View style={styles.skeletonText} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const discount = getDiscountPercentage(
|
||||
product.price,
|
||||
product.compareAtPrice,
|
||||
);
|
||||
const currency = product.currency === 'INR' ? '₹' : product.currency;
|
||||
|
||||
return (
|
||||
<View style={styles.infoBox}>
|
||||
<View style={styles.brandRow}>
|
||||
<Text style={styles.brandText}>
|
||||
{product.brand || product.merchant?.name || 'BRAND'}
|
||||
</Text>
|
||||
<View style={styles.ratingBadge}>
|
||||
<Text style={{ fontSize: 11 }}>⭐</Text>
|
||||
<Text style={styles.ratingBadgeText}>
|
||||
{parseFloat(product.avgRating).toFixed(1)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.titleText}>{product.name}</Text>
|
||||
|
||||
<View style={styles.priceRow}>
|
||||
<Text style={styles.priceText}>
|
||||
{formatPrice(product.price, currency)}
|
||||
</Text>
|
||||
{product.compareAtPrice &&
|
||||
parseFloat(product.compareAtPrice) > parseFloat(product.price) && (
|
||||
<Text style={styles.comparePriceText}>
|
||||
{formatPrice(product.compareAtPrice, currency)}
|
||||
</Text>
|
||||
)}
|
||||
{discount && (
|
||||
<View style={styles.discountBadgeWrap}>
|
||||
<Text style={styles.discountBadgeText}>{discount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.taxText}>Inclusive of all taxes</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRatingsSection = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.ratingsSection}>
|
||||
<View style={styles.skeletonBlock} />
|
||||
<View style={[styles.skeletonBlock, { height: 80 }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const avgRating = product?.avgRating ? parseFloat(product.avgRating) : 0;
|
||||
const reviews: ProductReview[] = (product as any)?.reviews ?? [];
|
||||
const totalRatings: number =
|
||||
(product as any)?.ratingsCount ?? reviews.length ?? 0;
|
||||
const breakdown: RatingBreakdown | undefined = (product as any)
|
||||
?.ratingBreakdown;
|
||||
const hasRatings = totalRatings > 0;
|
||||
|
||||
return (
|
||||
<View style={styles.ratingsSection}>
|
||||
<View style={styles.ratingsHeaderRow}>
|
||||
<Text style={styles.sectionTitle}>Ratings & Reviews</Text>
|
||||
{reviews.length > 3 && (
|
||||
<TouchableOpacity activeOpacity={0.7}>
|
||||
<Text style={styles.seeAllText}>See all</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{hasRatings ? (
|
||||
<View style={styles.ratingsSummaryCard}>
|
||||
<View
|
||||
style={[
|
||||
styles.ratingsSummaryLeft,
|
||||
breakdown && styles.ratingsSummaryLeftWithDivider,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.avgRatingNumber}>{avgRating.toFixed(1)}</Text>
|
||||
{renderStars(avgRating, 16)}
|
||||
<Text style={styles.totalRatingsText}>
|
||||
{totalRatings} {totalRatings === 1 ? 'rating' : 'ratings'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{breakdown && (
|
||||
<View style={styles.ratingsBarsWrap}>
|
||||
{[5, 4, 3, 2, 1].map(star => {
|
||||
const count = breakdown[star as 1 | 2 | 3 | 4 | 5] ?? 0;
|
||||
const pct =
|
||||
totalRatings > 0 ? (count / totalRatings) * 100 : 0;
|
||||
return (
|
||||
<View key={star} style={styles.barRow}>
|
||||
<Text style={styles.barLabel}>{star}★</Text>
|
||||
<View style={styles.barTrack}>
|
||||
<View style={[styles.barFill, { width: `${pct}%` }]} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyRatingsCard}>
|
||||
<Text style={styles.emptyRatingsIcon}>⭐</Text>
|
||||
<Text style={styles.emptyRatingsTitle}>No ratings yet</Text>
|
||||
<Text style={styles.emptyRatingsSubtitle}>
|
||||
Be the first to share what you think of this product.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.rateProductBtn}
|
||||
activeOpacity={0.8}
|
||||
onPress={handleRateProduct}
|
||||
>
|
||||
<Text style={styles.rateProductBtnText}>Rate this product</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{reviews.length > 0 && (
|
||||
<View style={styles.reviewsList}>
|
||||
{reviews.slice(0, 3).map(review => (
|
||||
<View key={review.id} style={styles.reviewCard}>
|
||||
<View style={styles.reviewCardHeader}>
|
||||
<View style={styles.reviewerAvatar}>
|
||||
<Text style={styles.reviewerAvatarText}>
|
||||
{(review.userName || 'U').charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.reviewerName}>
|
||||
{review.userName || 'Anonymous'}
|
||||
</Text>
|
||||
{renderStars(review.rating, 12)}
|
||||
</View>
|
||||
<Text style={styles.reviewDate}>
|
||||
{review.createdAt
|
||||
? new Date(review.createdAt).toLocaleDateString('en-IN', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
: ''}
|
||||
</Text>
|
||||
</View>
|
||||
{!!review.comment && (
|
||||
<Text style={styles.reviewComment}>{review.comment}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
@ -312,69 +372,147 @@ export const ProviderDetailsScreen: React.FC = () => {
|
||||
contentContainerStyle={styles.contentBody}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<HeroSection
|
||||
styles={styles}
|
||||
imageUrl={provider?.imageUrl}
|
||||
onBack={() => navigation.goBack()}
|
||||
/>
|
||||
<View style={{ position: 'relative' }}>
|
||||
{renderImages()}
|
||||
|
||||
<InfoCard
|
||||
styles={styles}
|
||||
name={resolvedProviderName}
|
||||
rating={provider?.rating ?? 4.5}
|
||||
deliveryTime={provider?.deliveryTime ?? '25 min'}
|
||||
tag={provider?.tag ?? 'Italian'}
|
||||
/>
|
||||
<View style={styles.topIconRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<Text style={styles.iconButtonText}>←</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.iconButtonGroup}>
|
||||
<TouchableOpacity
|
||||
style={[styles.iconButton, { marginRight: 12 }]}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.iconButtonText}>🔗</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.iconButton} activeOpacity={0.8}>
|
||||
<Text style={{ fontSize: 17 }}>🤍</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<OffersRow styles={styles} />
|
||||
{renderProductInfo()}
|
||||
|
||||
<View style={styles.sectionDivider} />
|
||||
|
||||
<View style={styles.menuHeaderRow}>
|
||||
<Text style={styles.menuTitle}>Menu</Text>
|
||||
<Text style={styles.menuCount}>{catalog.length} items</Text>
|
||||
</View>
|
||||
<View style={styles.detailsSection}>
|
||||
<Text style={styles.sectionTitle}>Product Details</Text>
|
||||
<Text style={styles.descriptionText}>
|
||||
{product?.description ||
|
||||
'No description available for this product.'}
|
||||
</Text>
|
||||
|
||||
<CategoryTabs
|
||||
styles={styles}
|
||||
categories={categories}
|
||||
activeCategory={activeCategory}
|
||||
onSelect={setActiveCategory}
|
||||
/>
|
||||
|
||||
<View style={styles.menuList}>
|
||||
{filteredItems.length === 0 && (
|
||||
<View style={styles.emptyMenuWrap}>
|
||||
<Text style={styles.emptyMenuEmoji}>🍽️</Text>
|
||||
<Text style={styles.emptyMenuText}>
|
||||
No items in this category yet
|
||||
<View style={styles.metaGrid}>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Category</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{product?.category?.name || 'N/A'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{filteredItems.map(item => (
|
||||
<CatalogItemRow
|
||||
key={item.id}
|
||||
imageUrl={item.imageUrl}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
price={item.price}
|
||||
quantity={getItemQuantity(item.id)}
|
||||
onAdd={() => handleAddItem(item)}
|
||||
onRemove={() => {}}
|
||||
/>
|
||||
))}
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>SKU</Text>
|
||||
<Text style={styles.metaValue}>{product?.sku || 'N/A'}</Text>
|
||||
</View>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Stock</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{product?.stockQuantity ? 'In Stock' : 'Out of Stock'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Merchant</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{product?.merchant?.name || 'N/A'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.sectionDivider} />
|
||||
|
||||
{renderRatingsSection()}
|
||||
</ScrollView>
|
||||
|
||||
{totalItems > 0 && (
|
||||
<CartStrip
|
||||
styles={styles}
|
||||
totalItems={totalItems}
|
||||
totalPrice={totalPrice}
|
||||
onPress={() => navigation.navigate('CartScreen')}
|
||||
/>
|
||||
)}
|
||||
{/* Sticky Bottom Bar */}
|
||||
<View style={styles.bottomBarWrap}>
|
||||
{isInCart ? (
|
||||
// ── Already in cart: show inline qty stepper + Go to Cart ──
|
||||
<>
|
||||
<View style={styles.qtySelector}>
|
||||
<TouchableOpacity
|
||||
style={styles.qtyBtn}
|
||||
onPress={handleDecrement}
|
||||
activeOpacity={0.7}
|
||||
disabled={localQty <= 1}
|
||||
>
|
||||
<Text style={styles.qtyBtnText}>-</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.qtyValue}>{localQty}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.qtyBtn}
|
||||
onPress={handleIncrement}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.qtyBtnText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.addBtn}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => navigation.navigate('CartScreen')}
|
||||
>
|
||||
<Text style={styles.addBtnText}>Go to Cart</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
// ── Not in cart: qty picker + Add to Cart ──
|
||||
<>
|
||||
<View style={styles.qtySelector}>
|
||||
<TouchableOpacity
|
||||
style={styles.qtyBtn}
|
||||
onPress={() => setLocalQty(q => Math.max(0, q - 1))}
|
||||
activeOpacity={0.7}
|
||||
disabled={localQty <= 0}
|
||||
>
|
||||
<Text style={styles.qtyBtnText}>-</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.qtyValue}>{localQty}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.qtyBtn}
|
||||
onPress={() => setLocalQty(q => q + 1)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.qtyBtnText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.addBtn}
|
||||
activeOpacity={0.8}
|
||||
onPress={handleAddToCart}
|
||||
disabled={
|
||||
!product ||
|
||||
(product.isTrackStock && product.stockQuantity === 0)
|
||||
}
|
||||
>
|
||||
<Text style={styles.addBtnText}>
|
||||
{!product
|
||||
? 'Loading...'
|
||||
: product.isTrackStock && product.stockQuantity === 0
|
||||
? 'Out of Stock'
|
||||
: 'Add to Cart'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
33
app/features/screens/providerDetailsScreen/reducer.ts
Normal file
33
app/features/screens/providerDetailsScreen/reducer.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
import { Product } from '@interfaces';
|
||||
import { getProductDetailsThunk } from './thunk';
|
||||
|
||||
export interface ProviderDetailsState {
|
||||
product: Product | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: ProviderDetailsState = {
|
||||
product: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const providerDetailsReducer = createReducer(initialState, builder => {
|
||||
builder
|
||||
.addCase(getProductDetailsThunk.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(getProductDetailsThunk.fulfilled, (state, action) => {
|
||||
state.product = action.payload;
|
||||
state.isLoading = false;
|
||||
})
|
||||
.addCase(getProductDetailsThunk.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload || 'Failed to fetch product details';
|
||||
});
|
||||
});
|
||||
|
||||
export default providerDetailsReducer;
|
||||
18
app/features/screens/providerDetailsScreen/thunk.ts
Normal file
18
app/features/screens/providerDetailsScreen/thunk.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { getProductDetailsApi } from '@api';
|
||||
import { Product } from '@interfaces';
|
||||
|
||||
export const getProductDetailsThunk = createAsyncThunk<
|
||||
Product,
|
||||
string,
|
||||
{ rejectValue: string }
|
||||
>('providerDetails/getProductDetails', async (productId, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await getProductDetailsApi(productId);
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(
|
||||
error.response?.data?.message || 'Failed to fetch product details',
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -1 +1,2 @@
|
||||
export * from './setLocationScreen';
|
||||
export * from './reducer';
|
||||
|
||||
43
app/features/screens/setLocationScreen/reducer.ts
Normal file
43
app/features/screens/setLocationScreen/reducer.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { createReducer, createAction } from '@reduxjs/toolkit';
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const setLocationData = createAction<{
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
mapAddress: string;
|
||||
}>('setLocation/setLocationData');
|
||||
|
||||
export const clearLocationData = createAction('setLocation/clearLocationData');
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SetLocationState {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
mapAddress: string;
|
||||
}
|
||||
|
||||
const initialState: SetLocationState = {
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
mapAddress: '',
|
||||
};
|
||||
|
||||
// ─── Reducer ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const setLocationReducer = createReducer(initialState, builder => {
|
||||
builder
|
||||
.addCase(setLocationData, (state, action) => {
|
||||
state.latitude = action.payload.latitude;
|
||||
state.longitude = action.payload.longitude;
|
||||
state.mapAddress = action.payload.mapAddress;
|
||||
})
|
||||
.addCase(clearLocationData, state => {
|
||||
state.latitude = null;
|
||||
state.longitude = null;
|
||||
state.mapAddress = '';
|
||||
});
|
||||
});
|
||||
|
||||
export default setLocationReducer;
|
||||
@ -20,8 +20,14 @@ import {
|
||||
showPermissionDeniedAlert,
|
||||
LatLng,
|
||||
} from '../../../services/locationServices';
|
||||
import { useAppDispatch } from '@store';
|
||||
import { setLocationData } from './reducer';
|
||||
import { OnboardingStackParamList } from '@navigation/onboardingStack';
|
||||
|
||||
type NavProp = StackNavigationProp<AuthStackParamList, 'SetLocationScreen'>;
|
||||
type NavProp = StackNavigationProp<
|
||||
OnboardingStackParamList,
|
||||
'SetLocationScreen'
|
||||
>;
|
||||
|
||||
const DELTA = { latitudeDelta: 0.006, longitudeDelta: 0.006 };
|
||||
|
||||
@ -29,6 +35,7 @@ export const SetLocationScreen: React.FC = () => {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = getStyles(colors);
|
||||
const navigation = useNavigation<NavProp>();
|
||||
const dispatch = useAppDispatch();
|
||||
const mapRef = useRef<MapView>(null);
|
||||
// Guard: never call setState after the component has unmounted
|
||||
const isMounted = useRef(true);
|
||||
@ -149,7 +156,16 @@ export const SetLocationScreen: React.FC = () => {
|
||||
|
||||
<PrimaryButton
|
||||
title="Confirm Location"
|
||||
onPress={() => navigation.navigate('CompleteProfileScreen')}
|
||||
onPress={() => {
|
||||
dispatch(
|
||||
setLocationData({
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
mapAddress: address,
|
||||
}),
|
||||
);
|
||||
navigation.navigate('CompleteProfileScreen');
|
||||
}}
|
||||
style={styles.confirmButton}
|
||||
/>
|
||||
|
||||
|
||||
0
app/features/screens/setLocationScreen/thunk.ts
Normal file
0
app/features/screens/setLocationScreen/thunk.ts
Normal file
1
app/features/screens/writeReviewScreen/index.ts
Normal file
1
app/features/screens/writeReviewScreen/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './writeReviewScreen';
|
||||
@ -0,0 +1,31 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { typography } from '@theme';
|
||||
|
||||
export const getStyles = (colors: any) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.fontSize.xl,
|
||||
fontWeight: typography.fontWeight.bold,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: typography.fontSize.md,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 32,
|
||||
},
|
||||
});
|
||||
43
app/features/screens/writeReviewScreen/writeReviewScreen.tsx
Normal file
43
app/features/screens/writeReviewScreen/writeReviewScreen.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { getStyles } from './writeReviewScreen.styles';
|
||||
import { Header, RatingStars, PrimaryButton } from '@components';
|
||||
import { useAppTheme } from '@theme';
|
||||
import { AppStackParamList } from '../../../navigation/appStack';
|
||||
|
||||
type WriteReviewNavProp = StackNavigationProp<
|
||||
AppStackParamList,
|
||||
'WriteReviewScreen'
|
||||
>;
|
||||
|
||||
export const WriteReviewScreen: React.FC = () => {
|
||||
const { colors } = useAppTheme();
|
||||
const styles = getStyles(colors);
|
||||
const navigation = useNavigation<WriteReviewNavProp>();
|
||||
const [rating, setRating] = useState(0);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Header
|
||||
title="Order Delivered"
|
||||
onBack={() => navigation.navigate('MainTabs')}
|
||||
/>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.icon}>🎉</Text>
|
||||
<Text style={styles.title}>Your order has been delivered!</Text>
|
||||
<Text style={styles.subtitle}>How was your experience?</Text>
|
||||
|
||||
<RatingStars rating={rating} onRate={setRating} size={40} />
|
||||
|
||||
<PrimaryButton
|
||||
title="Rate & Tip"
|
||||
onPress={() => navigation.navigate('MainTabs')}
|
||||
disabled={rating === 0}
|
||||
style={{ marginTop: 32 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
51
app/interfaces/cart.ts
Normal file
51
app/interfaces/cart.ts
Normal file
@ -0,0 +1,51 @@
|
||||
export interface AddToCartRequest {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
attributes?: CartItemAttributes;
|
||||
}
|
||||
|
||||
export interface CartItemAttributes {
|
||||
size?: string;
|
||||
extraToppings?: string[];
|
||||
}
|
||||
|
||||
export interface CartProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
compareAtPrice: number;
|
||||
imageUrl: string;
|
||||
stockQuantity: number;
|
||||
isTrackStock: boolean;
|
||||
merchantId: string;
|
||||
merchantName: string;
|
||||
}
|
||||
|
||||
export interface CartItem {
|
||||
id: string;
|
||||
productId: string;
|
||||
quantity: number;
|
||||
attributes: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
product: CartProduct;
|
||||
}
|
||||
|
||||
export interface MerchantCartGroup {
|
||||
merchantId: string;
|
||||
merchantName: string;
|
||||
items: CartItem[];
|
||||
subtotal: number;
|
||||
}
|
||||
|
||||
export interface CartResponse {
|
||||
[x: string]: any;
|
||||
id: string;
|
||||
customerId: string;
|
||||
items: CartItem[];
|
||||
groupedByMerchant: MerchantCartGroup[];
|
||||
subtotal: number;
|
||||
totalItems: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@ -90,3 +90,6 @@ export interface PaymentMethod {
|
||||
}
|
||||
|
||||
export * from './auth';
|
||||
export * from './onboard';
|
||||
export * from './product';
|
||||
export * from './cart';
|
||||
|
||||
55
app/interfaces/onboard.ts
Normal file
55
app/interfaces/onboard.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { Role } from './auth';
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
imageUrl: string | null;
|
||||
isActive: boolean;
|
||||
children: Category[];
|
||||
}
|
||||
|
||||
export interface onBoardPayload {
|
||||
name: string;
|
||||
email: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
addressLabel: string;
|
||||
addressLine1: string;
|
||||
mapAddress: string;
|
||||
houseNumber: string;
|
||||
landmark: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
addressPhone: string;
|
||||
categoryPreferences: string[];
|
||||
gender: string;
|
||||
}
|
||||
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
roleId: string;
|
||||
roleEnum: 'CUSTOMER' | 'MERCHANT' | 'BUSINESS_ADMIN' | 'SUPER_ADMIN';
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
profileImage: string | null;
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED';
|
||||
mfaEnabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
isDeleted: boolean;
|
||||
lastLogoutAt: string | null;
|
||||
loginAt: string;
|
||||
refreshTokenId: string;
|
||||
refreshTokenHash: string;
|
||||
refreshTokenExpiresAt: string;
|
||||
categoryPreferences: string[];
|
||||
gender: 'MALE' | 'FEMALE' | 'OTHER';
|
||||
dateOfBirth: string;
|
||||
role: Role;
|
||||
}
|
||||
143
app/interfaces/product.ts
Normal file
143
app/interfaces/product.ts
Normal file
@ -0,0 +1,143 @@
|
||||
export interface ProductMedia {
|
||||
id: string;
|
||||
productId: string;
|
||||
url: string;
|
||||
mediaType: 'IMAGE' | 'VIDEO';
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ProductCategory {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProductMerchant {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProductAttributes {
|
||||
featured?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ProductDimensions {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface Products {
|
||||
id: string;
|
||||
merchantId: string;
|
||||
categoryId: string;
|
||||
sku: string;
|
||||
barcode: string | null;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
productType: string;
|
||||
price: string;
|
||||
compareAtPrice: string;
|
||||
currency: string;
|
||||
weightKg: string | null;
|
||||
weightGm: string | null;
|
||||
dimensions: ProductDimensions;
|
||||
attributes: ProductAttributes;
|
||||
stockQuantity: number;
|
||||
isTrackStock: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
category: ProductCategory;
|
||||
merchant: ProductMerchant;
|
||||
media: ProductMedia[];
|
||||
avgRating: string;
|
||||
totalRatings: number;
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
merchantId: string;
|
||||
categoryId: string;
|
||||
sku: string;
|
||||
barcode: string | null;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
productType: ProductType;
|
||||
price: string;
|
||||
compareAtPrice: string;
|
||||
currency: string;
|
||||
weightKg: string | null;
|
||||
weightGm: string | null;
|
||||
dimensions: ProductDimensions;
|
||||
attributes: ProductAttributes;
|
||||
stockQuantity: number;
|
||||
isTrackStock: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
category: ProductCategory;
|
||||
merchant: ProductMerchant;
|
||||
media: ProductMedia[];
|
||||
avgRating: string;
|
||||
totalRatings: number;
|
||||
recentRatings: ProductRating[];
|
||||
}
|
||||
|
||||
export interface ProductCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface ProductMerchant {
|
||||
id: string;
|
||||
name: string;
|
||||
merchantCode: string;
|
||||
rating: string;
|
||||
}
|
||||
|
||||
// export interface ProductMedia {
|
||||
// id: string;
|
||||
// productId: string;
|
||||
// url: string;
|
||||
// mediaType: MediaType;
|
||||
// sortOrder: number;
|
||||
// createdAt: string;
|
||||
// }
|
||||
|
||||
// export interface ProductAttributes {
|
||||
// featured: boolean;
|
||||
// }
|
||||
|
||||
// export interface ProductDimensions {
|
||||
// [key: string]: unknown;
|
||||
// }
|
||||
|
||||
export interface ProductRating {
|
||||
id: string;
|
||||
userId: string;
|
||||
rating: number;
|
||||
review: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export enum ProductType {
|
||||
FOOD = 'FOOD',
|
||||
GROCERIES = 'GROCERIES',
|
||||
PHARMACY = 'PHARMACY',
|
||||
ELECTRONICS = 'ELECTRONICS',
|
||||
}
|
||||
|
||||
export enum MediaType {
|
||||
IMAGE = 'IMAGE',
|
||||
VIDEO = 'VIDEO',
|
||||
}
|
||||
@ -13,6 +13,7 @@ import {
|
||||
LiveTrackingScreen,
|
||||
OrderDeliveredScreen,
|
||||
HelpSupportScreen,
|
||||
WriteReviewScreen,
|
||||
} from '@features/screens';
|
||||
|
||||
export type AppStackParamList = {
|
||||
@ -27,6 +28,7 @@ export type AppStackParamList = {
|
||||
LiveTrackingScreen: { orderId?: string } | undefined;
|
||||
OrderDeliveredScreen: { orderId?: string } | undefined;
|
||||
HelpSupportScreen: undefined;
|
||||
WriteReviewScreen: { productId: string } | undefined;
|
||||
};
|
||||
|
||||
const Stack = createStackNavigator<AppStackParamList>();
|
||||
@ -36,15 +38,34 @@ export const AppStack: React.FC = () => {
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="MainTabs" component={MainTabNavigator} />
|
||||
<Stack.Screen name="ProviderListScreen" component={ProviderListScreen} />
|
||||
<Stack.Screen name="ProviderDetailsScreen" component={ProviderDetailsScreen} />
|
||||
<Stack.Screen
|
||||
name="ProviderDetailsScreen"
|
||||
component={ProviderDetailsScreen}
|
||||
/>
|
||||
<Stack.Screen name="CartScreen" component={CartScreen} />
|
||||
<Stack.Screen name="CheckoutAddressScreen" component={CheckoutAddressScreen} />
|
||||
<Stack.Screen name="CheckoutPaymentScreen" component={CheckoutPaymentScreen} />
|
||||
<Stack.Screen name="OrderConfirmedScreen" component={OrderConfirmedScreen} />
|
||||
<Stack.Screen name="OrderTrackingScreen" component={OrderTrackingScreen} />
|
||||
<Stack.Screen
|
||||
name="CheckoutAddressScreen"
|
||||
component={CheckoutAddressScreen}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CheckoutPaymentScreen"
|
||||
component={CheckoutPaymentScreen}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="OrderConfirmedScreen"
|
||||
component={OrderConfirmedScreen}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="OrderTrackingScreen"
|
||||
component={OrderTrackingScreen}
|
||||
/>
|
||||
<Stack.Screen name="LiveTrackingScreen" component={LiveTrackingScreen} />
|
||||
<Stack.Screen name="OrderDeliveredScreen" component={OrderDeliveredScreen} />
|
||||
<Stack.Screen
|
||||
name="OrderDeliveredScreen"
|
||||
component={OrderDeliveredScreen}
|
||||
/>
|
||||
<Stack.Screen name="HelpSupportScreen" component={HelpSupportScreen} />
|
||||
<Stack.Screen name="WriteReviewScreen" component={WriteReviewScreen} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
@ -12,10 +12,10 @@ import {
|
||||
export type AuthStackParamList = {
|
||||
LoginScreen: undefined;
|
||||
OtpScreen: { mobileNumber: string };
|
||||
SetLocationScreen: undefined;
|
||||
CompleteProfileScreen: undefined;
|
||||
PreferencesScreen: undefined;
|
||||
OnboardingCompleteScreen: undefined;
|
||||
// SetLocationScreen: undefined;
|
||||
// CompleteProfileScreen: undefined;
|
||||
// PreferencesScreen: undefined;
|
||||
// OnboardingCompleteScreen: undefined;
|
||||
};
|
||||
|
||||
const Stack = createStackNavigator<AuthStackParamList>();
|
||||
@ -25,10 +25,6 @@ export const AuthStack: React.FC = () => {
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="LoginScreen" component={LoginScreen} />
|
||||
<Stack.Screen name="OtpScreen" component={OtpScreen} />
|
||||
<Stack.Screen name="SetLocationScreen" component={SetLocationScreen} />
|
||||
<Stack.Screen name="CompleteProfileScreen" component={CompleteProfileScreen} />
|
||||
<Stack.Screen name="PreferencesScreen" component={PreferencesScreen} />
|
||||
<Stack.Screen name="OnboardingCompleteScreen" component={OnboardingCompleteScreen} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
33
app/navigation/onboardingStack.tsx
Normal file
33
app/navigation/onboardingStack.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import {
|
||||
CompleteProfileScreen,
|
||||
OnboardingCompleteScreen,
|
||||
PreferencesScreen,
|
||||
SetLocationScreen,
|
||||
} from '@features/screens';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
|
||||
export type OnboardingStackParamList = {
|
||||
SetLocationScreen: undefined;
|
||||
CompleteProfileScreen: undefined;
|
||||
PreferencesScreen: undefined;
|
||||
OnboardingCompleteScreen: undefined;
|
||||
};
|
||||
|
||||
const Stack = createStackNavigator<OnboardingStackParamList>();
|
||||
|
||||
export const OnboardingStack: React.FC = () => {
|
||||
return (
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="SetLocationScreen" component={SetLocationScreen} />
|
||||
<Stack.Screen
|
||||
name="CompleteProfileScreen"
|
||||
component={CompleteProfileScreen}
|
||||
/>
|
||||
<Stack.Screen name="PreferencesScreen" component={PreferencesScreen} />
|
||||
<Stack.Screen
|
||||
name="OnboardingCompleteScreen"
|
||||
component={OnboardingCompleteScreen}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
);
|
||||
};
|
||||
@ -1,17 +1,23 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '../store';
|
||||
import { RootState, useAppSelector } from '../store';
|
||||
import { AuthStack } from './authStack';
|
||||
import { AppStack } from './appStack';
|
||||
import { OnboardingStack } from './onboardingStack';
|
||||
|
||||
export const RootNavigator: React.FC = () => {
|
||||
const { isAuthenticated, onboardingComplete } = useSelector(
|
||||
(state: RootState) => state.auth,
|
||||
const accessToken = useSelector((state: RootState) => state.auth.accessToken);
|
||||
const isOnboarding = useAppSelector(
|
||||
state => state.auth.user?.status === 'ONBOARDING',
|
||||
);
|
||||
|
||||
if (!isAuthenticated || !onboardingComplete) {
|
||||
if (!accessToken) {
|
||||
return <AuthStack />;
|
||||
}
|
||||
|
||||
if (isOnboarding) {
|
||||
return <OnboardingStack />;
|
||||
}
|
||||
|
||||
return <AppStack />;
|
||||
};
|
||||
|
||||
@ -15,7 +15,7 @@ const STORAGE_KEYS = {
|
||||
} as const;
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
const BASE_URL = 'https://a84e-202-8-116-13.ngrok-free.app'; // TODO: replace with your actual base URL
|
||||
const BASE_URL = 'https://2e1b-202-8-116-13.ngrok-free.app'; // TODO: replace with your actual base URL
|
||||
|
||||
// ─── Token Helpers ───────────────────────────────────────────────────────────
|
||||
export const tokenManager = {
|
||||
|
||||
@ -1,16 +1,2 @@
|
||||
export { default as authReducer } from './reducer';
|
||||
export {
|
||||
setUser,
|
||||
setLocation,
|
||||
completeProfile,
|
||||
completeOnboarding,
|
||||
logout,
|
||||
clearError,
|
||||
} from './reducer';
|
||||
export type { AuthState } from './reducer';
|
||||
export {
|
||||
loginWithPhone,
|
||||
verifyOtp,
|
||||
updateProfile,
|
||||
logoutUser,
|
||||
} from './thunk';
|
||||
export * from './reducer';
|
||||
export * from './thunk';
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
import { LoginResponse, VerifyOtpResponse } from '../../../interfaces';
|
||||
import { loginWithPhone, verifyOtp } from './thunk';
|
||||
import { User } from '@interfaces/auth';
|
||||
import { createAction, createReducer } from '@reduxjs/toolkit';
|
||||
import { VerifyOtpResponse } from '../../../interfaces';
|
||||
import { loginWithPhone, verifyOtp, logoutUser, updateProfile } from './thunk';
|
||||
import { LoginResponse, User } from '@interfaces/auth';
|
||||
|
||||
// ─── Sync Actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const completeOnboarding = createAction('auth/completeOnboarding');
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -61,6 +65,42 @@ const authReducer = createReducer(initialState, builder => {
|
||||
.addCase(verifyOtp.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
|
||||
// Complete Onboarding — flips user.status so RootNavigator swaps to AppStack
|
||||
.addCase(completeOnboarding, state => {
|
||||
if (state.user) {
|
||||
state.user = { ...state.user, status: 'ACTIVE' };
|
||||
}
|
||||
})
|
||||
|
||||
// Update Profile
|
||||
.addCase(updateProfile.fulfilled, (state, action) => {
|
||||
if (state.user && action.payload?.status) {
|
||||
state.user = { ...state.user, status: action.payload.status };
|
||||
}
|
||||
state.isLoading = false;
|
||||
state.error = null;
|
||||
})
|
||||
|
||||
// Logout — clear all auth state
|
||||
.addCase(logoutUser.fulfilled, state => {
|
||||
state.user = null;
|
||||
state.accessToken = null;
|
||||
state.refreshToken = null;
|
||||
state.loginData = null;
|
||||
state.isNewUser = null;
|
||||
state.isLoading = false;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(logoutUser.rejected, state => {
|
||||
// Even on failure, clear local auth state (tokens already cleared by thunk)
|
||||
state.user = null;
|
||||
state.accessToken = null;
|
||||
state.refreshToken = null;
|
||||
state.loginData = null;
|
||||
state.isNewUser = null;
|
||||
state.isLoading = false;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
export { default as cartReducer } from './reducer';
|
||||
export {
|
||||
addItem,
|
||||
removeItem,
|
||||
clearCart,
|
||||
applyCoupon,
|
||||
removeCoupon,
|
||||
selectCartTotal,
|
||||
} from './reducer';
|
||||
export {
|
||||
addToCartThunk,
|
||||
getCartThunk,
|
||||
updateCartItemThunk,
|
||||
} from './thunk';
|
||||
export type { CartState } from './reducer';
|
||||
|
||||
@ -1,54 +1,45 @@
|
||||
import { createAction, createReducer } from '@reduxjs/toolkit';
|
||||
import { CartItem, CatalogItem } from '../../../interfaces';
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
import { CartItem, MerchantCartGroup } from '@interfaces/cart';
|
||||
import { addToCartThunk, getCartThunk, updateCartItemThunk } from './thunk';
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CartState {
|
||||
id: string | null;
|
||||
customerId: string | null;
|
||||
items: CartItem[];
|
||||
couponCode: string | null;
|
||||
discount: number;
|
||||
providerId: string | null;
|
||||
providerName: string | null;
|
||||
groupedByMerchant: MerchantCartGroup[];
|
||||
subtotal: number;
|
||||
totalItems: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: CartState = {
|
||||
id: null,
|
||||
customerId: null,
|
||||
items: [],
|
||||
couponCode: null,
|
||||
discount: 0,
|
||||
providerId: null,
|
||||
providerName: null,
|
||||
groupedByMerchant: [],
|
||||
subtotal: 0,
|
||||
totalItems: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// ─── Actions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const addItem = createAction<{
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
item: CatalogItem;
|
||||
}>('cart/addItem');
|
||||
|
||||
export const removeItem = createAction<string>('cart/removeItem');
|
||||
export const clearCart = createAction('cart/clearCart');
|
||||
export const applyCoupon = createAction<string>('cart/applyCoupon');
|
||||
export const removeCoupon = createAction('cart/removeCoupon');
|
||||
|
||||
// ─── Selector ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const selectCartTotal = (state: { cart: CartState }) => {
|
||||
const subtotal = state.cart.items.reduce(
|
||||
(sum, item) => sum + item.item.price * item.quantity,
|
||||
0,
|
||||
);
|
||||
const deliveryFee = 40;
|
||||
const platformFee = 10;
|
||||
const discount = state.cart.discount;
|
||||
const subtotal = state.cart.subtotal || 0;
|
||||
const deliveryFee = 40; // Flat fee for now
|
||||
const platformFee = 10; // Flat fee for now
|
||||
const discount = 0; // Removing discount logic for now as requested
|
||||
return {
|
||||
subtotal,
|
||||
deliveryFee,
|
||||
platformFee,
|
||||
discount,
|
||||
total: Math.max(0, subtotal + deliveryFee + platformFee - discount),
|
||||
itemCount: state.cart.items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
itemCount: state.cart.totalItems || 0,
|
||||
};
|
||||
};
|
||||
|
||||
@ -56,58 +47,64 @@ export const selectCartTotal = (state: { cart: CartState }) => {
|
||||
|
||||
const cartReducer = createReducer(initialState, builder => {
|
||||
builder
|
||||
.addCase(addItem, (state, action) => {
|
||||
const { providerId, providerName, item } = action.payload;
|
||||
// getCartThunk
|
||||
.addCase(getCartThunk.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(getCartThunk.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
const cart = action.payload;
|
||||
state.id = cart.id;
|
||||
state.customerId = cart.customerId;
|
||||
state.items = cart.items || [];
|
||||
state.groupedByMerchant = cart.groupedByMerchant || [];
|
||||
state.subtotal = cart.subtotal || 0;
|
||||
state.totalItems = cart.totalItems || 0;
|
||||
})
|
||||
.addCase(getCartThunk.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = (action.payload as string) || 'Failed to get cart';
|
||||
})
|
||||
|
||||
// Clear cart if switching providers
|
||||
if (state.providerId && state.providerId !== providerId) {
|
||||
state.items = [];
|
||||
}
|
||||
// addToCartThunk
|
||||
.addCase(addToCartThunk.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(addToCartThunk.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
const cart = action.payload;
|
||||
state.id = cart.id;
|
||||
state.customerId = cart.customerId;
|
||||
state.items = cart.items || [];
|
||||
state.groupedByMerchant = cart.groupedByMerchant || [];
|
||||
state.subtotal = cart.subtotal || 0;
|
||||
state.totalItems = cart.totalItems || 0;
|
||||
})
|
||||
.addCase(addToCartThunk.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = (action.payload as string) || 'Failed to add to cart';
|
||||
})
|
||||
|
||||
state.providerId = providerId;
|
||||
state.providerName = providerName;
|
||||
|
||||
const existing = state.items.find(i => i.item.id === item.id);
|
||||
if (existing) {
|
||||
existing.quantity += 1;
|
||||
} else {
|
||||
state.items.push({
|
||||
id: `${providerId}-${item.id}`,
|
||||
providerId,
|
||||
providerName,
|
||||
item,
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
// updateCartItemThunk
|
||||
.addCase(updateCartItemThunk.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(removeItem, (state, action) => {
|
||||
const existing = state.items.find(i => i.item.id === action.payload);
|
||||
if (existing) {
|
||||
if (existing.quantity > 1) {
|
||||
existing.quantity -= 1;
|
||||
} else {
|
||||
state.items = state.items.filter(i => i.item.id !== action.payload);
|
||||
}
|
||||
}
|
||||
if (state.items.length === 0) {
|
||||
state.providerId = null;
|
||||
state.providerName = null;
|
||||
}
|
||||
.addCase(updateCartItemThunk.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
const cart = action.payload;
|
||||
state.id = cart.id;
|
||||
state.customerId = cart.customerId;
|
||||
state.items = cart.items || [];
|
||||
state.groupedByMerchant = cart.groupedByMerchant || [];
|
||||
state.subtotal = cart.subtotal || 0;
|
||||
state.totalItems = cart.totalItems || 0;
|
||||
})
|
||||
.addCase(clearCart, state => {
|
||||
state.items = [];
|
||||
state.couponCode = null;
|
||||
state.discount = 0;
|
||||
state.providerId = null;
|
||||
state.providerName = null;
|
||||
})
|
||||
.addCase(applyCoupon, (state, action) => {
|
||||
state.couponCode = action.payload;
|
||||
state.discount = 50;
|
||||
})
|
||||
.addCase(removeCoupon, state => {
|
||||
state.couponCode = null;
|
||||
state.discount = 0;
|
||||
.addCase(updateCartItemThunk.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = (action.payload as string) || 'Failed to update cart item';
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,2 +1,48 @@
|
||||
// Cart thunks — placeholder for future async cart operations
|
||||
// (e.g., syncing cart with backend, applying server-side coupons)
|
||||
import { addToCartApi, getCartApi, updateCartItem } from '@api';
|
||||
import { AddToCartRequest, CartResponse } from '@interfaces/cart';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
export const addToCartThunk = createAsyncThunk<CartResponse, AddToCartRequest>(
|
||||
'cart/addToCart',
|
||||
async (addToCartRequest, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await addToCartApi(addToCartRequest);
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to add to cart';
|
||||
return rejectWithValue(message);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const getCartThunk = createAsyncThunk<CartResponse>(
|
||||
'cart/getCart',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await getCartApi();
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to get cart';
|
||||
return rejectWithValue(message);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const updateCartItemThunk = createAsyncThunk<
|
||||
CartResponse,
|
||||
{ productId: string; quantity: number }
|
||||
>(
|
||||
'cart/updateCartItem',
|
||||
async ({ productId, quantity }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await updateCartItem(productId, quantity);
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to update cart item';
|
||||
return rejectWithValue(message);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,12 +1,23 @@
|
||||
import { combineReducers } from '@reduxjs/toolkit';
|
||||
import { authReducer } from './commonreducers/auth';
|
||||
|
||||
import { cartReducer } from './commonreducers/cart';
|
||||
import { orderReducer } from './commonreducers/order';
|
||||
import preferencesReducer from '@features/screens/preferencesScreen/reducer';
|
||||
import setLocationReducer from '@features/screens/setLocationScreen/reducer';
|
||||
import completeProfileReducer from '@features/screens/completeProfileScreen/reducer';
|
||||
import authReducer from './commonreducers/auth/reducer';
|
||||
import homeReducer from '@features/screens/homeScreen/reducer';
|
||||
import providerDetailsReducer from '@features/screens/providerDetailsScreen/reducer';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
auth: authReducer,
|
||||
cart: cartReducer,
|
||||
order: orderReducer,
|
||||
preferences: preferencesReducer,
|
||||
setLocation: setLocationReducer,
|
||||
completeProfile: completeProfileReducer,
|
||||
home: homeReducer,
|
||||
providerDetails: providerDetailsReducer,
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof rootReducer>;
|
||||
|
||||
5
app/utils/helper.ts
Normal file
5
app/utils/helper.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const getFullUrl = (url?: string) => {
|
||||
const BASE_URL = 'https://2e1b-202-8-116-13.ngrok-free.app';
|
||||
if (!url) return '';
|
||||
return url.startsWith('/') ? `${BASE_URL}${url}` : url;
|
||||
};
|
||||
@ -13,6 +13,8 @@ module.exports = {
|
||||
'@navigation': './app/navigation',
|
||||
'@services': './app/services',
|
||||
'@store': './app/store',
|
||||
'@api': './app/api',
|
||||
'@utils': './app/utils',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@ -19,7 +19,11 @@
|
||||
"@navigation": ["./app/navigation"],
|
||||
"@navigation/*": ["./app/navigation/*"],
|
||||
"@hooks": ["./app/hooks"],
|
||||
"@hooks/*": ["./app/hooks/*"]
|
||||
"@hooks/*": ["./app/hooks/*"],
|
||||
"@utils": ["./app/utils"],
|
||||
"@utils/*": ["./app/utils/*"],
|
||||
"@services": ["./app/services"],
|
||||
"@services/*": ["./app/services/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user