From 0806289a81850b5cb25b019e8311ab109f32cc7e Mon Sep 17 00:00:00 2001 From: Tamojit Biswas Date: Tue, 7 Jul 2026 17:44:48 +0530 Subject: [PATCH] feat: implement core feature screens, navigation structure, and redux store state management --- app/api/cartApi.ts | 16 + app/api/index.ts | 3 + app/api/onboardApi.ts | 12 + app/api/productApi.ts | 17 + app/api/reviewApi.ts | 0 app/components/index.ts | 1 + app/components/productCard/index.ts | 1 + .../productCard/productCard.styles.ts | 109 +++ app/components/productCard/productCard.tsx | 112 +++ app/features/screens/LoginScreen/index.ts | 1 - .../screens/cartScreen/cartScreen.tsx | 106 +-- .../completeProfileScreen.tsx | 129 ++- .../screens/completeProfileScreen/index.ts | 1 + .../screens/completeProfileScreen/reducer.ts | 71 ++ .../screens/completeProfileScreen/thunk.ts | 0 .../screens/homeScreen/homeScreen.styles.ts | 5 +- .../screens/homeScreen/homeScreen.tsx | 73 +- app/features/screens/homeScreen/index.ts | 2 + app/features/screens/homeScreen/reducer.ts | 36 + app/features/screens/homeScreen/thunk.ts | 17 + app/features/screens/index.ts | 1 + .../onboardingCompleteScreen.tsx | 5 +- .../screens/preferencesScreen/index.ts | 2 + .../preferencesScreen/preferencesScreen.tsx | 155 +++- .../screens/preferencesScreen/reducer.ts | 59 ++ .../screens/preferencesScreen/thunk.ts | 33 + .../screens/providerDetailsScreen/index.ts | 2 + .../providerDetailsScreen.styles.ts | 583 +++++++++----- .../providerDetailsScreen.tsx | 752 +++++++++++------- .../screens/providerDetailsScreen/reducer.ts | 33 + .../screens/providerDetailsScreen/thunk.ts | 18 + .../screens/setLocationScreen/index.ts | 1 + .../screens/setLocationScreen/reducer.ts | 43 + .../setLocationScreen/setLocationScreen.tsx | 20 +- .../screens/setLocationScreen/thunk.ts | 0 .../screens/writeReviewScreen/index.ts | 1 + .../writeReviewScreen.styles.ts | 31 + .../writeReviewScreen/writeReviewScreen.tsx | 43 + app/interfaces/cart.ts | 51 ++ app/interfaces/index.ts | 3 + app/interfaces/onboard.ts | 55 ++ app/interfaces/product.ts | 143 ++++ app/navigation/appStack.tsx | 33 +- app/navigation/authStack.tsx | 12 +- app/navigation/onboardingStack.tsx | 33 + app/navigation/rootNavigator.tsx | 14 +- app/services/apiClient.ts | 2 +- app/store/commonreducers/auth/index.ts | 18 +- app/store/commonreducers/auth/reducer.ts | 48 +- app/store/commonreducers/cart/index.ts | 10 +- app/store/commonreducers/cart/reducer.ts | 155 ++-- app/store/commonreducers/cart/thunk.ts | 50 +- app/store/rootReducer.ts | 13 +- app/utils/helper.ts | 5 + babel.config.js | 2 + tsconfig.json | 6 +- 56 files changed, 2350 insertions(+), 797 deletions(-) create mode 100644 app/api/cartApi.ts create mode 100644 app/api/onboardApi.ts create mode 100644 app/api/productApi.ts create mode 100644 app/api/reviewApi.ts create mode 100644 app/components/productCard/index.ts create mode 100644 app/components/productCard/productCard.styles.ts create mode 100644 app/components/productCard/productCard.tsx create mode 100644 app/features/screens/completeProfileScreen/reducer.ts create mode 100644 app/features/screens/completeProfileScreen/thunk.ts create mode 100644 app/features/screens/homeScreen/reducer.ts create mode 100644 app/features/screens/homeScreen/thunk.ts create mode 100644 app/features/screens/preferencesScreen/reducer.ts create mode 100644 app/features/screens/preferencesScreen/thunk.ts create mode 100644 app/features/screens/providerDetailsScreen/reducer.ts create mode 100644 app/features/screens/providerDetailsScreen/thunk.ts create mode 100644 app/features/screens/setLocationScreen/reducer.ts create mode 100644 app/features/screens/setLocationScreen/thunk.ts create mode 100644 app/features/screens/writeReviewScreen/index.ts create mode 100644 app/features/screens/writeReviewScreen/writeReviewScreen.styles.ts create mode 100644 app/features/screens/writeReviewScreen/writeReviewScreen.tsx create mode 100644 app/interfaces/cart.ts create mode 100644 app/interfaces/onboard.ts create mode 100644 app/interfaces/product.ts create mode 100644 app/navigation/onboardingStack.tsx create mode 100644 app/utils/helper.ts diff --git a/app/api/cartApi.ts b/app/api/cartApi.ts new file mode 100644 index 0000000..2a108de --- /dev/null +++ b/app/api/cartApi.ts @@ -0,0 +1,16 @@ +import { AddToCartRequest, CartResponse } from '@interfaces/cart'; +import { apiClient } from '@services'; + +export const addToCartApi = async (addToCartRequest: AddToCartRequest) => { + return apiClient.post('/cart/items', addToCartRequest); +}; + +export const getCartApi = async () => { + return apiClient.get('/cart'); +}; + +export const updateCartItem = async (productId: string, quantity: number) => { + return apiClient.patch(`/cart/items/${productId}`, { + quantity, + }); +}; diff --git a/app/api/index.ts b/app/api/index.ts index 63d9991..d4b4695 100644 --- a/app/api/index.ts +++ b/app/api/index.ts @@ -1,2 +1,5 @@ export * from './authApi'; export * from './deliveryApi'; +export * from './onboardApi'; +export * from './productApi'; +export * from './cartApi'; diff --git a/app/api/onboardApi.ts b/app/api/onboardApi.ts new file mode 100644 index 0000000..0b72efb --- /dev/null +++ b/app/api/onboardApi.ts @@ -0,0 +1,12 @@ +import { Category, onBoardPayload, UserProfile } from '@interfaces/onboard'; +import { apiClient } from '@services'; + +export const getCatagories = async (): Promise => { + return await apiClient.get('/categories'); +}; + +export const onBoardComplete = async ( + payload: onBoardPayload, +): Promise => { + return await apiClient.post('/auth/onboard', payload); +}; diff --git a/app/api/productApi.ts b/app/api/productApi.ts new file mode 100644 index 0000000..a201f5e --- /dev/null +++ b/app/api/productApi.ts @@ -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 => { + return await apiClient.get(`/products`); +}; + +export const getProductDetailsApi = async ( + productId: string, +): Promise => { + return await apiClient.get(`/products/${productId}`); +}; diff --git a/app/api/reviewApi.ts b/app/api/reviewApi.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/components/index.ts b/app/components/index.ts index c861e95..a2babe1 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -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'; diff --git a/app/components/productCard/index.ts b/app/components/productCard/index.ts new file mode 100644 index 0000000..f67722b --- /dev/null +++ b/app/components/productCard/index.ts @@ -0,0 +1 @@ +export * from './productCard'; diff --git a/app/components/productCard/productCard.styles.ts b/app/components/productCard/productCard.styles.ts new file mode 100644 index 0000000..c9cc057 --- /dev/null +++ b/app/components/productCard/productCard.styles.ts @@ -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, + }, + }); diff --git a/app/components/productCard/productCard.tsx b/app/components/productCard/productCard.tsx new file mode 100644 index 0000000..7935fca --- /dev/null +++ b/app/components/productCard/productCard.tsx @@ -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; +} + +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 = ({ + id, + name, + imageUrl, + price, + compareAtPrice, + brand, + currency = 'β‚Ή', + onPress, + onAddPress, + style, +}) => { + const { colors } = useAppTheme(); + const styles = getStyles(colors); + + const discountText = getDiscountPercentage(price, compareAtPrice); + + return ( + + + + {discountText && ( + + {discountText} + + )} + + 🀍 + + + + + {brand && ( + + {brand} + + )} + + {name} + + + + {formatPrice(price, currency)} + {compareAtPrice && parseFloat(compareAtPrice) > parseFloat(price) && ( + + {formatPrice(compareAtPrice, currency)} + + )} + + + + + ADD + + + + ); +}; diff --git a/app/features/screens/LoginScreen/index.ts b/app/features/screens/LoginScreen/index.ts index 5c766db..7298bc5 100644 --- a/app/features/screens/LoginScreen/index.ts +++ b/app/features/screens/LoginScreen/index.ts @@ -1,2 +1 @@ export * from './loginScreen'; -export * from './loginScreen.styles'; diff --git a/app/features/screens/cartScreen/cartScreen.tsx b/app/features/screens/cartScreen/cartScreen.tsx index b56aa0d..45729b9 100644 --- a/app/features/screens/cartScreen/cartScreen.tsx +++ b/app/features/screens/cartScreen/cartScreen.tsx @@ -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; @@ -27,20 +22,14 @@ export const CartScreen: React.FC = () => { const styles = getStyles(colors); const dispatch = useAppDispatch(); const navigation = useNavigation(); - 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 (
navigation.goBack()} /> @@ -85,19 +74,36 @@ export const CartScreen: React.FC = () => { index < items.length - 1 && styles.cartItemDivider, ]} > - - πŸ• - + - {item.item.title} + {item.product.name} - β‚Ή{item.item.price} + β‚Ή{item.product.price} {}} - 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, + }), + ); + } + }} /> ))} @@ -113,48 +119,12 @@ export const CartScreen: React.FC = () => { + {/* Coupon logic removed/commented out for now as requested Apply Coupon - {couponCode ? ( - - - - βœ“ - - - {couponCode} - Coupon applied - - - - Remove - - - ) : ( - - 🏷️ - - - Apply - - - )} + ... + */} Bill Details diff --git a/app/features/screens/completeProfileScreen/completeProfileScreen.tsx b/app/features/screens/completeProfileScreen/completeProfileScreen.tsx index 1bafa94..186f38a 100644 --- a/app/features/screens/completeProfileScreen/completeProfileScreen.tsx +++ b/app/features/screens/completeProfileScreen/completeProfileScreen.tsx @@ -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; +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'} > - + Complete Profile Tell us about yourself + {/* Personal Information */} + + + {/* Gender */} + + Gender + + + {['MALE', 'FEMALE', 'OTHER'].map(item => ( + setGender(item as 'MALE' | 'FEMALE' | 'OTHER')} + > + + {item.charAt(0) + item.slice(1).toLowerCase()} + + + ))} + + + {/* Address */} + + + + + + + + + + + + + + + + {/* Location Label */} + Location Label + {LOCATION_LABELS.map(label => ( { selectedLabel === label && styles.labelChipSelected, ]} onPress={() => setSelectedLabel(label)} - activeOpacity={0.7} > ('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; diff --git a/app/features/screens/completeProfileScreen/thunk.ts b/app/features/screens/completeProfileScreen/thunk.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/features/screens/homeScreen/homeScreen.styles.ts b/app/features/screens/homeScreen/homeScreen.styles.ts index 39da1da..05c6748 100644 --- a/app/features/screens/homeScreen/homeScreen.styles.ts +++ b/app/features/screens/homeScreen/homeScreen.styles.ts @@ -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', diff --git a/app/features/screens/homeScreen/homeScreen.tsx b/app/features/screens/homeScreen/homeScreen.tsx index d63e2ec..91df4a5 100644 --- a/app/features/screens/homeScreen/homeScreen.tsx +++ b/app/features/screens/homeScreen/homeScreen.tsx @@ -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, @@ -74,18 +75,20 @@ export const HomeScreen: React.FC = () => { const styles = getStyles(colors); const navigation = useNavigation(); - const [providers, setProviders] = useState([]); + 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 = () => { - - πŸ”” + navigation.navigate('CartScreen')} + > + 🧺 @@ -224,8 +231,8 @@ export const HomeScreen: React.FC = () => { {selectedCategory === 'all' ? 'Popular near you' : selectedCategory} - {filteredProviders.length} place - {filteredProviders.length === 1 ? '' : 's'} delivering to you + {filteredProducts.length} product + {filteredProducts.length === 1 ? '' : 's'} available @@ -233,31 +240,31 @@ export const HomeScreen: React.FC = () => { - {/* Provider list */} + {/* Product list */} - {filteredProviders.length === 0 && ( - - 🍽️ - - No providers in this category yet - + {filteredProducts.length === 0 && ( + + πŸ›οΈ + No products found )} - {filteredProviders.map(provider => ( - + {filteredProducts.map(product => ( + { navigation.navigate('ProviderDetailsScreen', { - providerId: provider.id, - }) - } + providerId: product.id, + providerName: product.name, + }); + }} /> ))} diff --git a/app/features/screens/homeScreen/index.ts b/app/features/screens/homeScreen/index.ts index eacd088..e285a7e 100644 --- a/app/features/screens/homeScreen/index.ts +++ b/app/features/screens/homeScreen/index.ts @@ -1 +1,3 @@ export * from './homeScreen'; +export { default as homeReducer } from './reducer'; +export * from './thunk'; diff --git a/app/features/screens/homeScreen/reducer.ts b/app/features/screens/homeScreen/reducer.ts new file mode 100644 index 0000000..dcadc1b --- /dev/null +++ b/app/features/screens/homeScreen/reducer.ts @@ -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; diff --git a/app/features/screens/homeScreen/thunk.ts b/app/features/screens/homeScreen/thunk.ts new file mode 100644 index 0000000..c137142 --- /dev/null +++ b/app/features/screens/homeScreen/thunk.ts @@ -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', + ); + } +}); diff --git a/app/features/screens/index.ts b/app/features/screens/index.ts index dc530f7..7eaf429 100644 --- a/app/features/screens/index.ts +++ b/app/features/screens/index.ts @@ -19,3 +19,4 @@ export * from './myOrdersScreen'; export * from './offersScreen'; export * from './helpSupportScreen'; export * from './accountScreen'; +export * from './writeReviewScreen'; diff --git a/app/features/screens/onboardingCompleteScreen/onboardingCompleteScreen.tsx b/app/features/screens/onboardingCompleteScreen/onboardingCompleteScreen.tsx index ff56126..06be8c4 100644 --- a/app/features/screens/onboardingCompleteScreen/onboardingCompleteScreen.tsx +++ b/app/features/screens/onboardingCompleteScreen/onboardingCompleteScreen.tsx @@ -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()); }; diff --git a/app/features/screens/preferencesScreen/index.ts b/app/features/screens/preferencesScreen/index.ts index 7dc5967..305cf28 100644 --- a/app/features/screens/preferencesScreen/index.ts +++ b/app/features/screens/preferencesScreen/index.ts @@ -1 +1,3 @@ export * from './preferencesScreen'; +export * from './thunk'; +export * from './reducer'; diff --git a/app/features/screens/preferencesScreen/preferencesScreen.tsx b/app/features/screens/preferencesScreen/preferencesScreen.tsx index eb515a9..0efd0c6 100644 --- a/app/features/screens/preferencesScreen/preferencesScreen.tsx +++ b/app/features/screens/preferencesScreen/preferencesScreen.tsx @@ -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; - -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(); + const dispatch = useAppDispatch(); - const [selectedCategories, setSelectedCategories] = useState(['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([]); 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 ( Preferences Select your interests - - {CATEGORIES.map((cat) => ( - toggleCategory(cat.key)} - activeOpacity={0.7} - > - {cat.icon} - + )} + + {!!error && ( + + {error} + + )} + + {!!submitError && ( + + {submitError} + + )} + + {!isLoading && ( + + {(categories || []).map(cat => ( + toggleCategory(cat.id)} + activeOpacity={0.7} > - {cat.label} - - - ))} - + {cat.name} + + ))} + + )} @@ -81,8 +161,9 @@ export const PreferencesScreen: React.FC = () => { navigation.navigate('OnboardingCompleteScreen')} + title={isSubmitting ? 'Please wait…' : 'Continue'} + onPress={handleContinue} + disabled={isSubmitting} style={{ marginTop: 32 }} /> diff --git a/app/features/screens/preferencesScreen/reducer.ts b/app/features/screens/preferencesScreen/reducer.ts new file mode 100644 index 0000000..b7a2baa --- /dev/null +++ b/app/features/screens/preferencesScreen/reducer.ts @@ -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; diff --git a/app/features/screens/preferencesScreen/thunk.ts b/app/features/screens/preferencesScreen/thunk.ts new file mode 100644 index 0000000..f3e7d61 --- /dev/null +++ b/app/features/screens/preferencesScreen/thunk.ts @@ -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); + } + }, +); diff --git a/app/features/screens/providerDetailsScreen/index.ts b/app/features/screens/providerDetailsScreen/index.ts index a976177..40e09d6 100644 --- a/app/features/screens/providerDetailsScreen/index.ts +++ b/app/features/screens/providerDetailsScreen/index.ts @@ -1 +1,3 @@ export * from './providerDetailsScreen'; +export { default as providerDetailsReducer } from './reducer'; +export * from './thunk'; diff --git a/app/features/screens/providerDetailsScreen/providerDetailsScreen.styles.ts b/app/features/screens/providerDetailsScreen/providerDetailsScreen.styles.ts index cec6da1..ee040ac 100644 --- a/app/features/screens/providerDetailsScreen/providerDetailsScreen.styles.ts +++ b/app/features/screens/providerDetailsScreen/providerDetailsScreen.styles.ts @@ -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, }, }); diff --git a/app/features/screens/providerDetailsScreen/providerDetailsScreen.tsx b/app/features/screens/providerDetailsScreen/providerDetailsScreen.tsx index cd69d41..387abdd 100644 --- a/app/features/screens/providerDetailsScreen/providerDetailsScreen.tsx +++ b/app/features/screens/providerDetailsScreen/providerDetailsScreen.tsx @@ -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; - imageUrl?: string; - onBack: () => void; -} - -const HeroSection: React.FC = ({ - styles, - imageUrl, - onBack, -}) => ( - - {imageUrl ? ( - - - - ) : ( - - 🍽️ - - - )} - - - - ← - - - - πŸ”— - - - 🀍 - - - - -); - -interface InfoCardProps { - styles: ReturnType; - 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 = ({ - styles, - name, - rating, - deliveryTime, - tag, -}) => ( - - - - {name} - - - ⭐ - {rating.toFixed(1)} - - - - {tag} β€’ Multi-cuisine - - - - πŸ• - {deliveryTime} - - - - πŸ“ - 2.4 km away - - - - - - Open now - β€’ Closes 11:30 PM - - -); - -interface OffersRowProps { - styles: ReturnType; -} - -const OffersRow: React.FC = ({ styles }) => ( - - - {OFFERS.map(offer => ( - - {offer.icon} - {offer.label} - - ))} - - -); - -interface CategoryTabsProps { - styles: ReturnType; - categories: string[]; - activeCategory: string; - onSelect: (category: string) => void; -} - -const CategoryTabs: React.FC = ({ - styles, - categories, - activeCategory, - onSelect, -}) => ( - - {categories.map(cat => { - const isActive = activeCategory === cat; - return ( - onSelect(cat)} - activeOpacity={0.75} - > - - {cat} - - - ); - })} - -); - -interface CartStripProps { - styles: ReturnType; - totalItems: number; - totalPrice: number; - onPress: () => void; -} - -const CartStrip: React.FC = ({ - styles, - totalItems, - totalPrice, - onPress, -}) => ( - - - - πŸ›οΈ - - - {totalItems} item{totalItems === 1 ? '' : 's'} - - β‚Ή{totalPrice} - - - - View Cart - β†’ - - - -); - -// --------------------------------------------------------------------------- -// Screen -// --------------------------------------------------------------------------- +type RatingBreakdown = Partial>; export const ProviderDetailsScreen: React.FC = () => { const { colors } = useAppTheme(); @@ -246,65 +57,314 @@ export const ProviderDetailsScreen: React.FC = () => { const dispatch = useAppDispatch(); const navigation = useNavigation(); const route = useRoute(); - 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(null); - const [catalog, setCatalog] = useState([]); - 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) => { + 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 ( + + {[1, 2, 3, 4, 5].map(i => ( + + {i <= rounded ? 'β˜…' : 'β˜†'} + + ))} + + ); + }; + + const renderImages = () => { + if (isLoading) { + return ( + + + + ); + } + + const images = product?.media?.length + ? product.media + : [ + { + id: 'fallback', + url: product?.imageUrl || '', + mediaType: 'IMAGE', + sortOrder: 0, + productId: '', + createdAt: '', + }, + ]; + + return ( + + + {images.map(img => ( + + ))} + + {images.length > 1 && ( + + {images.map((_, i) => ( + + ))} + + )} + + ); + }; + + const renderProductInfo = () => { + if (isLoading || !product) { + return ( + + + + + + + ); + } + + const discount = getDiscountPercentage( + product.price, + product.compareAtPrice, + ); + const currency = product.currency === 'INR' ? 'β‚Ή' : product.currency; + + return ( + + + + {product.brand || product.merchant?.name || 'BRAND'} + + + ⭐ + + {parseFloat(product.avgRating).toFixed(1)} + + + + + {product.name} + + + + {formatPrice(product.price, currency)} + + {product.compareAtPrice && + parseFloat(product.compareAtPrice) > parseFloat(product.price) && ( + + {formatPrice(product.compareAtPrice, currency)} + + )} + {discount && ( + + {discount} + + )} + + Inclusive of all taxes + + ); + }; + + const renderRatingsSection = () => { + if (isLoading) { + return ( + + + + + ); + } + + 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 ( + + + Ratings & Reviews + {reviews.length > 3 && ( + + See all + + )} + + + {hasRatings ? ( + + + {avgRating.toFixed(1)} + {renderStars(avgRating, 16)} + + {totalRatings} {totalRatings === 1 ? 'rating' : 'ratings'} + + + + {breakdown && ( + + {[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 ( + + {star}β˜… + + + + + ); + })} + + )} + + ) : ( + + ⭐ + No ratings yet + + Be the first to share what you think of this product. + + + Rate this product + + + )} + + {reviews.length > 0 && ( + + {reviews.slice(0, 3).map(review => ( + + + + + {(review.userName || 'U').charAt(0).toUpperCase()} + + + + + {review.userName || 'Anonymous'} + + {renderStars(review.rating, 12)} + + + {review.createdAt + ? new Date(review.createdAt).toLocaleDateString('en-IN', { + day: 'numeric', + month: 'short', + }) + : ''} + + + {!!review.comment && ( + {review.comment} + )} + + ))} + + )} + + ); + }; + return ( { contentContainerStyle={styles.contentBody} showsVerticalScrollIndicator={false} > - navigation.goBack()} - /> + + {renderImages()} - + + navigation.goBack()} + > + ← + + + + πŸ”— + + + 🀍 + + + + - + {renderProductInfo()} - - Menu - {catalog.length} items - + + Product Details + + {product?.description || + 'No description available for this product.'} + - - - - {filteredItems.length === 0 && ( - - 🍽️ - - No items in this category yet + + + Category + + {product?.category?.name || 'N/A'} - )} - - {filteredItems.map(item => ( - handleAddItem(item)} - onRemove={() => {}} - /> - ))} + + SKU + {product?.sku || 'N/A'} + + + Stock + + {product?.stockQuantity ? 'In Stock' : 'Out of Stock'} + + + + Merchant + + {product?.merchant?.name || 'N/A'} + + + + + + + {renderRatingsSection()} - {totalItems > 0 && ( - navigation.navigate('CartScreen')} - /> - )} + {/* Sticky Bottom Bar */} + + {isInCart ? ( + // ── Already in cart: show inline qty stepper + Go to Cart ── + <> + + + - + + {localQty} + + + + + + + navigation.navigate('CartScreen')} + > + Go to Cart + + + ) : ( + // ── Not in cart: qty picker + Add to Cart ── + <> + + setLocalQty(q => Math.max(0, q - 1))} + activeOpacity={0.7} + disabled={localQty <= 0} + > + - + + {localQty} + setLocalQty(q => q + 1)} + activeOpacity={0.7} + > + + + + + + + + {!product + ? 'Loading...' + : product.isTrackStock && product.stockQuantity === 0 + ? 'Out of Stock' + : 'Add to Cart'} + + + + )} + ); }; diff --git a/app/features/screens/providerDetailsScreen/reducer.ts b/app/features/screens/providerDetailsScreen/reducer.ts new file mode 100644 index 0000000..b97554f --- /dev/null +++ b/app/features/screens/providerDetailsScreen/reducer.ts @@ -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; diff --git a/app/features/screens/providerDetailsScreen/thunk.ts b/app/features/screens/providerDetailsScreen/thunk.ts new file mode 100644 index 0000000..ad8d36a --- /dev/null +++ b/app/features/screens/providerDetailsScreen/thunk.ts @@ -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', + ); + } +}); diff --git a/app/features/screens/setLocationScreen/index.ts b/app/features/screens/setLocationScreen/index.ts index 1bd476b..d55e480 100644 --- a/app/features/screens/setLocationScreen/index.ts +++ b/app/features/screens/setLocationScreen/index.ts @@ -1 +1,2 @@ export * from './setLocationScreen'; +export * from './reducer'; diff --git a/app/features/screens/setLocationScreen/reducer.ts b/app/features/screens/setLocationScreen/reducer.ts new file mode 100644 index 0000000..f7dc29e --- /dev/null +++ b/app/features/screens/setLocationScreen/reducer.ts @@ -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; diff --git a/app/features/screens/setLocationScreen/setLocationScreen.tsx b/app/features/screens/setLocationScreen/setLocationScreen.tsx index 7fba6c0..a401953 100644 --- a/app/features/screens/setLocationScreen/setLocationScreen.tsx +++ b/app/features/screens/setLocationScreen/setLocationScreen.tsx @@ -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; +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(); + const dispatch = useAppDispatch(); const mapRef = useRef(null); // Guard: never call setState after the component has unmounted const isMounted = useRef(true); @@ -149,7 +156,16 @@ export const SetLocationScreen: React.FC = () => { navigation.navigate('CompleteProfileScreen')} + onPress={() => { + dispatch( + setLocationData({ + latitude: coords.latitude, + longitude: coords.longitude, + mapAddress: address, + }), + ); + navigation.navigate('CompleteProfileScreen'); + }} style={styles.confirmButton} /> diff --git a/app/features/screens/setLocationScreen/thunk.ts b/app/features/screens/setLocationScreen/thunk.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/features/screens/writeReviewScreen/index.ts b/app/features/screens/writeReviewScreen/index.ts new file mode 100644 index 0000000..ae2895b --- /dev/null +++ b/app/features/screens/writeReviewScreen/index.ts @@ -0,0 +1 @@ +export * from './writeReviewScreen'; diff --git a/app/features/screens/writeReviewScreen/writeReviewScreen.styles.ts b/app/features/screens/writeReviewScreen/writeReviewScreen.styles.ts new file mode 100644 index 0000000..5f05676 --- /dev/null +++ b/app/features/screens/writeReviewScreen/writeReviewScreen.styles.ts @@ -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, + }, +}); diff --git a/app/features/screens/writeReviewScreen/writeReviewScreen.tsx b/app/features/screens/writeReviewScreen/writeReviewScreen.tsx new file mode 100644 index 0000000..4eec3be --- /dev/null +++ b/app/features/screens/writeReviewScreen/writeReviewScreen.tsx @@ -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(); + const [rating, setRating] = useState(0); + + return ( + +
navigation.navigate('MainTabs')} + /> + + πŸŽ‰ + Your order has been delivered! + How was your experience? + + + + navigation.navigate('MainTabs')} + disabled={rating === 0} + style={{ marginTop: 32 }} + /> + + + ); +}; diff --git a/app/interfaces/cart.ts b/app/interfaces/cart.ts new file mode 100644 index 0000000..3445ea9 --- /dev/null +++ b/app/interfaces/cart.ts @@ -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; + 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; +} diff --git a/app/interfaces/index.ts b/app/interfaces/index.ts index f1c8719..b28f55c 100644 --- a/app/interfaces/index.ts +++ b/app/interfaces/index.ts @@ -90,3 +90,6 @@ export interface PaymentMethod { } export * from './auth'; +export * from './onboard'; +export * from './product'; +export * from './cart'; diff --git a/app/interfaces/onboard.ts b/app/interfaces/onboard.ts new file mode 100644 index 0000000..a9788a7 --- /dev/null +++ b/app/interfaces/onboard.ts @@ -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; +} diff --git a/app/interfaces/product.ts b/app/interfaces/product.ts new file mode 100644 index 0000000..6c2a6f3 --- /dev/null +++ b/app/interfaces/product.ts @@ -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', +} diff --git a/app/navigation/appStack.tsx b/app/navigation/appStack.tsx index ac4230b..8fb7a61 100644 --- a/app/navigation/appStack.tsx +++ b/app/navigation/appStack.tsx @@ -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(); @@ -36,15 +38,34 @@ export const AppStack: React.FC = () => { - + - - - - + + + + - + + ); }; diff --git a/app/navigation/authStack.tsx b/app/navigation/authStack.tsx index 27c69d7..54c041f 100644 --- a/app/navigation/authStack.tsx +++ b/app/navigation/authStack.tsx @@ -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(); @@ -25,10 +25,6 @@ export const AuthStack: React.FC = () => { - - - - ); }; diff --git a/app/navigation/onboardingStack.tsx b/app/navigation/onboardingStack.tsx new file mode 100644 index 0000000..1200ad4 --- /dev/null +++ b/app/navigation/onboardingStack.tsx @@ -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(); + +export const OnboardingStack: React.FC = () => { + return ( + + + + + + + ); +}; diff --git a/app/navigation/rootNavigator.tsx b/app/navigation/rootNavigator.tsx index 43127d0..fb6dc3c 100644 --- a/app/navigation/rootNavigator.tsx +++ b/app/navigation/rootNavigator.tsx @@ -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 ; } + if (isOnboarding) { + return ; + } + return ; }; diff --git a/app/services/apiClient.ts b/app/services/apiClient.ts index 901c71a..76778a8 100644 --- a/app/services/apiClient.ts +++ b/app/services/apiClient.ts @@ -15,7 +15,7 @@ const STORAGE_KEYS = { } as const; // ─── Config ────────────────────────────────────────────────────────────────── -const BASE_URL = 'https://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 = { diff --git a/app/store/commonreducers/auth/index.ts b/app/store/commonreducers/auth/index.ts index b16cae8..2684094 100644 --- a/app/store/commonreducers/auth/index.ts +++ b/app/store/commonreducers/auth/index.ts @@ -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'; diff --git a/app/store/commonreducers/auth/reducer.ts b/app/store/commonreducers/auth/reducer.ts index 2f12838..1d34a4e 100644 --- a/app/store/commonreducers/auth/reducer.ts +++ b/app/store/commonreducers/auth/reducer.ts @@ -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; }); }); diff --git a/app/store/commonreducers/cart/index.ts b/app/store/commonreducers/cart/index.ts index 07d1dde..b77d777 100644 --- a/app/store/commonreducers/cart/index.ts +++ b/app/store/commonreducers/cart/index.ts @@ -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'; diff --git a/app/store/commonreducers/cart/reducer.ts b/app/store/commonreducers/cart/reducer.ts index 0d7a2f4..b7f9d9a 100644 --- a/app/store/commonreducers/cart/reducer.ts +++ b/app/store/commonreducers/cart/reducer.ts @@ -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('cart/removeItem'); -export const clearCart = createAction('cart/clearCart'); -export const applyCoupon = createAction('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'; }); }); diff --git a/app/store/commonreducers/cart/thunk.ts b/app/store/commonreducers/cart/thunk.ts index c6e601d..c18dcc1 100644 --- a/app/store/commonreducers/cart/thunk.ts +++ b/app/store/commonreducers/cart/thunk.ts @@ -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( + '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( + '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); + } + }, +); diff --git a/app/store/rootReducer.ts b/app/store/rootReducer.ts index 2af31bc..d290838 100644 --- a/app/store/rootReducer.ts +++ b/app/store/rootReducer.ts @@ -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; diff --git a/app/utils/helper.ts b/app/utils/helper.ts new file mode 100644 index 0000000..119b66c --- /dev/null +++ b/app/utils/helper.ts @@ -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; +}; diff --git a/babel.config.js b/babel.config.js index 3fcb3d5..58622ed 100644 --- a/babel.config.js +++ b/babel.config.js @@ -13,6 +13,8 @@ module.exports = { '@navigation': './app/navigation', '@services': './app/services', '@store': './app/store', + '@api': './app/api', + '@utils': './app/utils', }, }, ], diff --git a/tsconfig.json b/tsconfig.json index c59ec17..1af74f6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"],