Compare commits
20 Commits
main
...
feat(api-i
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f53fe51c7 | |||
| 48c2ac64c5 | |||
| 4331243da6 | |||
| 1aed769b62 | |||
| 495c1f5f6b | |||
| eecbf9484c | |||
| 8c443d76eb | |||
| da1feb91e4 | |||
| 40b21a4446 | |||
| bed28f2310 | |||
| b6af3c8d24 | |||
| 6259c5b740 | |||
| 022fa69c0a | |||
| 5b8bc3d88a | |||
| c7dbdd3b8a | |||
|
|
0183e2694e | ||
| c4c3c22151 | |||
| d7c2254d2e | |||
| 0806289a81 | |||
| 7a0fd59892 |
4
.env
Normal file
4
.env
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
STRIPE_SECRET_KEY=sk_test_51Ox04DSHlFQYe8R5HXy6nj0eQqtqAP4ynF7ODFg71ork78B38MPsDV3gQEo2EYaFL9OG75L8tG7bKxptsmVeONrS00ji44eUIl
|
||||||
|
STRIPE_PUBLISHABLE_KEY=pk_test_51Ox04DSHlFQYe8R5Cvm8i6n99QynUNj7WQzACB89PImnt0X8Z54SBFM22ghSNHYFo7OgVcyba9QhhyrrdRqRPJpF00Us8XJv7f
|
||||||
|
RAZORPAY_KEY_ID=rzp_test_TCxbW8AxcXgMCj
|
||||||
|
RAZORPAY_KEY_SECRET=rLlM87yFehhsVNSqjCE2qRz5
|
||||||
5
ReactotronConfig.js
Normal file
5
ReactotronConfig.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import Reactotron from 'reactotron-react-native';
|
||||||
|
|
||||||
|
Reactotron.configure() // controls connection & communication settings
|
||||||
|
.useReactNative() // add all built-in react native plugins
|
||||||
|
.connect(); // let's connect!
|
||||||
@ -117,3 +117,5 @@ dependencies {
|
|||||||
implementation jscFlavor
|
implementation jscFlavor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apply from: file("../../node_modules/react-native-vector-icons/fonts.gradle")
|
||||||
43
app/App.tsx
43
app/App.tsx
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { StatusBar, useColorScheme } from 'react-native';
|
import { StatusBar, StyleSheet, useColorScheme, View } from 'react-native';
|
||||||
import {
|
import {
|
||||||
initialWindowMetrics,
|
initialWindowMetrics,
|
||||||
SafeAreaProvider,
|
SafeAreaProvider,
|
||||||
@ -10,22 +10,51 @@ import { Provider } from 'react-redux';
|
|||||||
import { persistor, store } from './store';
|
import { persistor, store } from './store';
|
||||||
import { RootNavigator } from './navigation/rootNavigator';
|
import { RootNavigator } from './navigation/rootNavigator';
|
||||||
import { PersistGate } from 'redux-persist/integration/react';
|
import { PersistGate } from 'redux-persist/integration/react';
|
||||||
|
import { colors } from '@theme';
|
||||||
|
import { StripeProvider } from '@stripe/stripe-react-native';
|
||||||
|
import { ENV } from './config';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const isDarkMode = useColorScheme() === 'dark';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<PersistGate loading={null} persistor={persistor}>
|
<PersistGate loading={null} persistor={persistor}>
|
||||||
|
<StripeProvider publishableKey={ENV.STRIPE_PUBLISHABLE_KEY}>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
<AppContent />
|
||||||
<NavigationContainer>
|
|
||||||
<RootNavigator />
|
|
||||||
</NavigationContainer>
|
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
</StripeProvider>
|
||||||
</PersistGate>
|
</PersistGate>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const isDarkMode = useColorScheme() === 'dark';
|
||||||
|
const safeAreaInsets = useSafeAreaInsets(); // ✅ now inside SafeAreaProvider
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
paddingTop: safeAreaInsets.top,
|
||||||
|
paddingBottom: safeAreaInsets.bottom,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
||||||
|
<NavigationContainer>
|
||||||
|
<RootNavigator />
|
||||||
|
</NavigationContainer>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@ -1,20 +1,8 @@
|
|||||||
import { apiClient } from '../services/apiClient';
|
import { apiClient } from '../services/apiClient';
|
||||||
import { User } from '../interfaces';
|
import { LoginResponse, User, VerifyOtpResponse } from '../interfaces';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface LoginResponse {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VerifyOtpResponse {
|
|
||||||
success: boolean;
|
|
||||||
user: User;
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateProfileResponse {
|
export interface UpdateProfileResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
user: User;
|
user: User;
|
||||||
@ -26,11 +14,11 @@ export interface LogoutResponse {
|
|||||||
|
|
||||||
// ─── Endpoints ───────────────────────────────────────────────────────────────
|
// ─── Endpoints ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const loginApi = (mobileNumber: string) =>
|
export const loginApi = (phone: string) =>
|
||||||
apiClient.post<LoginResponse>('/auth/login', { mobileNumber });
|
apiClient.post<LoginResponse>('/auth/otp/request', { phone });
|
||||||
|
|
||||||
export const verifyOtpApi = (mobileNumber: string, otp: string) =>
|
export const verifyOtpApi = (phone: string, code: string, role: string) =>
|
||||||
apiClient.post<VerifyOtpResponse>('/auth/verify-otp', { mobileNumber, otp });
|
apiClient.post<VerifyOtpResponse>('/auth/otp/verify', { phone, code, role });
|
||||||
|
|
||||||
export const updateProfileApi = (data: {
|
export const updateProfileApi = (data: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
16
app/api/cartApi.ts
Normal file
16
app/api/cartApi.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { AddToCartRequest, CartResponse } from '@interfaces/cart';
|
||||||
|
import { apiClient } from '@services';
|
||||||
|
|
||||||
|
export const addToCartApi = async (addToCartRequest: AddToCartRequest) => {
|
||||||
|
return apiClient.post<CartResponse>('/cart/items', addToCartRequest);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCartApi = async () => {
|
||||||
|
return apiClient.get<CartResponse>('/cart');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCartItem = async (productId: string, quantity: number) => {
|
||||||
|
return apiClient.patch<CartResponse>(`/cart/items/${productId}`, {
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
|
};
|
||||||
18
app/api/customerDetailsApi.ts
Normal file
18
app/api/customerDetailsApi.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import {
|
||||||
|
CustomerResponse,
|
||||||
|
WalletResponse,
|
||||||
|
WalletTransaction,
|
||||||
|
} from '@interfaces';
|
||||||
|
import { apiClient } from '@services';
|
||||||
|
|
||||||
|
export const getCustomerDetails = async () => {
|
||||||
|
return await apiClient.get<CustomerResponse>('/customers/profile');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWalletDetails = async (): Promise<WalletResponse> => {
|
||||||
|
return await apiClient.get<WalletResponse>('/wallets/balance');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWalletTransactions = async (): Promise<WalletTransaction[]> => {
|
||||||
|
return await apiClient.get<WalletTransaction[]>('/wallets/transactions');
|
||||||
|
};
|
||||||
@ -1,5 +1,11 @@
|
|||||||
import { apiClient } from '../services/apiClient';
|
import { apiClient } from '../services/apiClient';
|
||||||
import { Provider, CatalogItem, Order, DeliveryAgent, Location } from '../interfaces';
|
import {
|
||||||
|
Provider,
|
||||||
|
CatalogItem,
|
||||||
|
Order,
|
||||||
|
DeliveryAgent,
|
||||||
|
Location,
|
||||||
|
} from '../interfaces';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -10,8 +16,7 @@ export interface PlaceOrderResponse {
|
|||||||
|
|
||||||
// ─── Endpoints ───────────────────────────────────────────────────────────────
|
// ─── Endpoints ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const getProvidersApi = () =>
|
export const getProvidersApi = () => apiClient.get<Provider[]>('/providers');
|
||||||
apiClient.get<Provider[]>('/providers');
|
|
||||||
|
|
||||||
export const getProviderCatalogApi = (providerId: string) =>
|
export const getProviderCatalogApi = (providerId: string) =>
|
||||||
apiClient.get<CatalogItem[]>(`/providers/${providerId}/catalog`);
|
apiClient.get<CatalogItem[]>(`/providers/${providerId}/catalog`);
|
||||||
@ -19,8 +24,8 @@ export const getProviderCatalogApi = (providerId: string) =>
|
|||||||
export const searchProvidersApi = (query: string) =>
|
export const searchProvidersApi = (query: string) =>
|
||||||
apiClient.get<Provider[]>(`/search?q=${query}`);
|
apiClient.get<Provider[]>(`/search?q=${query}`);
|
||||||
|
|
||||||
export const placeOrderApi = (orderData: Partial<Order>) =>
|
// export const placeOrderApi = (orderData: Partial<Order>) =>
|
||||||
apiClient.post<PlaceOrderResponse>('/orders', orderData);
|
// apiClient.post<PlaceOrderResponse>('/orders', orderData);
|
||||||
|
|
||||||
export const getDeliveryAgentApi = () =>
|
export const getDeliveryAgentApi = () =>
|
||||||
apiClient.get<DeliveryAgent>('/delivery/agent');
|
apiClient.get<DeliveryAgent>('/delivery/agent');
|
||||||
|
|||||||
@ -1,2 +1,12 @@
|
|||||||
export * from './authApi';
|
export * from './authApi';
|
||||||
export * from './deliveryApi';
|
export * from './deliveryApi';
|
||||||
|
export * from './onboardApi';
|
||||||
|
export * from './productApi';
|
||||||
|
export * from './cartApi';
|
||||||
|
export * from './paymentMethodsApi';
|
||||||
|
export * from './customerDetailsApi';
|
||||||
|
export * from './orderApi';
|
||||||
|
export * from './offerApi';
|
||||||
|
export * from './reviewApi';
|
||||||
|
export * from './walletApi';
|
||||||
|
export * from './supportApi';
|
||||||
9
app/api/offerApi.ts
Normal file
9
app/api/offerApi.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { apiClient } from '@services';
|
||||||
|
import { PromotionResponse } from '@interfaces';
|
||||||
|
|
||||||
|
export const fetchOffers = async (): Promise<PromotionResponse> => {
|
||||||
|
const response = await apiClient.get<PromotionResponse>(
|
||||||
|
'/promotions/customer/offers',
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
12
app/api/onboardApi.ts
Normal file
12
app/api/onboardApi.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Category, onBoardPayload, UserProfile } from '@interfaces/onboard';
|
||||||
|
import { apiClient } from '@services';
|
||||||
|
|
||||||
|
export const getCatagories = async (): Promise<Category[]> => {
|
||||||
|
return await apiClient.get('/categories');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onBoardComplete = async (
|
||||||
|
payload: onBoardPayload,
|
||||||
|
): Promise<UserProfile> => {
|
||||||
|
return await apiClient.post('/auth/onboard', payload);
|
||||||
|
};
|
||||||
14
app/api/orderApi.ts
Normal file
14
app/api/orderApi.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { apiClient } from '@services';
|
||||||
|
import { Order, OrderRequest, PlaceOrderResponse } from '@interfaces';
|
||||||
|
|
||||||
|
export const placeOrderApi = async (payload: OrderRequest) => {
|
||||||
|
return await apiClient.post<PlaceOrderResponse>('/orders', payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOrderHistoryApi = async () => {
|
||||||
|
return await apiClient.get<Order[]>('/orders');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOrderByIdApi = async (orderId: string) => {
|
||||||
|
return await apiClient.get<Order>(`/orders/${orderId}`);
|
||||||
|
};
|
||||||
24
app/api/paymentMethodsApi.ts
Normal file
24
app/api/paymentMethodsApi.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { PaymentMethodsResponse } from '@interfaces';
|
||||||
|
import { apiClient } from '@services';
|
||||||
|
|
||||||
|
export const getAllPaymentMethodsApi = async () => {
|
||||||
|
return await apiClient.get<PaymentMethodsResponse>('/payments/options');
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface VerifyPaymentPayload {
|
||||||
|
gatewayOrderId: string;
|
||||||
|
gatewayPaymentId: string;
|
||||||
|
gatewaySignature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyPaymentResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const verifyPaymentApi = async (payload: VerifyPaymentPayload) => {
|
||||||
|
return await apiClient.post<VerifyPaymentResponse>(
|
||||||
|
'/payments/process',
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
};
|
||||||
32
app/api/productApi.ts
Normal file
32
app/api/productApi.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
Products,
|
||||||
|
PaginationMeta,
|
||||||
|
Product,
|
||||||
|
CategoriesResponse,
|
||||||
|
} from '@interfaces';
|
||||||
|
import { apiClient } from '@services';
|
||||||
|
|
||||||
|
export interface GetProductsResponse {
|
||||||
|
products: Products[];
|
||||||
|
meta: PaginationMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getProductsApi = async (
|
||||||
|
categoryId?: string,
|
||||||
|
): Promise<GetProductsResponse> => {
|
||||||
|
return await apiClient.get<GetProductsResponse>(`/products`, {
|
||||||
|
params: {
|
||||||
|
categoryId: categoryId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProductDetailsApi = async (
|
||||||
|
productId: string,
|
||||||
|
): Promise<Product> => {
|
||||||
|
return await apiClient.get<Product>(`/products/${productId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCategoriesApi = async (): Promise<CategoriesResponse> => {
|
||||||
|
return await apiClient.get<CategoriesResponse>(`/categories/selected`);
|
||||||
|
};
|
||||||
7
app/api/reviewApi.ts
Normal file
7
app/api/reviewApi.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { giveRatingPayload } from '@interfaces';
|
||||||
|
import { apiClient } from '@services';
|
||||||
|
|
||||||
|
export const giveRatingApi = async (id: string, payload: giveRatingPayload) => {
|
||||||
|
const response = await apiClient.post(`/products/${id}/ratings`, payload);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
48
app/api/supportApi.ts
Normal file
48
app/api/supportApi.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { apiClient } from '@services';
|
||||||
|
import {
|
||||||
|
SupportTicket,
|
||||||
|
CreateTicketPayload,
|
||||||
|
PostMessagePayload,
|
||||||
|
SupportMessage,
|
||||||
|
} from '@interfaces';
|
||||||
|
|
||||||
|
export const createTicketApi = async (payload: CreateTicketPayload) => {
|
||||||
|
return await apiClient.post<SupportTicket>('/support/tickets', payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMyTicketsApi = async () => {
|
||||||
|
return await apiClient.get<SupportTicket[]>('/support/tickets');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTicketDetailsApi = async (ticketId: string) => {
|
||||||
|
return await apiClient.get<SupportTicket>(`/support/tickets/${ticketId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const postMessageApi = async (
|
||||||
|
ticketId: string,
|
||||||
|
payload: PostMessagePayload,
|
||||||
|
) => {
|
||||||
|
return await apiClient.post<SupportMessage>(
|
||||||
|
`/support/tickets/${ticketId}/messages`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reopenTicketApi = async (ticketId: string) => {
|
||||||
|
return await apiClient.patch<SupportTicket>(
|
||||||
|
`/support/tickets/${ticketId}/reopen`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadSupportAttachmentApi = async (formData: FormData) => {
|
||||||
|
return await apiClient.post<{ id: string; filename: string; url: string }>(
|
||||||
|
'/support/upload',
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
15
app/api/walletApi.ts
Normal file
15
app/api/walletApi.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { WalletTopUpPaymentSessionResponse } from "@interfaces";
|
||||||
|
import { apiClient } from "@services";
|
||||||
|
|
||||||
|
export interface TopupPayload {
|
||||||
|
amount: number,
|
||||||
|
paymentMethod: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initializeWalletTopUpApi = async (
|
||||||
|
payload: TopupPayload): Promise<WalletTopUpPaymentSessionResponse> => {
|
||||||
|
return await apiClient.post<WalletTopUpPaymentSessionResponse>(
|
||||||
|
'/wallets/topup',
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
import { OrderStatus, TrackingEntry } from '@interfaces';
|
||||||
|
|
||||||
|
export interface OrderStatusTimelineProps {
|
||||||
|
tracking?: TrackingEntry[];
|
||||||
|
currentStatus: OrderStatus;
|
||||||
|
styles?: any; // optional – component uses its own internal styles via useAppTheme
|
||||||
|
deliveryPartner?: { name: string; phone: string; }
|
||||||
|
}
|
||||||
317
app/components/OrderStatusTimeline/OrderStatusTimeline.styles.ts
Normal file
317
app/components/OrderStatusTimeline/OrderStatusTimeline.styles.ts
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
import { typography } from '@theme';
|
||||||
|
|
||||||
|
export const getStyles = (colors: any) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
// Status Card / Container
|
||||||
|
cardContainer: {
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
headerSection: {
|
||||||
|
padding: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
headerIconContainer: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: '#E8F5E9',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
headerIconText: {
|
||||||
|
fontSize: 24,
|
||||||
|
},
|
||||||
|
headerTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
statusHeaderTitle: {
|
||||||
|
fontSize: typography.fontSize.lg,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
statusHeaderDesc: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Horizontal Stepper
|
||||||
|
stepperWrapper: {
|
||||||
|
paddingVertical: 20,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
stepperRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
stepLineBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 14,
|
||||||
|
left: '12%',
|
||||||
|
right: '12%',
|
||||||
|
height: 3,
|
||||||
|
backgroundColor: colors.border,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
stepLineActive: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 14,
|
||||||
|
left: '12%',
|
||||||
|
height: 3,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
stepItem: {
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
zIndex: 3,
|
||||||
|
},
|
||||||
|
stepDot: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.border,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
stepDotCompleted: {
|
||||||
|
borderColor: colors.primary,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
stepDotActive: {
|
||||||
|
borderColor: colors.primary,
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderWidth: 3,
|
||||||
|
},
|
||||||
|
stepCheckmark: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
stepInnerDotActive: {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
stepLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
stepLabelActive: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Driver Section
|
||||||
|
driverSection: {
|
||||||
|
padding: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
backgroundColor: colors.inputBg,
|
||||||
|
},
|
||||||
|
driverInfoRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
driverAvatarContainer: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
driverAvatarText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
},
|
||||||
|
driverMeta: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
driverRoleText: {
|
||||||
|
fontSize: 10,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
driverNameText: {
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
driverVehicleText: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
ratingBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFF9C4',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
ratingText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: '#F57F17',
|
||||||
|
},
|
||||||
|
driverActionsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
callButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
messageButton: {
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderColor: colors.border,
|
||||||
|
marginLeft: 6,
|
||||||
|
},
|
||||||
|
actionIcon: {
|
||||||
|
marginRight: 6,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
callButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
},
|
||||||
|
messageButtonText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Collapsible Trigger / Detailed Logs
|
||||||
|
toggleSection: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
toggleButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
toggleButtonText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.primary,
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
toggleChevron: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
detailedTimelineWrapper: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Legacy vertical timeline classes (re-styled for premium feel)
|
||||||
|
timelineRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
},
|
||||||
|
timelineDotColumn: {
|
||||||
|
alignItems: 'center',
|
||||||
|
width: 24,
|
||||||
|
},
|
||||||
|
timelineDot: {
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 7,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
timelineDotCompleted: {
|
||||||
|
borderColor: colors.primary,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
timelineDotCancelled: {
|
||||||
|
borderColor: colors.error,
|
||||||
|
backgroundColor: colors.error,
|
||||||
|
},
|
||||||
|
timelineCheck: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
timelineLine: {
|
||||||
|
width: 2,
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 36,
|
||||||
|
backgroundColor: colors.border,
|
||||||
|
marginVertical: 4,
|
||||||
|
},
|
||||||
|
timelineLineCompleted: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
timelineContent: {
|
||||||
|
flex: 1,
|
||||||
|
paddingBottom: 18,
|
||||||
|
paddingLeft: 16,
|
||||||
|
},
|
||||||
|
timelineLabelCompleted: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
timelineLabelPending: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.regular,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
timelineLabelCancelled: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.error,
|
||||||
|
},
|
||||||
|
timelineTime: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
375
app/components/OrderStatusTimeline/OrderStatusTimeline.tsx
Normal file
375
app/components/OrderStatusTimeline/OrderStatusTimeline.tsx
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
Linking,
|
||||||
|
Platform,
|
||||||
|
LayoutAnimation,
|
||||||
|
UIManager,
|
||||||
|
} from 'react-native';
|
||||||
|
import { OrderStatus, TrackingEntry } from '@interfaces';
|
||||||
|
import { formatDate, formatTime } from '@utils/helper';
|
||||||
|
import { OrderStatusTimelineProps } from './OrderStatusTimeline.props';
|
||||||
|
import { useAppTheme } from '@theme';
|
||||||
|
import { getStyles } from './OrderStatusTimeline.styles';
|
||||||
|
|
||||||
|
if (
|
||||||
|
Platform.OS === 'android' &&
|
||||||
|
UIManager.setLayoutAnimationEnabledExperimental
|
||||||
|
) {
|
||||||
|
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_FLOW: OrderStatus[] = [
|
||||||
|
'PENDING',
|
||||||
|
'CONFIRMED',
|
||||||
|
'PREPARING',
|
||||||
|
'READY_FOR_PICKUP',
|
||||||
|
'OUT_FOR_DELIVERY',
|
||||||
|
'DELIVERED',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STATUS_LABEL: Record<OrderStatus, string> = {
|
||||||
|
PENDING: 'Order Placed',
|
||||||
|
CONFIRMED: 'Confirmed',
|
||||||
|
PREPARING: 'Preparing',
|
||||||
|
READY_FOR_PICKUP: 'Ready for Pickup',
|
||||||
|
OUT_FOR_DELIVERY: 'Out for Delivery',
|
||||||
|
DELIVERED: 'Delivered',
|
||||||
|
CANCELLED: 'Cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_DETAILS: Record<
|
||||||
|
OrderStatus,
|
||||||
|
{ title: string; desc: string; icon: string }
|
||||||
|
> = {
|
||||||
|
PENDING: {
|
||||||
|
title: 'Waiting for Confirmation',
|
||||||
|
desc: 'The merchant is reviewing your order. We will start preparing it soon.',
|
||||||
|
icon: '⏳',
|
||||||
|
},
|
||||||
|
CONFIRMED: {
|
||||||
|
title: 'Order Confirmed',
|
||||||
|
desc: 'Your order has been accepted. The chef will start preparing it shortly.',
|
||||||
|
icon: '✅',
|
||||||
|
},
|
||||||
|
PREPARING: {
|
||||||
|
title: 'Preparing Your Food',
|
||||||
|
desc: 'The kitchen is preparing your delicious order with fresh ingredients.',
|
||||||
|
icon: '🍳',
|
||||||
|
},
|
||||||
|
READY_FOR_PICKUP: {
|
||||||
|
title: 'Ready for Pickup',
|
||||||
|
desc: 'Your order is ready. The delivery partner is picking it up now.',
|
||||||
|
icon: '📦',
|
||||||
|
},
|
||||||
|
OUT_FOR_DELIVERY: {
|
||||||
|
title: 'Out for Delivery',
|
||||||
|
desc: 'Our delivery partner is on the way with your food. Keep your phone handy!',
|
||||||
|
icon: '🛵',
|
||||||
|
},
|
||||||
|
DELIVERED: {
|
||||||
|
title: 'Order Delivered',
|
||||||
|
desc: 'Your order has been successfully delivered. Enjoy your meal!',
|
||||||
|
icon: '🎉',
|
||||||
|
},
|
||||||
|
CANCELLED: {
|
||||||
|
title: 'Order Cancelled',
|
||||||
|
desc: 'This order was cancelled. Any refund will be processed shortly.',
|
||||||
|
icon: '❌',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStepIndex = (status: OrderStatus): number => {
|
||||||
|
switch (status) {
|
||||||
|
case 'PENDING':
|
||||||
|
case 'CONFIRMED':
|
||||||
|
return 0;
|
||||||
|
case 'PREPARING':
|
||||||
|
return 1;
|
||||||
|
case 'READY_FOR_PICKUP':
|
||||||
|
case 'OUT_FOR_DELIVERY':
|
||||||
|
return 2;
|
||||||
|
case 'DELIVERED':
|
||||||
|
return 3;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrderStatusTimeline: React.FC<OrderStatusTimelineProps> = ({
|
||||||
|
tracking = [],
|
||||||
|
currentStatus,
|
||||||
|
styles: propStyles,
|
||||||
|
deliveryPartner,
|
||||||
|
}) => {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const localStyles = getStyles(colors);
|
||||||
|
|
||||||
|
// If the caller passes the screen styles, they won't have timelineRow etc, so we fallback to localStyles
|
||||||
|
const styles =
|
||||||
|
propStyles && propStyles.timelineRow ? propStyles : localStyles;
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const toggleExpand = () => {
|
||||||
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||||
|
setExpanded(!expanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCall = () => {
|
||||||
|
Linking.openURL(`tel:${deliveryPartner?.phone}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMessage = () => {
|
||||||
|
Linking.openURL(`sms:${deliveryPartner?.phone}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackingMap = tracking.reduce<Record<string, string>>((acc, t) => {
|
||||||
|
acc[t.status] = t.time;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const details = STATUS_DETAILS[currentStatus] || STATUS_DETAILS.PENDING;
|
||||||
|
|
||||||
|
if (currentStatus === 'CANCELLED') {
|
||||||
|
return (
|
||||||
|
<View style={localStyles.cardContainer}>
|
||||||
|
{/* Cancelled Status Banner */}
|
||||||
|
<View style={localStyles.headerSection}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
localStyles.headerIconContainer,
|
||||||
|
{ backgroundColor: '#FFEBEE' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={localStyles.headerIconText}>❌</Text>
|
||||||
|
</View>
|
||||||
|
<View style={localStyles.headerTextContainer}>
|
||||||
|
<Text
|
||||||
|
style={[localStyles.statusHeaderTitle, { color: colors.error }]}
|
||||||
|
>
|
||||||
|
{details.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={localStyles.statusHeaderDesc}>{details.desc}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Collapsible detailed view */}
|
||||||
|
<View style={localStyles.toggleSection}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={toggleExpand}
|
||||||
|
style={localStyles.toggleButton}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={localStyles.toggleButtonText}>
|
||||||
|
{expanded ? 'Hide Details' : 'View Detailed History'}
|
||||||
|
</Text>
|
||||||
|
<Text style={localStyles.toggleChevron}>
|
||||||
|
{expanded ? '▲' : '▼'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<View style={localStyles.detailedTimelineWrapper}>
|
||||||
|
<View style={styles.timelineRow}>
|
||||||
|
<View style={styles.timelineDotColumn}>
|
||||||
|
<View
|
||||||
|
style={[styles.timelineDot, styles.timelineDotCancelled]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.timelineContent}>
|
||||||
|
<Text style={styles.timelineLabelCancelled}>
|
||||||
|
Order Cancelled
|
||||||
|
</Text>
|
||||||
|
{trackingMap['CANCELLED'] && (
|
||||||
|
<Text style={styles.timelineTime}>
|
||||||
|
{formatDate(trackingMap['CANCELLED'])} at{' '}
|
||||||
|
{formatTime(trackingMap['CANCELLED'])}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeStep = getStepIndex(currentStatus);
|
||||||
|
const steps = [
|
||||||
|
{ label: 'Placed', status: 'PENDING' },
|
||||||
|
{ label: 'Preparing', status: 'PREPARING' },
|
||||||
|
{ label: 'On the Way', status: 'OUT_FOR_DELIVERY' },
|
||||||
|
{ label: 'Delivered', status: 'DELIVERED' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const showDriver = [
|
||||||
|
'OUT_FOR_DELIVERY'
|
||||||
|
].includes(currentStatus);
|
||||||
|
const currentIndex = STATUS_FLOW.indexOf(currentStatus);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={localStyles.cardContainer}>
|
||||||
|
{/* Current Status Header Banner */}
|
||||||
|
<View style={localStyles.headerSection}>
|
||||||
|
<View style={localStyles.headerIconContainer}>
|
||||||
|
<Text style={localStyles.headerIconText}>{details.icon}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={localStyles.headerTextContainer}>
|
||||||
|
<Text style={localStyles.statusHeaderTitle}>{details.title}</Text>
|
||||||
|
<Text style={localStyles.statusHeaderDesc}>{details.desc}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Horizontal Progress Stepper */}
|
||||||
|
<View style={localStyles.stepperWrapper}>
|
||||||
|
<View style={localStyles.stepperRow}>
|
||||||
|
<View style={localStyles.stepLineBackground} />
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
localStyles.stepLineActive,
|
||||||
|
{ width: `${(activeStep / (steps.length - 1)) * 76}%` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{steps.map((step, idx) => {
|
||||||
|
const isCompleted = idx < activeStep;
|
||||||
|
const isActive = idx === activeStep;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={step.label} style={localStyles.stepItem}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
localStyles.stepDot,
|
||||||
|
isCompleted && localStyles.stepDotCompleted,
|
||||||
|
isActive && localStyles.stepDotActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{isCompleted ? (
|
||||||
|
<Text style={localStyles.stepCheckmark}>✓</Text>
|
||||||
|
) : isActive ? (
|
||||||
|
<View style={localStyles.stepInnerDotActive} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
localStyles.stepLabel,
|
||||||
|
(isCompleted || isActive) && localStyles.stepLabelActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Delivery Partner Details (if driver assigned) */}
|
||||||
|
{showDriver && (
|
||||||
|
<View style={localStyles.driverSection}>
|
||||||
|
<View style={localStyles.driverInfoRow}>
|
||||||
|
<View style={localStyles.driverAvatarContainer}>
|
||||||
|
<Text style={localStyles.driverAvatarText}>{deliveryPartner?.name?.substring(0, 2)?.toUpperCase()}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={localStyles.driverMeta}>
|
||||||
|
<Text style={localStyles.driverRoleText}>Your Delivery Hero</Text>
|
||||||
|
<Text style={localStyles.driverNameText}>{deliveryPartner?.name}</Text>
|
||||||
|
<Text style={localStyles.driverVehicleText}>
|
||||||
|
{deliveryPartner?.phone}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={localStyles.ratingBadge}>
|
||||||
|
<Text style={localStyles.ratingText}>⭐ 4.8</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={localStyles.driverActionsRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[localStyles.actionButton, localStyles.callButton]}
|
||||||
|
onPress={handleCall}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={localStyles.actionIcon}>📞</Text>
|
||||||
|
<Text style={localStyles.callButtonText}>Call Partner</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[localStyles.actionButton, localStyles.messageButton]}
|
||||||
|
onPress={handleMessage}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={localStyles.actionIcon}>💬</Text>
|
||||||
|
<Text style={localStyles.messageButtonText}>Chat</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* View Detailed History Toggle */}
|
||||||
|
<View style={localStyles.toggleSection}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={toggleExpand}
|
||||||
|
style={localStyles.toggleButton}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={localStyles.toggleButtonText}>
|
||||||
|
{expanded ? 'Hide Details' : 'View Detailed History'}
|
||||||
|
</Text>
|
||||||
|
<Text style={localStyles.toggleChevron}>{expanded ? '▲' : '▼'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Detailed Vertical Logs */}
|
||||||
|
{expanded && (
|
||||||
|
<View style={localStyles.detailedTimelineWrapper}>
|
||||||
|
{STATUS_FLOW.map((status, index) => {
|
||||||
|
const isCompleted = index <= currentIndex;
|
||||||
|
const isLast = index === STATUS_FLOW.length - 1;
|
||||||
|
const time = trackingMap[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={status} style={styles.timelineRow}>
|
||||||
|
<View style={styles.timelineDotColumn}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.timelineDot,
|
||||||
|
isCompleted && styles.timelineDotCompleted,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{isCompleted && <Text style={styles.timelineCheck}>✓</Text>}
|
||||||
|
</View>
|
||||||
|
{!isLast && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.timelineLine,
|
||||||
|
index < currentIndex && styles.timelineLineCompleted,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.timelineContent}>
|
||||||
|
<Text
|
||||||
|
style={
|
||||||
|
isCompleted
|
||||||
|
? styles.timelineLabelCompleted
|
||||||
|
: styles.timelineLabelPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{STATUS_LABEL[status]}
|
||||||
|
</Text>
|
||||||
|
{time && (
|
||||||
|
<Text style={styles.timelineTime}>
|
||||||
|
{formatDate(time)} at {formatTime(time)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
2
app/components/OrderStatusTimeline/index.ts
Normal file
2
app/components/OrderStatusTimeline/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './OrderStatusTimeline';
|
||||||
|
export type * from './OrderStatusTimeline.props';
|
||||||
28
app/components/TrackingMap/TrackingMap.styles.ts
Normal file
28
app/components/TrackingMap/TrackingMap.styles.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
export const getStyles = (colors: any) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
...StyleSheet.absoluteFill,
|
||||||
|
},
|
||||||
|
driverMarker: {
|
||||||
|
backgroundColor: '#FFF',
|
||||||
|
padding: 6,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#FF7F00',
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
driverEmoji: {
|
||||||
|
fontSize: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
172
app/components/TrackingMap/TrackingMap.tsx
Normal file
172
app/components/TrackingMap/TrackingMap.tsx
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { View, Text } from 'react-native';
|
||||||
|
import MapView, { Marker, Polyline, PROVIDER_GOOGLE } from 'react-native-maps';
|
||||||
|
import { useAppTheme } from '@theme';
|
||||||
|
import { getRouteDirections, getHaversineDistance } from '@services';
|
||||||
|
import { getStyles } from './TrackingMap.styles';
|
||||||
|
|
||||||
|
interface TrackingMapProps {
|
||||||
|
customerDropoff: { latitude: number; longitude: number };
|
||||||
|
driverLocation: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
heading?: number;
|
||||||
|
} | null;
|
||||||
|
showRoute?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrackingMap: React.FC<TrackingMapProps> = ({
|
||||||
|
customerDropoff,
|
||||||
|
driverLocation,
|
||||||
|
showRoute = true,
|
||||||
|
}) => {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = getStyles(colors);
|
||||||
|
const mapRef = useRef<MapView>(null);
|
||||||
|
const [routeCoords, setRouteCoords] = useState<
|
||||||
|
{ latitude: number; longitude: number }[]
|
||||||
|
>([]);
|
||||||
|
const lastFetchedLocation = useRef<{
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const initialRegion = {
|
||||||
|
latitude: customerDropoff.latitude,
|
||||||
|
longitude: customerDropoff.longitude,
|
||||||
|
latitudeDelta: 0.02,
|
||||||
|
longitudeDelta: 0.02,
|
||||||
|
};
|
||||||
|
// console.log('[TrackingMap] driverLocation:', driverLocation);
|
||||||
|
// console.log('[TrackingMap] customerDropoff:', customerDropoff);
|
||||||
|
// console.log('[TrackingMap] routeCoords:', routeCoords);
|
||||||
|
// Fetch routing coordinates between driver and customer dropoff
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
if (showRoute && driverLocation && customerDropoff) {
|
||||||
|
const currentDriverPos = {
|
||||||
|
latitude: driverLocation.latitude,
|
||||||
|
longitude: driverLocation.longitude,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Limit routing API calls if the driver has moved less than 50 meters
|
||||||
|
if (lastFetchedLocation.current) {
|
||||||
|
const dist = getHaversineDistance(
|
||||||
|
currentDriverPos,
|
||||||
|
lastFetchedLocation.current,
|
||||||
|
);
|
||||||
|
if (dist < 0.05 && routeCoords.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getRouteDirections(currentDriverPos, customerDropoff)
|
||||||
|
.then(points => {
|
||||||
|
if (isMounted) {
|
||||||
|
setRouteCoords(points);
|
||||||
|
lastFetchedLocation.current = currentDriverPos;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(
|
||||||
|
'[TrackingMap] Error fetching routing directions:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
if (isMounted) {
|
||||||
|
// Fallback to straight line
|
||||||
|
setRouteCoords([currentDriverPos, customerDropoff]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setRouteCoords([]);
|
||||||
|
lastFetchedLocation.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
showRoute,
|
||||||
|
driverLocation?.latitude,
|
||||||
|
driverLocation?.longitude,
|
||||||
|
customerDropoff?.latitude,
|
||||||
|
customerDropoff?.longitude,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Adjust camera to fit both the customer dropoff and driver location
|
||||||
|
useEffect(() => {
|
||||||
|
const coordsToFit: { latitude: number; longitude: number }[] = [];
|
||||||
|
if (customerDropoff) {
|
||||||
|
coordsToFit.push(customerDropoff);
|
||||||
|
}
|
||||||
|
if (driverLocation) {
|
||||||
|
coordsToFit.push({
|
||||||
|
latitude: driverLocation.latitude,
|
||||||
|
longitude: driverLocation.longitude,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coordsToFit.length > 0 && mapRef.current) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
mapRef.current?.fitToCoordinates(coordsToFit, {
|
||||||
|
edgePadding: { top: 80, right: 80, bottom: 80, left: 80 },
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
customerDropoff?.latitude,
|
||||||
|
customerDropoff?.longitude,
|
||||||
|
driverLocation?.latitude,
|
||||||
|
driverLocation?.longitude,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<MapView
|
||||||
|
ref={mapRef}
|
||||||
|
provider={PROVIDER_GOOGLE}
|
||||||
|
style={styles.map}
|
||||||
|
initialRegion={initialRegion}
|
||||||
|
>
|
||||||
|
{/* Customer Dropoff Location Marker */}
|
||||||
|
{customerDropoff && (
|
||||||
|
<Marker
|
||||||
|
coordinate={customerDropoff}
|
||||||
|
title="Delivery Location"
|
||||||
|
pinColor="red"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live Driver Marker */}
|
||||||
|
{driverLocation && (
|
||||||
|
<Marker
|
||||||
|
coordinate={{
|
||||||
|
latitude: driverLocation.latitude,
|
||||||
|
longitude: driverLocation.longitude,
|
||||||
|
}}
|
||||||
|
title="Delivery Partner"
|
||||||
|
anchor={{ x: 0.5, y: 0.5 }}
|
||||||
|
rotation={driverLocation.heading ?? 0}
|
||||||
|
>
|
||||||
|
<View style={styles.driverMarker}>
|
||||||
|
<Text style={styles.driverEmoji}>🛵</Text>
|
||||||
|
</View>
|
||||||
|
</Marker>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live Routing Polyline */}
|
||||||
|
{showRoute && routeCoords.length > 0 && (
|
||||||
|
<Polyline
|
||||||
|
coordinates={routeCoords}
|
||||||
|
strokeColor={colors.primary}
|
||||||
|
strokeWidth={4}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</MapView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrackingMap;
|
||||||
1
app/components/TrackingMap/index.ts
Normal file
1
app/components/TrackingMap/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './TrackingMap';
|
||||||
@ -3,6 +3,7 @@ import { View, Text, TouchableOpacity } from 'react-native';
|
|||||||
import { HeaderProps } from './header.props';
|
import { HeaderProps } from './header.props';
|
||||||
import { getStyles } from './header.styles';
|
import { getStyles } from './header.styles';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
|
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
|
||||||
export const Header: React.FC<HeaderProps> = ({ title, onBack, rightComponent }) => {
|
export const Header: React.FC<HeaderProps> = ({ title, onBack, rightComponent }) => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
@ -13,7 +14,7 @@ export const Header: React.FC<HeaderProps> = ({ title, onBack, rightComponent })
|
|||||||
<View style={styles.backButton}>
|
<View style={styles.backButton}>
|
||||||
{onBack && (
|
{onBack && (
|
||||||
<TouchableOpacity onPress={onBack} activeOpacity={0.7}>
|
<TouchableOpacity onPress={onBack} activeOpacity={0.7}>
|
||||||
<Text style={styles.backText}>{'<'}</Text>
|
<MaterialIcons name="arrow-back" size={24} color="black" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export * from './socialButton';
|
|||||||
export * from './header';
|
export * from './header';
|
||||||
export * from './searchBar';
|
export * from './searchBar';
|
||||||
export * from './providerCard';
|
export * from './providerCard';
|
||||||
|
export * from './productCard';
|
||||||
export * from './catalogItemRow';
|
export * from './catalogItemRow';
|
||||||
export * from './stepProgress';
|
export * from './stepProgress';
|
||||||
export * from './badge';
|
export * from './badge';
|
||||||
@ -12,3 +13,6 @@ export * from './categoryChip';
|
|||||||
export * from './ratingStars';
|
export * from './ratingStars';
|
||||||
export * from './orderHistoryCard';
|
export * from './orderHistoryCard';
|
||||||
export * from './paymentOption';
|
export * from './paymentOption';
|
||||||
|
export * from './OrderStatusTimeline';
|
||||||
|
export * from './TrackingMap';
|
||||||
|
export * from './productRatingModal';
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
|
import { OrderItem } from '@interfaces';
|
||||||
|
|
||||||
export interface OrderHistoryCardProps {
|
export interface OrderHistoryCardProps {
|
||||||
providerName: string;
|
providerName: string;
|
||||||
providerImage: string;
|
providerImage: string;
|
||||||
orderDate: string;
|
orderDate: string;
|
||||||
status: string;
|
status: string;
|
||||||
total: number;
|
total: number;
|
||||||
onReorder?: () => void;
|
onTrack?: () => void;
|
||||||
onDetails?: () => void;
|
onDetails?: () => void;
|
||||||
|
items?: OrderItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,12 +53,42 @@ export const getStyles = (colors: any) => StyleSheet.create({
|
|||||||
fontWeight: typography.fontWeight.medium,
|
fontWeight: typography.fontWeight.medium,
|
||||||
color: colors.primary,
|
color: colors.primary,
|
||||||
},
|
},
|
||||||
|
itemsContainer: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
itemRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
itemQuantityBadge: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 4,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
marginRight: 8,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
itemQuantityText: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
},
|
||||||
|
itemName: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: 10,
|
paddingTop: 12,
|
||||||
paddingTop: 10,
|
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: colors.border,
|
borderTopColor: colors.border,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -10,8 +10,9 @@ export const OrderHistoryCard: React.FC<OrderHistoryCardProps> = ({
|
|||||||
orderDate,
|
orderDate,
|
||||||
status,
|
status,
|
||||||
total,
|
total,
|
||||||
onReorder,
|
onTrack,
|
||||||
onDetails,
|
onDetails,
|
||||||
|
items,
|
||||||
}) => {
|
}) => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
const styles = getStyles(colors);
|
const styles = getStyles(colors);
|
||||||
@ -30,16 +31,30 @@ export const OrderHistoryCard: React.FC<OrderHistoryCardProps> = ({
|
|||||||
<Text style={styles.statusText}>{status}</Text>
|
<Text style={styles.statusText}>{status}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
{items && items.length > 0 && (
|
||||||
|
<View style={styles.itemsContainer}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<View key={item.id || index} style={styles.itemRow}>
|
||||||
|
<View style={styles.itemQuantityBadge}>
|
||||||
|
<Text style={styles.itemQuantityText}>{item.quantity}x</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.itemName} numberOfLines={1}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<Text style={styles.total}>₹{total}</Text>
|
<Text style={styles.total}>₹{total}</Text>
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{onReorder && (
|
{onTrack && status === 'OUT_FOR_DELIVERY' && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.actionButton}
|
style={styles.actionButton}
|
||||||
onPress={onReorder}
|
onPress={onTrack}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={styles.actionButtonText}>Reorder</Text>
|
<Text style={styles.actionButtonText}>Track Order</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
{onDetails && (
|
{onDetails && (
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
export interface PaymentOptionProps {
|
export interface PaymentOptionProps {
|
||||||
type: string;
|
|
||||||
label: string;
|
label: string;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
icon?: string;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { StyleSheet } from 'react-native';
|
import { StyleSheet } from 'react-native';
|
||||||
import { typography } from '@theme';
|
import { typography } from '@theme';
|
||||||
|
|
||||||
export const getStyles = (colors: any) => StyleSheet.create({
|
export const getStyles = (colors: any) =>
|
||||||
|
StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -18,7 +19,8 @@ export const getStyles = (colors: any) => StyleSheet.create({
|
|||||||
backgroundColor: '#E8F5E9',
|
backgroundColor: '#E8F5E9',
|
||||||
},
|
},
|
||||||
icon: {
|
icon: {
|
||||||
fontSize: 24,
|
width: 24,
|
||||||
|
height: 24,
|
||||||
marginRight: 12,
|
marginRight: 12,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
|
|||||||
@ -1,17 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, TouchableOpacity } from 'react-native';
|
import { View, Text, TouchableOpacity, Image } from 'react-native';
|
||||||
import { PaymentOptionProps } from './paymentOption.props';
|
import { PaymentOptionProps } from './paymentOption.props';
|
||||||
import { getStyles } from './paymentOption.styles';
|
import { getStyles } from './paymentOption.styles';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
|
|
||||||
export const PaymentOption: React.FC<PaymentOptionProps> = ({
|
export const PaymentOption: React.FC<PaymentOptionProps> = ({
|
||||||
type,
|
|
||||||
label,
|
label,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
icon,
|
||||||
onSelect,
|
onSelect,
|
||||||
}) => {
|
}) => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
const styles = getStyles(colors);
|
const styles = getStyles(colors);
|
||||||
|
// console.log(icon);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@ -19,7 +20,7 @@ export const PaymentOption: React.FC<PaymentOptionProps> = ({
|
|||||||
onPress={onSelect}
|
onPress={onSelect}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={styles.icon}>{type === 'UPI' ? '📱' : type === 'Card' ? '💳' : type === 'Wallet' ? '👛' : '💵'}</Text>
|
<Image source={{ uri: icon }} style={styles.icon} />
|
||||||
<Text style={styles.label}>{label}</Text>
|
<Text style={styles.label}>{label}</Text>
|
||||||
<View style={[styles.radio, isSelected && styles.radioSelected]}>
|
<View style={[styles.radio, isSelected && styles.radioSelected]}>
|
||||||
{isSelected && <View style={styles.radioInner} />}
|
{isSelected && <View style={styles.radioInner} />}
|
||||||
|
|||||||
1
app/components/productCard/index.ts
Normal file
1
app/components/productCard/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './productCard';
|
||||||
109
app/components/productCard/productCard.styles.ts
Normal file
109
app/components/productCard/productCard.styles.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { StyleSheet, Dimensions, Platform } from 'react-native';
|
||||||
|
import { typography } from '@theme';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
// Screen padding = 16 * 2 = 32
|
||||||
|
// Gap between cards = 16
|
||||||
|
// Total cards width = width - 32 - 16 = width - 48
|
||||||
|
const CARD_WIDTH = (width - 48) / 2;
|
||||||
|
|
||||||
|
export const getStyles = (colors: any) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
cardContainer: {
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
backgroundColor: colors.surface ?? '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
...Platform.select({
|
||||||
|
ios: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 8,
|
||||||
|
},
|
||||||
|
android: { elevation: 2 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
imageContainer: {
|
||||||
|
width: '100%',
|
||||||
|
height: CARD_WIDTH, // Square image
|
||||||
|
backgroundColor: colors.background ?? '#F8F9FA',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
resizeMode: 'cover',
|
||||||
|
},
|
||||||
|
discountBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
backgroundColor: colors.error ?? '#E53935',
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
discountText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
},
|
||||||
|
favoriteButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
infoContainer: {
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
brandText: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: 2,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
titleText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
priceRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
priceText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
comparePriceText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textDecorationLine: 'line-through',
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: colors.primaryMuted ?? '#E9F7EF',
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
addButtonText: {
|
||||||
|
color: colors.primary ?? '#05824C',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
},
|
||||||
|
});
|
||||||
111
app/components/productCard/productCard.tsx
Normal file
111
app/components/productCard/productCard.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleProp,
|
||||||
|
ViewStyle,
|
||||||
|
} from 'react-native';
|
||||||
|
import { getStyles } from './productCard.styles';
|
||||||
|
import { useAppTheme } from '@theme';
|
||||||
|
import { getFullUrl } from '@utils';
|
||||||
|
|
||||||
|
export interface ProductCardProps {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
imageUrl: string;
|
||||||
|
price: string;
|
||||||
|
compareAtPrice?: string;
|
||||||
|
brand?: string | null;
|
||||||
|
currency?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
onAddPress?: () => void;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDiscountPercentage = (price: string, comparePrice?: string) => {
|
||||||
|
if (!comparePrice || !price) return null;
|
||||||
|
const p = parseFloat(price);
|
||||||
|
const c = parseFloat(comparePrice);
|
||||||
|
if (c <= p || c <= 0) return null;
|
||||||
|
const discount = Math.round(((c - p) / c) * 100);
|
||||||
|
return discount > 0 ? `${discount}% OFF` : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatPrice = (price: string, currency: string = '₹') => {
|
||||||
|
const num = parseFloat(price);
|
||||||
|
return isNaN(num) ? price : `${currency}${num.toFixed(0)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProductCard: React.FC<ProductCardProps> = ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
imageUrl,
|
||||||
|
price,
|
||||||
|
compareAtPrice,
|
||||||
|
brand,
|
||||||
|
currency = '₹',
|
||||||
|
onPress,
|
||||||
|
onAddPress,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = getStyles(colors);
|
||||||
|
|
||||||
|
const discountText = getDiscountPercentage(price, compareAtPrice);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.cardContainer, style]}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={onPress}
|
||||||
|
>
|
||||||
|
<View style={styles.imageContainer}>
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: getFullUrl(imageUrl),
|
||||||
|
}}
|
||||||
|
style={styles.image}
|
||||||
|
// fallback source can be handled here if needed
|
||||||
|
/>
|
||||||
|
{discountText && (
|
||||||
|
<View style={styles.discountBadge}>
|
||||||
|
<Text style={styles.discountText}>{discountText}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity style={styles.favoriteButton}>
|
||||||
|
<Text style={{ fontSize: 14 }}>🤍</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.infoContainer}>
|
||||||
|
{brand && (
|
||||||
|
<Text style={styles.brandText} numberOfLines={1}>
|
||||||
|
{brand}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text style={styles.titleText} numberOfLines={2}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.priceRow}>
|
||||||
|
<Text style={styles.priceText}>{formatPrice(price, currency)}</Text>
|
||||||
|
{compareAtPrice && parseFloat(compareAtPrice) > parseFloat(price) && (
|
||||||
|
<Text style={styles.comparePriceText}>
|
||||||
|
{formatPrice(compareAtPrice, currency)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* <TouchableOpacity
|
||||||
|
style={styles.addButton}
|
||||||
|
onPress={onAddPress || onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.addButtonText}>+ ADD</Text>
|
||||||
|
</TouchableOpacity> */}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
app/components/productRatingModal/index.ts
Normal file
1
app/components/productRatingModal/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './productRatingModal';
|
||||||
231
app/components/productRatingModal/productRatingModal.styles.ts
Normal file
231
app/components/productRatingModal/productRatingModal.styles.ts
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
import { StyleSheet, Dimensions, Platform } from 'react-native';
|
||||||
|
import { typography } from '@theme';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
export const getStyles = (colors: any) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
// ── Backdrop ──────────────────────────────────────────────────────────────
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Sheet ─────────────────────────────────────────────────────────────────
|
||||||
|
sheet: {
|
||||||
|
backgroundColor: colors.cardBg ?? '#FFFFFF',
|
||||||
|
borderTopLeftRadius: 28,
|
||||||
|
borderTopRightRadius: 28,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 40 : 28,
|
||||||
|
...Platform.select({
|
||||||
|
ios: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: -4 },
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 16,
|
||||||
|
},
|
||||||
|
android: { elevation: 16 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Drag handle ───────────────────────────────────────────────────────────
|
||||||
|
dragHandle: {
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: colors.border ?? '#DEDEDE',
|
||||||
|
alignSelf: 'center',
|
||||||
|
marginTop: 12,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Product hero ──────────────────────────────────────────────────────────
|
||||||
|
productHero: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
productEmoji: {
|
||||||
|
fontSize: 52,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
productName: {
|
||||||
|
fontSize: typography.fontSize.lg,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
productMeta: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Rating section ────────────────────────────────────────────────────────
|
||||||
|
ratingSection: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
ratingLabel: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: 14,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
},
|
||||||
|
ratingHint: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Divider ───────────────────────────────────────────────────────────────
|
||||||
|
divider: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: colors.border ?? '#ECECEC',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Comment input ─────────────────────────────────────────────────────────
|
||||||
|
inputSection: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
inputLabel: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
optionalTag: {
|
||||||
|
fontWeight: typography.fontWeight.regular ?? '400',
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
},
|
||||||
|
commentInput: {
|
||||||
|
backgroundColor: colors.surface ?? '#F5F6F8',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: colors.border ?? '#ECECEC',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text,
|
||||||
|
minHeight: 88,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
},
|
||||||
|
commentInputFocused: {
|
||||||
|
borderColor: colors.primary ?? '#05824C',
|
||||||
|
},
|
||||||
|
charCount: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: 'right',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Image upload ──────────────────────────────────────────────────────────
|
||||||
|
imageSection: {
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
imageLabel: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
imageScrollContent: {
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
addImageBtn: {
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: colors.primary ?? '#05824C',
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: colors.primaryMuted ?? '#E9F7EF',
|
||||||
|
},
|
||||||
|
addImageIcon: {
|
||||||
|
fontSize: 22,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
addImageText: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: colors.primary ?? '#05824C',
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
imageThumb: {
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: 12,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
imageThumbnail: {
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
removeImageBtn: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -6,
|
||||||
|
right: -6,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: colors.error ?? '#E53935',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
removeImageText: {
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
lineHeight: 14,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Submit button ─────────────────────────────────────────────────────────
|
||||||
|
submitBtn: {
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 15,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
submitBtnActive: {
|
||||||
|
backgroundColor: colors.primary ?? '#05824C',
|
||||||
|
},
|
||||||
|
submitBtnDisabled: {
|
||||||
|
backgroundColor: colors.border ?? '#DEDEDE',
|
||||||
|
},
|
||||||
|
submitBtnText: {
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
submitBtnTextDisabled: {
|
||||||
|
color: colors.textSecondary ?? '#9E9E9E',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Already-rated banner ──────────────────────────────────────────────────
|
||||||
|
ratedBanner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.primaryMuted ?? '#E9F7EF',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
ratedBannerEmoji: {
|
||||||
|
fontSize: 20,
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
ratedBannerText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.primary ?? '#05824C',
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
});
|
||||||
288
app/components/productRatingModal/productRatingModal.tsx
Normal file
288
app/components/productRatingModal/productRatingModal.tsx
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
Animated,
|
||||||
|
ScrollView,
|
||||||
|
Image,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
TouchableWithoutFeedback,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useAppTheme } from '@theme';
|
||||||
|
import { RatingStars } from '../ratingStars/ratingStars';
|
||||||
|
import { getStyles } from './productRatingModal.styles';
|
||||||
|
import { giveRatingPayload } from '@interfaces';
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ProductRatingModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
/** Product id being rated */
|
||||||
|
productId: string;
|
||||||
|
/** Display name of the product */
|
||||||
|
productName: string;
|
||||||
|
/** Optional: qty badge text, e.g. "2x" */
|
||||||
|
quantity?: string | number;
|
||||||
|
/** The parent order id — required by the rating API */
|
||||||
|
orderId?: string;
|
||||||
|
/** Called when the user submits their rating */
|
||||||
|
onSubmit: (payload: giveRatingPayload) => void;
|
||||||
|
/** Called when the modal is dismissed without submitting */
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rating hint labels ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const RATING_LABELS: Record<number, string> = {
|
||||||
|
1: 'Terrible 😞',
|
||||||
|
2: 'Bad 😕',
|
||||||
|
3: 'Okay 😐',
|
||||||
|
4: 'Good 😊',
|
||||||
|
5: 'Excellent 🤩',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_COMMENT = 300;
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ProductRatingModal: React.FC<ProductRatingModalProps> = ({
|
||||||
|
visible,
|
||||||
|
orderId,
|
||||||
|
productId,
|
||||||
|
productName,
|
||||||
|
quantity,
|
||||||
|
onSubmit,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = getStyles(colors);
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────────────────────────────────────
|
||||||
|
const [rating, setRating] = useState(0);
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const [commentFocused, setCommentFocused] = useState(false);
|
||||||
|
const [imageUris] = useState<string[]>([]); // placeholder – wire launchImageLibrary here
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
// ── Animation ────────────────────────────────────────────────────────────
|
||||||
|
const slideY = useRef(new Animated.Value(400)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
// reset state each time the modal opens for a new product
|
||||||
|
setRating(0);
|
||||||
|
setComment('');
|
||||||
|
setSubmitted(false);
|
||||||
|
Animated.spring(slideY, {
|
||||||
|
toValue: 0,
|
||||||
|
useNativeDriver: true,
|
||||||
|
damping: 18,
|
||||||
|
stiffness: 160,
|
||||||
|
}).start();
|
||||||
|
} else {
|
||||||
|
slideY.setValue(400);
|
||||||
|
}
|
||||||
|
}, [visible, slideY]);
|
||||||
|
|
||||||
|
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
Animated.timing(slideY, {
|
||||||
|
toValue: 400,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(onClose);
|
||||||
|
}, [slideY, onClose]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (rating === 0) return;
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
rating,
|
||||||
|
comment,
|
||||||
|
images: imageUris,
|
||||||
|
orderId: orderId ?? '',
|
||||||
|
});
|
||||||
|
setSubmitted(true);
|
||||||
|
}, [rating, comment, imageUris, orderId, onSubmit]);
|
||||||
|
|
||||||
|
const canSubmit = rating > 0;
|
||||||
|
|
||||||
|
// ── Render ───────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={handleClose}
|
||||||
|
statusBarTranslucent
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
{/* Backdrop — tap to dismiss */}
|
||||||
|
<TouchableWithoutFeedback onPress={handleClose}>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<TouchableWithoutFeedback>
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.sheet, { transform: [{ translateY: slideY }] }]}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
<View style={styles.dragHandle} />
|
||||||
|
|
||||||
|
{/* Product hero */}
|
||||||
|
<View style={styles.productHero}>
|
||||||
|
<Text style={styles.productEmoji}>🛍️</Text>
|
||||||
|
<Text style={styles.productName} numberOfLines={2}>
|
||||||
|
{productName}
|
||||||
|
</Text>
|
||||||
|
{quantity !== undefined && (
|
||||||
|
<Text style={styles.productMeta}>Qty: {quantity}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Already-submitted banner */}
|
||||||
|
{submitted ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.ratedBanner}>
|
||||||
|
<Text style={styles.ratedBannerEmoji}>✅</Text>
|
||||||
|
<Text style={styles.ratedBannerText}>
|
||||||
|
Thanks for your rating!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Show submitted stars (read-only) */}
|
||||||
|
<View style={styles.ratingSection}>
|
||||||
|
<RatingStars rating={rating} size={34} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.submitBtn, styles.submitBtnActive]}
|
||||||
|
onPress={handleClose}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.submitBtnText}>Done</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{/* Star rating */}
|
||||||
|
<View style={styles.ratingSection}>
|
||||||
|
<Text style={styles.ratingLabel}>Your Rating</Text>
|
||||||
|
<RatingStars
|
||||||
|
rating={rating}
|
||||||
|
onRate={setRating}
|
||||||
|
size={40}
|
||||||
|
/>
|
||||||
|
<Text style={styles.ratingHint}>
|
||||||
|
{rating > 0
|
||||||
|
? RATING_LABELS[rating]
|
||||||
|
: 'Tap a star to rate'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
|
{/* Comment (optional) */}
|
||||||
|
<View style={styles.inputSection}>
|
||||||
|
<Text style={styles.inputLabel}>
|
||||||
|
Review{' '}
|
||||||
|
<Text style={styles.optionalTag}>(optional)</Text>
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
styles.commentInput,
|
||||||
|
commentFocused && styles.commentInputFocused,
|
||||||
|
]}
|
||||||
|
placeholder="Share your experience with this product..."
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
value={comment}
|
||||||
|
onChangeText={t => setComment(t.slice(0, MAX_COMMENT))}
|
||||||
|
multiline
|
||||||
|
onFocus={() => setCommentFocused(true)}
|
||||||
|
onBlur={() => setCommentFocused(false)}
|
||||||
|
returnKeyType="done"
|
||||||
|
blurOnSubmit
|
||||||
|
/>
|
||||||
|
<Text style={styles.charCount}>
|
||||||
|
{comment.length}/{MAX_COMMENT}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Image upload strip (optional) */}
|
||||||
|
<View style={styles.imageSection}>
|
||||||
|
<Text style={styles.imageLabel}>
|
||||||
|
Photos{' '}
|
||||||
|
<Text style={styles.optionalTag}>(optional)</Text>
|
||||||
|
</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.imageScrollContent}
|
||||||
|
>
|
||||||
|
{/* Add photo button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.addImageBtn}
|
||||||
|
activeOpacity={0.75}
|
||||||
|
/* TODO: wire react-native-image-picker here */
|
||||||
|
>
|
||||||
|
<Text style={styles.addImageIcon}>📷</Text>
|
||||||
|
<Text style={styles.addImageText}>Add</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Thumbnail previews */}
|
||||||
|
{imageUris.map((uri, i) => (
|
||||||
|
<View key={i} style={styles.imageThumb}>
|
||||||
|
<Image
|
||||||
|
source={{ uri }}
|
||||||
|
style={styles.imageThumbnail}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.removeImageBtn}
|
||||||
|
hitSlop={{ top: 4, right: 4, bottom: 4, left: 4 }}
|
||||||
|
>
|
||||||
|
<Text style={styles.removeImageText}>✕</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.submitBtn,
|
||||||
|
canSubmit
|
||||||
|
? styles.submitBtnActive
|
||||||
|
: styles.submitBtnDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.submitBtnText,
|
||||||
|
!canSubmit && styles.submitBtnTextDisabled,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Submit Review
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -3,6 +3,7 @@ import { View, TextInput, Text } from 'react-native';
|
|||||||
import { SearchBarProps } from './searchBar.props';
|
import { SearchBarProps } from './searchBar.props';
|
||||||
import { getStyles } from './searchBar.styles';
|
import { getStyles } from './searchBar.styles';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
|
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
|
||||||
export const SearchBar: React.FC<SearchBarProps> = ({
|
export const SearchBar: React.FC<SearchBarProps> = ({
|
||||||
value,
|
value,
|
||||||
@ -16,7 +17,11 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.icon}>🔍</Text>
|
<MaterialCommunityIcons
|
||||||
|
name="magnify"
|
||||||
|
size={24}
|
||||||
|
color={colors.primary}
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
10
app/config/index.ts
Normal file
10
app/config/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
// ─── Environment Configuration ───────────────────────────────────────────────
|
||||||
|
// Centralizes all environment keys for the application.
|
||||||
|
// Since React Native doesn't natively expose process.env at runtime,
|
||||||
|
// we export them directly from here.
|
||||||
|
|
||||||
|
export const ENV = {
|
||||||
|
STRIPE_PUBLISHABLE_KEY:
|
||||||
|
'pk_test_51Ox04DSHlFQYe8R5Cvm8i6n99QynUNj7WQzACB89PImnt0X8Z54SBFM22ghSNHYFo7OgVcyba9QhhyrrdRqRPJpF00Us8XJv7f',
|
||||||
|
RAZORPAY_KEY_ID: 'rzp_test_TCxbW8AxcXgMCj',
|
||||||
|
};
|
||||||
@ -9,7 +9,8 @@ import {
|
|||||||
import { getStyles } from './loginScreen.styles';
|
import { getStyles } from './loginScreen.styles';
|
||||||
import { CustomInput, PrimaryButton } from '@components';
|
import { CustomInput, PrimaryButton } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { useLoginScreen } from '../../../hooks';
|
import { useLoginScreen } from './hooks/useLoginScreen';
|
||||||
|
import { RootState, useAppSelector } from '@store';
|
||||||
|
|
||||||
export const LoginScreen: React.FC = () => {
|
export const LoginScreen: React.FC = () => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
@ -18,6 +19,8 @@ export const LoginScreen: React.FC = () => {
|
|||||||
const { mobileNumber, error, onChangeMobileNumber, handleLogin } =
|
const { mobileNumber, error, onChangeMobileNumber, handleLogin } =
|
||||||
useLoginScreen();
|
useLoginScreen();
|
||||||
|
|
||||||
|
const { isLoading } = useAppSelector((state: RootState) => state.auth);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
@ -69,9 +72,10 @@ export const LoginScreen: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
title="Get OTP"
|
title={isLoading ? 'Loading...' : 'Get OTP'}
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
style={{ marginTop: 12 }}
|
style={{ marginTop: 12 }}
|
||||||
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,8 @@ import { useState, useCallback } from 'react';
|
|||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
import { AuthStackParamList } from 'app/navigation/authStack';
|
import { AuthStackParamList } from 'app/navigation/authStack';
|
||||||
import { loginWithPhone, useAppDispatch } from '../store';
|
import { loginWithPhone, useAppDispatch } from '@store';
|
||||||
|
import { Alert } from 'react-native';
|
||||||
|
|
||||||
type LoginNavProp = StackNavigationProp<AuthStackParamList, 'LoginScreen'>;
|
type LoginNavProp = StackNavigationProp<AuthStackParamList, 'LoginScreen'>;
|
||||||
|
|
||||||
@ -50,8 +51,14 @@ export const useLoginScreen = (): UseLoginScreenResult => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
dispatch(loginWithPhone(mobileNumber));
|
dispatch(loginWithPhone(mobileNumber))
|
||||||
|
.unwrap()
|
||||||
|
.then(() => {
|
||||||
navigation.navigate('OtpScreen', { mobileNumber });
|
navigation.navigate('OtpScreen', { mobileNumber });
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
Alert.alert(err);
|
||||||
|
});
|
||||||
}, [mobileNumber, dispatch, navigation]);
|
}, [mobileNumber, dispatch, navigation]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -1,2 +1 @@
|
|||||||
export * from './loginScreen';
|
export * from './loginScreen';
|
||||||
export * from './loginScreen.styles';
|
|
||||||
|
|||||||
@ -1,18 +1,20 @@
|
|||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
|
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
|
||||||
import {
|
import {
|
||||||
useNavigation,
|
useNavigation,
|
||||||
CompositeNavigationProp,
|
CompositeNavigationProp,
|
||||||
|
useFocusEffect,
|
||||||
} from '@react-navigation/native';
|
} from '@react-navigation/native';
|
||||||
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
import { getStyles } from './accountScreen.styles';
|
import { getStyles } from './accountScreen.styles';
|
||||||
import { Header, PrimaryButton } from '@components';
|
import { Header, PrimaryButton } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../store';
|
|
||||||
import { logout } from '../../../store/commonreducers/auth';
|
|
||||||
import { AppStackParamList } from '../../../navigation/appStack';
|
import { AppStackParamList } from '../../../navigation/appStack';
|
||||||
import { MainTabParamList } from '../../../navigation/mainTabNavigator';
|
import { MainTabParamList } from '../../../navigation/mainTabNavigator';
|
||||||
|
import { logoutUser, useAppDispatch, useAppSelector } from '@store';
|
||||||
|
import { getWalletBalanceThunk } from './thunk';
|
||||||
|
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
|
||||||
type AccountNavProp = CompositeNavigationProp<
|
type AccountNavProp = CompositeNavigationProp<
|
||||||
BottomTabNavigationProp<MainTabParamList, 'AccountScreen'>,
|
BottomTabNavigationProp<MainTabParamList, 'AccountScreen'>,
|
||||||
@ -37,19 +39,14 @@ const MENU_SECTIONS: MenuSectionData[] = [
|
|||||||
title: 'Account',
|
title: 'Account',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
icon: '📍',
|
icon: 'wallet-outline',
|
||||||
label: 'My Addresses',
|
label: 'Wallet',
|
||||||
subLabel: 'Manage delivery addresses',
|
|
||||||
tint: '#FDECEA',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '💳',
|
|
||||||
label: 'Payment Methods',
|
|
||||||
subLabel: 'Cards, UPI & wallets',
|
subLabel: 'Cards, UPI & wallets',
|
||||||
tint: '#FFF4E5',
|
tint: '#FFF4E5',
|
||||||
|
onPress: navigation => navigation.navigate('WalletScreen'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '🔔',
|
icon: 'bell-outline',
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
subLabel: 'Alerts, offers & updates',
|
subLabel: 'Alerts, offers & updates',
|
||||||
tint: '#FFF9DB',
|
tint: '#FFF9DB',
|
||||||
@ -60,20 +57,20 @@ const MENU_SECTIONS: MenuSectionData[] = [
|
|||||||
title: 'Support & Legal',
|
title: 'Support & Legal',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
icon: '❓',
|
icon: 'help-circle-outline',
|
||||||
label: 'Help & Support',
|
label: 'Help & Support',
|
||||||
subLabel: 'FAQs and contact us',
|
subLabel: 'FAQs and contact us',
|
||||||
tint: '#EAF4FF',
|
tint: '#EAF4FF',
|
||||||
onPress: navigation => navigation.navigate('HelpSupportScreen'),
|
onPress: navigation => navigation.navigate('HelpSupportScreen'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '📋',
|
icon: 'file-document-outline',
|
||||||
label: 'Terms & Conditions',
|
label: 'Terms & Conditions',
|
||||||
subLabel: 'Our terms of service',
|
subLabel: 'Our terms of service',
|
||||||
tint: '#F1F0FF',
|
tint: '#F1F0FF',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '🔒',
|
icon: 'shield-lock-outline',
|
||||||
label: 'Privacy Policy',
|
label: 'Privacy Policy',
|
||||||
subLabel: 'How we handle your data',
|
subLabel: 'How we handle your data',
|
||||||
tint: '#E9F7EF',
|
tint: '#E9F7EF',
|
||||||
@ -87,7 +84,16 @@ export const AccountScreen: React.FC = () => {
|
|||||||
const styles = getStyles(colors);
|
const styles = getStyles(colors);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigation = useNavigation<AccountNavProp>();
|
const navigation = useNavigation<AccountNavProp>();
|
||||||
const user = useAppSelector(state => state.auth.user);
|
const user = useAppSelector(
|
||||||
|
state => state.customerProfile.customerDetails?.user,
|
||||||
|
);
|
||||||
|
const { wallet } = useAppSelector(state => state.account);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
dispatch(getWalletBalanceThunk());
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
|
||||||
// TODO: wire these to real selectors once order/wallet state is available.
|
// TODO: wire these to real selectors once order/wallet state is available.
|
||||||
// const ordersCount = user?.ordersCount ?? 0;
|
// const ordersCount = user?.ordersCount ?? 0;
|
||||||
@ -119,18 +125,22 @@ export const AccountScreen: React.FC = () => {
|
|||||||
|
|
||||||
<Text style={styles.profileName}>{user?.name || 'User'}</Text>
|
<Text style={styles.profileName}>{user?.name || 'User'}</Text>
|
||||||
<View style={styles.profilePhoneRow}>
|
<View style={styles.profilePhoneRow}>
|
||||||
<Text style={styles.profilePhoneIcon}>📞</Text>
|
<MaterialCommunityIcons
|
||||||
|
name="phone-outline"
|
||||||
|
size={20}
|
||||||
|
color={colors.primary}
|
||||||
|
/>
|
||||||
<Text style={styles.profilePhone}>
|
<Text style={styles.profilePhone}>
|
||||||
{user?.mobileNumber || '+91 98765 43210'}
|
{user?.phone || '+91 98765 43210'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity
|
{/* <TouchableOpacity
|
||||||
style={styles.editProfileButton}
|
style={styles.editProfileButton}
|
||||||
activeOpacity={0.75}
|
activeOpacity={0.75}
|
||||||
>
|
>
|
||||||
<Text style={styles.editProfileButtonText}>Edit Profile</Text>
|
<Text style={styles.editProfileButtonText}>Edit Profile</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity> */}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Quick stats */}
|
{/* Quick stats */}
|
||||||
@ -171,7 +181,11 @@ export const AccountScreen: React.FC = () => {
|
|||||||
{ backgroundColor: item.tint },
|
{ backgroundColor: item.tint },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.menuIcon}>{item.icon}</Text>
|
<MaterialCommunityIcons
|
||||||
|
name={item.icon}
|
||||||
|
size={24}
|
||||||
|
color={colors.primary}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.menuTextWrap}>
|
<View style={styles.menuTextWrap}>
|
||||||
<Text style={styles.menuLabel}>{item.label}</Text>
|
<Text style={styles.menuLabel}>{item.label}</Text>
|
||||||
@ -189,7 +203,10 @@ export const AccountScreen: React.FC = () => {
|
|||||||
|
|
||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
<View style={styles.logoutSection}>
|
<View style={styles.logoutSection}>
|
||||||
<PrimaryButton title="Logout" onPress={() => dispatch(logout())} />
|
<PrimaryButton
|
||||||
|
title="Logout"
|
||||||
|
onPress={() => dispatch(logoutUser())}
|
||||||
|
/>
|
||||||
<Text style={styles.versionText}>App version 1.0.0</Text>
|
<Text style={styles.versionText}>App version 1.0.0</Text>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@ -1 +1,3 @@
|
|||||||
export * from './accountScreen';
|
export * from './accountScreen';
|
||||||
|
export * from './thunk';
|
||||||
|
export * from './reducer';
|
||||||
|
|||||||
26
app/features/screens/accountScreen/reducer.ts
Normal file
26
app/features/screens/accountScreen/reducer.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { createReducer } from '@reduxjs/toolkit';
|
||||||
|
import { WalletResponse } from '@interfaces';
|
||||||
|
import { getWalletBalanceThunk } from './thunk';
|
||||||
|
|
||||||
|
export interface WalletState {
|
||||||
|
wallet: WalletResponse;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: WalletState = {
|
||||||
|
wallet: {
|
||||||
|
walletId: '',
|
||||||
|
currency: '',
|
||||||
|
balance: 0,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const accountReducer = createReducer(initialState, builder => {
|
||||||
|
builder.addCase(getWalletBalanceThunk.fulfilled, (state, action) => {
|
||||||
|
state.wallet = action.payload;
|
||||||
|
});
|
||||||
|
builder.addCase(getWalletBalanceThunk.rejected, (state, action) => {
|
||||||
|
state.error = action.payload as string;
|
||||||
|
});
|
||||||
|
});
|
||||||
14
app/features/screens/accountScreen/thunk.ts
Normal file
14
app/features/screens/accountScreen/thunk.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import { getWalletDetails } from '@api';
|
||||||
|
|
||||||
|
export const getWalletBalanceThunk = createAsyncThunk(
|
||||||
|
'wallet/getWalletBalance',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const data = await getWalletDetails();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -1,110 +1,224 @@
|
|||||||
import { StyleSheet, Platform } from 'react-native';
|
import { StyleSheet, Platform } from 'react-native';
|
||||||
import { typography } from '@theme';
|
import { typography } from '@theme';
|
||||||
|
|
||||||
|
// ─── Shared shadows ────────────────────────────────────────────────────────────
|
||||||
|
const cardShadow = Platform.select({
|
||||||
|
ios: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.07,
|
||||||
|
shadowRadius: 12,
|
||||||
|
},
|
||||||
|
android: { elevation: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const heavyShadow = Platform.select({
|
||||||
|
ios: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: -6 },
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 16,
|
||||||
|
},
|
||||||
|
android: { elevation: 16 },
|
||||||
|
});
|
||||||
|
|
||||||
export const getStyles = (colors: any) =>
|
export const getStyles = (colors: any) =>
|
||||||
StyleSheet.create({
|
StyleSheet.create({
|
||||||
|
// ── Layout ──────────────────────────────────────────────────────────────────
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: colors.background,
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
paddingBottom: 140,
|
paddingBottom: 140,
|
||||||
|
paddingTop: 4,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- ETA banner ----------
|
// ── Section wrapper ─────────────────────────────────────────────────────────
|
||||||
|
section: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
marginTop: 22,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 10,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── ETA Banner ──────────────────────────────────────────────────────────────
|
||||||
etaBanner: {
|
etaBanner: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: colors.primaryMuted ?? '#E9F7EF',
|
justifyContent: 'space-between',
|
||||||
marginHorizontal: 16,
|
backgroundColor: colors.primaryMuted ?? '#E8F5EE',
|
||||||
marginTop: 12,
|
|
||||||
marginBottom: 4,
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingVertical: 10,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
},
|
|
||||||
etaIcon: {
|
|
||||||
fontSize: 16,
|
|
||||||
marginRight: 8,
|
|
||||||
},
|
|
||||||
etaText: {
|
|
||||||
fontSize: typography.fontSize.sm,
|
|
||||||
fontWeight: typography.fontWeight.semibold,
|
|
||||||
color: colors.primary,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ---------- Item cards ----------
|
|
||||||
itemsCard: {
|
|
||||||
backgroundColor: colors.cardBg,
|
|
||||||
marginHorizontal: 16,
|
marginHorizontal: 16,
|
||||||
marginTop: 14,
|
marginTop: 14,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
overflow: 'hidden',
|
paddingVertical: 14,
|
||||||
...Platform.select({
|
paddingHorizontal: 14,
|
||||||
ios: {
|
borderWidth: 1,
|
||||||
shadowColor: '#000',
|
borderColor: colors.primary + '22',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.05,
|
|
||||||
shadowRadius: 8,
|
|
||||||
},
|
},
|
||||||
android: { elevation: 1 },
|
etaLeft: {
|
||||||
}),
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
etaIconWrap: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: colors.primary + '18',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
etaIcon: {
|
||||||
|
fontSize: 20,
|
||||||
|
},
|
||||||
|
etaText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
etaSubtext: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
etaBadge: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 5,
|
||||||
|
},
|
||||||
|
etaBadgeText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Items Card ──────────────────────────────────────────────────────────────
|
||||||
|
itemsCard: {
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: 'hidden',
|
||||||
|
...cardShadow,
|
||||||
|
},
|
||||||
|
itemsCardHeader: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 14,
|
||||||
|
paddingBottom: 6,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
itemsCardHeaderText: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.8,
|
||||||
},
|
},
|
||||||
cartItem: {
|
cartItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 16,
|
||||||
},
|
},
|
||||||
cartItemDivider: {
|
cartItemDivider: {
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: colors.border,
|
||||||
},
|
},
|
||||||
itemThumb: {
|
|
||||||
width: 52,
|
// Veg indicator (left border dot)
|
||||||
height: 52,
|
vegIndicator: {
|
||||||
borderRadius: 10,
|
width: 14,
|
||||||
backgroundColor: colors.inputBg,
|
height: 14,
|
||||||
|
borderRadius: 3,
|
||||||
|
borderWidth: 1.5,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
marginRight: 10,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
vegDot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
|
||||||
|
itemThumbWrap: {
|
||||||
marginRight: 12,
|
marginRight: 12,
|
||||||
},
|
},
|
||||||
itemThumbEmoji: {
|
itemThumb: {
|
||||||
fontSize: 22,
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: colors.inputBg,
|
||||||
},
|
},
|
||||||
itemInfo: {
|
itemInfo: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginRight: 10,
|
marginRight: 8,
|
||||||
},
|
},
|
||||||
itemTitle: {
|
itemTitle: {
|
||||||
fontSize: typography.fontSize.md,
|
fontSize: typography.fontSize.md,
|
||||||
fontWeight: typography.fontWeight.semibold,
|
fontWeight: typography.fontWeight.semibold,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
marginBottom: 4,
|
marginBottom: 6,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
itemPriceRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 6,
|
||||||
},
|
},
|
||||||
itemPrice: {
|
itemPrice: {
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
fontWeight: typography.fontWeight.bold,
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
itemMrp: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
|
textDecorationLine: 'line-through',
|
||||||
|
},
|
||||||
|
discountBadge: {
|
||||||
|
backgroundColor: colors.primary + '18',
|
||||||
|
borderRadius: 4,
|
||||||
|
paddingHorizontal: 5,
|
||||||
|
paddingVertical: 2,
|
||||||
|
},
|
||||||
|
discountBadgeText: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.primary,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- Add more items ----------
|
// ── Add more items row ─────────────────────────────────────────────────────
|
||||||
addMoreRow: {
|
addMoreRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingVertical: 12,
|
paddingVertical: 14,
|
||||||
marginHorizontal: 16,
|
marginHorizontal: 14,
|
||||||
|
marginBottom: 14,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
borderStyle: 'dashed',
|
borderStyle: 'dashed',
|
||||||
borderColor: colors.border,
|
borderColor: colors.primary + '55',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
},
|
},
|
||||||
addMoreIcon: {
|
addMoreIcon: {
|
||||||
fontSize: 14,
|
fontSize: 16,
|
||||||
marginRight: 6,
|
marginRight: 6,
|
||||||
color: colors.primary,
|
color: colors.primary,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
},
|
},
|
||||||
addMoreText: {
|
addMoreText: {
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
@ -112,68 +226,100 @@ export const getStyles = (colors: any) =>
|
|||||||
color: colors.primary,
|
color: colors.primary,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- Footer ----------
|
// ── Savings Pill ───────────────────────────────────────────────────────────
|
||||||
footer: {
|
savingsPill: {
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingTop: 20,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Coupon
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: typography.fontSize.sm,
|
|
||||||
fontWeight: typography.fontWeight.bold,
|
|
||||||
color: colors.text,
|
|
||||||
marginBottom: 10,
|
|
||||||
},
|
|
||||||
couponCard: {
|
|
||||||
backgroundColor: colors.cardBg,
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: 6,
|
|
||||||
marginBottom: 20,
|
|
||||||
...Platform.select({
|
|
||||||
ios: {
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.05,
|
|
||||||
shadowRadius: 8,
|
|
||||||
},
|
|
||||||
android: { elevation: 1 },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
couponRow: {
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFF3E0',
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
},
|
||||||
|
savingsPillEmoji: {
|
||||||
|
fontSize: 16,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
savingsPillText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: '#E65100',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Coupon Card ────────────────────────────────────────────────────────────
|
||||||
|
couponCard: {
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
...cardShadow,
|
||||||
|
},
|
||||||
|
couponInputRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
},
|
},
|
||||||
couponIcon: {
|
couponIcon: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
marginLeft: 10,
|
marginRight: 8,
|
||||||
marginRight: 4,
|
|
||||||
},
|
},
|
||||||
couponInput: {
|
couponInput: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: 44,
|
height: 48,
|
||||||
paddingHorizontal: 8,
|
fontSize: typography.fontSize.sm,
|
||||||
fontSize: typography.fontSize.md,
|
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
},
|
},
|
||||||
applyButton: {
|
applyButton: {
|
||||||
paddingHorizontal: 18,
|
paddingHorizontal: 18,
|
||||||
paddingVertical: 11,
|
paddingVertical: 9,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: colors.primary,
|
||||||
marginRight: 4,
|
},
|
||||||
|
applyButtonDisabled: {
|
||||||
|
opacity: 0.35,
|
||||||
},
|
},
|
||||||
applyButtonText: {
|
applyButtonText: {
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
fontWeight: typography.fontWeight.semibold,
|
fontWeight: typography.fontWeight.bold,
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
},
|
},
|
||||||
|
viewCouponsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
},
|
||||||
|
viewCouponsLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
viewCouponsTag: {
|
||||||
|
fontSize: 15,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
viewCouponsText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
viewCouponsChevron: {
|
||||||
|
fontSize: 20,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Applied coupon
|
||||||
couponAppliedRow: {
|
couponAppliedRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingVertical: 10,
|
paddingVertical: 14,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 14,
|
||||||
},
|
},
|
||||||
couponAppliedLeft: {
|
couponAppliedLeft: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -181,16 +327,16 @@ export const getStyles = (colors: any) =>
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
couponCheckBadge: {
|
couponCheckBadge: {
|
||||||
width: 28,
|
width: 36,
|
||||||
height: 28,
|
height: 36,
|
||||||
borderRadius: 14,
|
borderRadius: 18,
|
||||||
backgroundColor: colors.primaryMuted ?? '#E9F7EF',
|
backgroundColor: colors.primary + '18',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginRight: 10,
|
marginRight: 12,
|
||||||
},
|
},
|
||||||
couponCheckIcon: {
|
couponCheckIcon: {
|
||||||
fontSize: 13,
|
fontSize: 16,
|
||||||
},
|
},
|
||||||
couponAppliedCode: {
|
couponAppliedCode: {
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
@ -200,80 +346,225 @@ export const getStyles = (colors: any) =>
|
|||||||
couponAppliedSub: {
|
couponAppliedSub: {
|
||||||
fontSize: typography.fontSize.xs,
|
fontSize: typography.fontSize.xs,
|
||||||
color: colors.primary,
|
color: colors.primary,
|
||||||
marginTop: 1,
|
marginTop: 2,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
},
|
||||||
|
couponRemoveBtn: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#D32F2F' + '44',
|
||||||
},
|
},
|
||||||
couponRemoveText: {
|
couponRemoveText: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: '#D32F2F',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Instructions Card ──────────────────────────────────────────────────────
|
||||||
|
instructionsCard: {
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
...cardShadow,
|
||||||
|
},
|
||||||
|
instructionsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
},
|
||||||
|
instructionsIcon: {
|
||||||
|
fontSize: 18,
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
instructionsPlaceholder: {
|
||||||
|
flex: 1,
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.placeholder,
|
||||||
|
},
|
||||||
|
instructionsFilledText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
},
|
||||||
|
instructionsChevron: {
|
||||||
|
fontSize: 22,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
instructionsInput: {
|
||||||
|
minHeight: 80,
|
||||||
|
padding: 14,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Tip Card ───────────────────────────────────────────────────────────────
|
||||||
|
tipCard: {
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 14,
|
||||||
|
...cardShadow,
|
||||||
|
},
|
||||||
|
tipSubtitle: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: 14,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
tipOptionsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
tipOption: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 9,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
tipOptionSelected: {
|
||||||
|
borderColor: colors.primary,
|
||||||
|
backgroundColor: colors.primary + '14',
|
||||||
|
},
|
||||||
|
tipSelectedCheck: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
},
|
||||||
|
tipOptionText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
tipOptionTextSelected: {
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
tipRemove: {
|
||||||
|
marginTop: 12,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
tipRemoveText: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
fontWeight: typography.fontWeight.semibold,
|
fontWeight: typography.fontWeight.semibold,
|
||||||
color: '#D32F2F',
|
color: '#D32F2F',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Bill details
|
// ── Bill Card ──────────────────────────────────────────────────────────────
|
||||||
billCard: {
|
billCard: {
|
||||||
backgroundColor: colors.cardBg,
|
backgroundColor: colors.cardBg,
|
||||||
borderRadius: 14,
|
borderRadius: 16,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
...Platform.select({
|
...cardShadow,
|
||||||
ios: {
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.05,
|
|
||||||
shadowRadius: 8,
|
|
||||||
},
|
|
||||||
android: { elevation: 1 },
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
feeRow: {
|
feeRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginBottom: 10,
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
feeLabelRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
feeLabel: {
|
feeLabel: {
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
},
|
},
|
||||||
|
infoIcon: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
},
|
||||||
feeValue: {
|
feeValue: {
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
fontWeight: typography.fontWeight.medium,
|
fontWeight: typography.fontWeight.medium,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
},
|
},
|
||||||
|
discountLabel: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
discountValue: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
},
|
||||||
|
gstNote: {
|
||||||
|
backgroundColor: colors.inputBg,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 10,
|
||||||
|
marginBottom: 12,
|
||||||
|
marginTop: -4,
|
||||||
|
},
|
||||||
|
gstNoteText: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 17,
|
||||||
|
},
|
||||||
|
billDivider: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: colors.border,
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
totalRow: {
|
totalRow: {
|
||||||
borderTopWidth: 1,
|
flexDirection: 'row',
|
||||||
borderTopColor: colors.border,
|
justifyContent: 'space-between',
|
||||||
paddingTop: 12,
|
alignItems: 'center',
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
marginBottom: 0,
|
|
||||||
},
|
},
|
||||||
totalLabel: {
|
totalLabel: {
|
||||||
fontSize: typography.fontSize.md,
|
fontSize: typography.fontSize.md,
|
||||||
fontWeight: typography.fontWeight.bold,
|
fontWeight: typography.fontWeight.bold,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
},
|
},
|
||||||
|
totalSaved: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.primary,
|
||||||
|
marginTop: 2,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
},
|
||||||
totalValue: {
|
totalValue: {
|
||||||
fontSize: typography.fontSize.md,
|
fontSize: 20,
|
||||||
fontWeight: typography.fontWeight.bold,
|
fontWeight: typography.fontWeight.bold,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
},
|
},
|
||||||
savingsBanner: {
|
|
||||||
|
// ── Policy Card ────────────────────────────────────────────────────────────
|
||||||
|
policyCard: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'flex-start',
|
||||||
backgroundColor: colors.primaryMuted ?? '#E9F7EF',
|
marginHorizontal: 16,
|
||||||
borderRadius: 10,
|
marginTop: 20,
|
||||||
paddingVertical: 8,
|
marginBottom: 8,
|
||||||
paddingHorizontal: 12,
|
backgroundColor: colors.inputBg,
|
||||||
marginTop: 14,
|
borderRadius: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
},
|
},
|
||||||
savingsIcon: {
|
policyIcon: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
marginRight: 6,
|
color: colors.textSecondary,
|
||||||
|
marginRight: 10,
|
||||||
|
marginTop: 1,
|
||||||
},
|
},
|
||||||
savingsText: {
|
policyText: {
|
||||||
|
flex: 1,
|
||||||
fontSize: typography.fontSize.xs,
|
fontSize: typography.fontSize.xs,
|
||||||
fontWeight: typography.fontWeight.semibold,
|
color: colors.textSecondary,
|
||||||
color: colors.primary,
|
lineHeight: 18,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- Bottom panel ----------
|
// ── Bottom Panel ───────────────────────────────────────────────────────────
|
||||||
bottomPanel: {
|
bottomPanel: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0,
|
left: 0,
|
||||||
@ -283,68 +574,107 @@ export const getStyles = (colors: any) =>
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingTop: 12,
|
paddingTop: 14,
|
||||||
paddingBottom: Platform.OS === 'ios' ? 28 : 16,
|
paddingBottom: Platform.OS === 'ios' ? 30 : 16,
|
||||||
backgroundColor: colors.cardBg,
|
backgroundColor: colors.cardBg,
|
||||||
borderTopLeftRadius: 20,
|
borderTopLeftRadius: 24,
|
||||||
borderTopRightRadius: 20,
|
borderTopRightRadius: 24,
|
||||||
...Platform.select({
|
...heavyShadow,
|
||||||
ios: {
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: -4 },
|
|
||||||
shadowOpacity: 0.08,
|
|
||||||
shadowRadius: 12,
|
|
||||||
},
|
},
|
||||||
android: { elevation: 10 },
|
bottomTotalWrap: {
|
||||||
}),
|
flex: 1,
|
||||||
},
|
},
|
||||||
bottomTotalWrap: {},
|
|
||||||
bottomTotalLabel: {
|
bottomTotalLabel: {
|
||||||
fontSize: typography.fontSize.xs,
|
fontSize: typography.fontSize.xs,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
bottomTotalRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
},
|
},
|
||||||
bottomTotal: {
|
bottomTotal: {
|
||||||
fontSize: typography.fontSize.lg,
|
fontSize: typography.fontSize.lg,
|
||||||
fontWeight: typography.fontWeight.bold,
|
fontWeight: typography.fontWeight.bold,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
},
|
},
|
||||||
|
bottomSavedBadge: {
|
||||||
|
backgroundColor: colors.primary + '18',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 7,
|
||||||
|
paddingVertical: 3,
|
||||||
|
},
|
||||||
|
bottomSavedText: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
checkoutButton: {
|
checkoutButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginLeft: 16,
|
marginLeft: 14,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- Empty state ----------
|
// ── Empty State ────────────────────────────────────────────────────────────
|
||||||
emptyWrap: {
|
emptyWrap: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingHorizontal: 32,
|
paddingHorizontal: 36,
|
||||||
|
},
|
||||||
|
emptyIllustration: {
|
||||||
|
width: 110,
|
||||||
|
height: 110,
|
||||||
|
borderRadius: 55,
|
||||||
|
backgroundColor: colors.inputBg,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
...cardShadow,
|
||||||
},
|
},
|
||||||
emptyEmoji: {
|
emptyEmoji: {
|
||||||
fontSize: 56,
|
fontSize: 52,
|
||||||
marginBottom: 16,
|
|
||||||
},
|
},
|
||||||
emptyTitle: {
|
emptyTitle: {
|
||||||
fontSize: typography.fontSize.lg,
|
fontSize: typography.fontSize.xl,
|
||||||
fontWeight: typography.fontWeight.bold,
|
fontWeight: typography.fontWeight.bold,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
marginBottom: 6,
|
marginBottom: 10,
|
||||||
},
|
},
|
||||||
emptySubtitle: {
|
emptySubtitle: {
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginBottom: 24,
|
lineHeight: 22,
|
||||||
|
marginBottom: 30,
|
||||||
},
|
},
|
||||||
emptyButton: {
|
emptyButton: {
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: colors.primary,
|
||||||
paddingHorizontal: 28,
|
paddingHorizontal: 36,
|
||||||
paddingVertical: 14,
|
paddingVertical: 15,
|
||||||
borderRadius: 12,
|
borderRadius: 14,
|
||||||
|
...cardShadow,
|
||||||
},
|
},
|
||||||
emptyButtonText: {
|
emptyButtonText: {
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
fontWeight: typography.fontWeight.bold,
|
fontWeight: typography.fontWeight.bold,
|
||||||
fontSize: typography.fontSize.md,
|
fontSize: typography.fontSize.md,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Skeleton ───────────────────────────────────────────────────────────────
|
||||||
|
skeletonCard: {
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginTop: 14,
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 16,
|
||||||
|
...cardShadow,
|
||||||
|
},
|
||||||
|
skeletonItemRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -1,82 +1,237 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
Image,
|
||||||
|
TextInput,
|
||||||
|
LayoutAnimation,
|
||||||
|
Platform,
|
||||||
|
UIManager,
|
||||||
|
Animated,
|
||||||
|
StatusBar,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
import { getStyles } from './cartScreen.styles';
|
import { getStyles } from './cartScreen.styles';
|
||||||
import { Header, QuantitySelector, PrimaryButton } from '@components';
|
import { Header, QuantitySelector, PrimaryButton } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../store';
|
|
||||||
import {
|
import {
|
||||||
removeItem,
|
|
||||||
applyCoupon,
|
|
||||||
removeCoupon,
|
|
||||||
selectCartTotal,
|
selectCartTotal,
|
||||||
|
getCartThunk,
|
||||||
|
updateCartItemThunk,
|
||||||
} from '../../../store/commonreducers/cart';
|
} from '../../../store/commonreducers/cart';
|
||||||
import { AppStackParamList } from '../../../navigation/appStack';
|
import { AppStackParamList } from '../../../navigation/appStack';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@store';
|
||||||
|
import { getFullUrl } from '@utils';
|
||||||
|
|
||||||
|
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
||||||
|
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||||||
|
}
|
||||||
|
|
||||||
type CartScreenNavProp = StackNavigationProp<AppStackParamList, 'CartScreen'>;
|
type CartScreenNavProp = StackNavigationProp<AppStackParamList, 'CartScreen'>;
|
||||||
|
|
||||||
|
const TIP_OPTIONS = [20, 30, 50, 100];
|
||||||
|
|
||||||
|
// ─── Skeleton shimmer ──────────────────────────────────────────────────────────
|
||||||
|
const SkeletonBlock: React.FC<{ width?: number | string; height?: number; borderRadius?: number; colors: any }> = ({
|
||||||
|
width = '100%',
|
||||||
|
height = 16,
|
||||||
|
borderRadius = 8,
|
||||||
|
colors,
|
||||||
|
}) => {
|
||||||
|
const shimmer = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(shimmer, { toValue: 1, duration: 900, useNativeDriver: true }),
|
||||||
|
Animated.timing(shimmer, { toValue: 0, duration: 900, useNativeDriver: true }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [shimmer]);
|
||||||
|
|
||||||
|
const opacity = shimmer.interpolate({ inputRange: [0, 1], outputRange: [0.3, 0.7] });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
width: width as any,
|
||||||
|
height,
|
||||||
|
borderRadius,
|
||||||
|
backgroundColor: colors.border,
|
||||||
|
opacity,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Savings pill ──────────────────────────────────────────────────────────────
|
||||||
|
const SavingsPill: React.FC<{ label: string; styles: any }> = ({ label, styles }) => (
|
||||||
|
<View style={styles.savingsPill}>
|
||||||
|
<Text style={styles.savingsPillEmoji}>🎉</Text>
|
||||||
|
<Text style={styles.savingsPillText}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Section Header ────────────────────────────────────────────────────────────
|
||||||
|
const SectionTitle: React.FC<{ label: string; styles: any }> = ({ label, styles }) => (
|
||||||
|
<Text style={styles.sectionTitle}>{label}</Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Main Component ────────────────────────────────────────────────────────────
|
||||||
export const CartScreen: React.FC = () => {
|
export const CartScreen: React.FC = () => {
|
||||||
const { colors } = useAppTheme();
|
const { colors, isDarkMode } = useAppTheme();
|
||||||
const styles = getStyles(colors);
|
const styles = getStyles(colors);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigation = useNavigation<CartScreenNavProp>();
|
const navigation = useNavigation<CartScreenNavProp>();
|
||||||
const { items, couponCode } = useAppSelector(state => state.cart);
|
const { items, isLoading } = useAppSelector(state => state.cart);
|
||||||
const totals = useAppSelector(selectCartTotal);
|
const totals = useAppSelector(selectCartTotal);
|
||||||
const [couponInput, setCouponInput] = useState('');
|
|
||||||
|
|
||||||
const handleCouponPress = () => {
|
const [instructions, setInstructions] = useState('');
|
||||||
if (couponCode) {
|
const [showInstructionsInput, setShowInstructionsInput] = useState(false);
|
||||||
dispatch(removeCoupon());
|
const [selectedTip, setSelectedTip] = useState<number | null>(null);
|
||||||
setCouponInput('');
|
const [couponInput, setCouponInput] = useState('');
|
||||||
} else if (couponInput) {
|
const [billExpanded, setBillExpanded] = useState(false);
|
||||||
dispatch(applyCoupon(couponInput));
|
|
||||||
|
// Animated values
|
||||||
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const slideAnim = useRef(new Animated.Value(30)).current;
|
||||||
|
const bottomPanelAnim = useRef(new Animated.Value(100)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(getCartThunk());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading) {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(fadeAnim, { toValue: 1, duration: 400, useNativeDriver: true }),
|
||||||
|
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, tension: 80, friction: 10 }),
|
||||||
|
Animated.spring(bottomPanelAnim, { toValue: 0, useNativeDriver: true, tension: 80, friction: 12 }),
|
||||||
|
]).start();
|
||||||
}
|
}
|
||||||
|
}, [isLoading, fadeAnim, slideAnim, bottomPanelAnim]);
|
||||||
|
|
||||||
|
const itemCount = useMemo(
|
||||||
|
() => items.reduce((sum, item) => sum + item.quantity, 0),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tipAmount = selectedTip ?? 0;
|
||||||
|
const grandTotal = (totals.total ?? 0) + tipAmount;
|
||||||
|
|
||||||
|
const toggleBillExpanded = () => {
|
||||||
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||||
|
setBillExpanded(prev => !prev);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (items.length === 0) {
|
// ─── Empty state ──────────────────────────────────────────────────────────────
|
||||||
|
if (!isLoading && items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Header title="Cart" onBack={() => navigation.goBack()} />
|
<StatusBar
|
||||||
|
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
|
||||||
|
backgroundColor={colors.background}
|
||||||
|
/>
|
||||||
|
<Header title="My Cart" onBack={() => navigation.goBack()} />
|
||||||
<View style={styles.emptyWrap}>
|
<View style={styles.emptyWrap}>
|
||||||
|
<View style={styles.emptyIllustration}>
|
||||||
<Text style={styles.emptyEmoji}>🛒</Text>
|
<Text style={styles.emptyEmoji}>🛒</Text>
|
||||||
|
</View>
|
||||||
<Text style={styles.emptyTitle}>Your cart is empty</Text>
|
<Text style={styles.emptyTitle}>Your cart is empty</Text>
|
||||||
<Text style={styles.emptySubtitle}>
|
<Text style={styles.emptySubtitle}>
|
||||||
Looks like you haven't added anything yet. Browse providers and find
|
Looks like you haven't added anything yet.{'\n'}Browse and find something you'll love!
|
||||||
something you'll love.
|
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.emptyButton}
|
style={styles.emptyButton}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
onPress={() => navigation.goBack()}
|
onPress={() => navigation.goBack()}
|
||||||
>
|
>
|
||||||
<Text style={styles.emptyButtonText}>Start Ordering</Text>
|
<Text style={styles.emptyButtonText}>Browse Menu</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Loading skeleton ─────────────────────────────────────────────────────────
|
||||||
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Header title="Cart" onBack={() => navigation.goBack()} />
|
<StatusBar
|
||||||
|
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
|
||||||
|
backgroundColor={colors.background}
|
||||||
|
/>
|
||||||
|
<Header title="My Cart" onBack={() => navigation.goBack()} />
|
||||||
|
<ScrollView contentContainerStyle={styles.list} showsVerticalScrollIndicator={false}>
|
||||||
|
<View style={[styles.skeletonCard, { marginTop: 12 }]}>
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<View key={i} style={styles.skeletonItemRow}>
|
||||||
|
<SkeletonBlock width={56} height={56} borderRadius={12} colors={colors} />
|
||||||
|
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||||
|
<SkeletonBlock width="80%" height={14} colors={colors} />
|
||||||
|
<SkeletonBlock width="40%" height={12} colors={colors} />
|
||||||
|
</View>
|
||||||
|
<SkeletonBlock width={80} height={32} borderRadius={8} colors={colors} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={styles.skeletonCard}>
|
||||||
|
<SkeletonBlock height={60} colors={colors} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.skeletonCard}>
|
||||||
|
<SkeletonBlock height={120} colors={colors} />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Cart View ───────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar
|
||||||
|
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
|
||||||
|
backgroundColor={colors.background}
|
||||||
|
/>
|
||||||
|
<Header title="My Cart" onBack={() => navigation.goBack()} />
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[{ flex: 1 }, { opacity: fadeAnim, transform: [{ translateY: slideAnim }] }]}
|
||||||
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
|
{/* ── ETA Banner ─────────────────────────────────────────── */}
|
||||||
<View style={styles.etaBanner}>
|
<View style={styles.etaBanner}>
|
||||||
|
<View style={styles.etaLeft}>
|
||||||
|
<View style={styles.etaIconWrap}>
|
||||||
<Text style={styles.etaIcon}>🛵</Text>
|
<Text style={styles.etaIcon}>🛵</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
<Text style={styles.etaText}>Delivery in 20–25 mins</Text>
|
<Text style={styles.etaText}>Delivery in 20–25 mins</Text>
|
||||||
|
<Text style={styles.etaSubtext}>Order arrives fresh & hot 🔥</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.etaBadge}>
|
||||||
|
<Text style={styles.etaBadgeText}>FASTEST</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* ── Items Card ─────────────────────────────────────────── */}
|
||||||
<View style={styles.itemsCard}>
|
<View style={styles.itemsCard}>
|
||||||
|
<View style={styles.itemsCardHeader}>
|
||||||
|
<Text style={styles.itemsCardHeaderText}>
|
||||||
|
{itemCount} {itemCount === 1 ? 'item' : 'items'} in your cart
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<View
|
<View
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@ -85,57 +240,123 @@ export const CartScreen: React.FC = () => {
|
|||||||
index < items.length - 1 && styles.cartItemDivider,
|
index < items.length - 1 && styles.cartItemDivider,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.itemThumb}>
|
{/* Veg / Non-veg indicator */}
|
||||||
<Text style={styles.itemThumbEmoji}>🍕</Text>
|
{/* {typeof item.product === 'boolean' && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.vegIndicator,
|
||||||
|
{ borderColor: item.product ? '#2E7D32' : '#C62828' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.vegDot,
|
||||||
|
{ backgroundColor: item.product ? '#2E7D32' : '#C62828' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<View style={styles.itemThumbWrap}>
|
||||||
|
<Image
|
||||||
|
style={styles.itemThumb}
|
||||||
|
source={{ uri: getFullUrl(item.product.imageUrl) }}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
<View style={styles.itemInfo}>
|
<View style={styles.itemInfo}>
|
||||||
<Text style={styles.itemTitle} numberOfLines={2}>
|
<Text style={styles.itemTitle} numberOfLines={2}>
|
||||||
{item.item.title}
|
{item.product.name}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.itemPriceRow}>
|
||||||
|
<Text style={styles.itemPrice}>₹{item.product.price}</Text>
|
||||||
|
{/* {!!item.product.mrp && item.product.mrp > item.product.price && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.itemMrp}>₹{item.product.mrp}</Text>
|
||||||
|
<View style={styles.discountBadge}>
|
||||||
|
<Text style={styles.discountBadgeText}>
|
||||||
|
{Math.round(((item.product.mrp - item.product.price) / item.product.mrp) * 100)}% OFF
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.itemPrice}>₹{item.item.price}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
</>
|
||||||
|
)} */}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Quantity */}
|
||||||
<QuantitySelector
|
<QuantitySelector
|
||||||
value={item.quantity}
|
value={item.quantity}
|
||||||
onIncrement={() => {}}
|
onIncrement={() =>
|
||||||
onDecrement={() => dispatch(removeItem(item.item.id))}
|
dispatch(
|
||||||
|
updateCartItemThunk({
|
||||||
|
productId: item.productId,
|
||||||
|
quantity: item.quantity + 1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDecrement={() => {
|
||||||
|
if (item.quantity >= 1) {
|
||||||
|
dispatch(
|
||||||
|
updateCartItemThunk({
|
||||||
|
productId: item.productId,
|
||||||
|
quantity: item.quantity - 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
|
||||||
|
|
||||||
|
{/* Add more items */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.addMoreRow}
|
style={styles.addMoreRow}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
onPress={() => navigation.goBack()}
|
onPress={() => navigation.goBack()}
|
||||||
>
|
>
|
||||||
<Text style={styles.addMoreIcon}>+</Text>
|
<Text style={styles.addMoreIcon}>+</Text>
|
||||||
<Text style={styles.addMoreText}>Add more items</Text>
|
<Text style={styles.addMoreText}>Add more items</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
{/* ── Savings if any ─────────────────────────────────────── */}
|
||||||
<Text style={styles.sectionTitle}>Apply Coupon</Text>
|
{totals.discount > 0 && (
|
||||||
|
<SavingsPill
|
||||||
|
label={`You're saving ₹${totals.discount} on this order 🎉`}
|
||||||
|
styles={styles}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Coupons & Offers ───────────────────────────────────── */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<SectionTitle label="Coupons & Offers" styles={styles} />
|
||||||
|
|
||||||
|
{totals.discount > 0 ? (
|
||||||
<View style={styles.couponCard}>
|
<View style={styles.couponCard}>
|
||||||
{couponCode ? (
|
|
||||||
<View style={styles.couponAppliedRow}>
|
<View style={styles.couponAppliedRow}>
|
||||||
<View style={styles.couponAppliedLeft}>
|
<View style={styles.couponAppliedLeft}>
|
||||||
<View style={styles.couponCheckBadge}>
|
<View style={styles.couponCheckBadge}>
|
||||||
<Text style={styles.couponCheckIcon}>✓</Text>
|
<Text style={styles.couponCheckIcon}>🏷️</Text>
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View>
|
||||||
<Text style={styles.couponAppliedCode}>{couponCode}</Text>
|
<Text style={styles.couponAppliedCode}>Coupon Applied!</Text>
|
||||||
<Text style={styles.couponAppliedSub}>Coupon applied</Text>
|
<Text style={styles.couponAppliedSub}>
|
||||||
|
You saved ₹{totals.discount} on this order
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity style={styles.couponRemoveBtn}>
|
||||||
onPress={handleCouponPress}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Text style={styles.couponRemoveText}>Remove</Text>
|
<Text style={styles.couponRemoveText}>Remove</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.couponRow}>
|
<View style={styles.couponCard}>
|
||||||
<Text style={styles.couponIcon}>🏷️</Text>
|
<View style={styles.couponInputRow}>
|
||||||
|
<Text style={styles.couponIcon}>🎁</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.couponInput}
|
style={styles.couponInput}
|
||||||
placeholder="Enter coupon code"
|
placeholder="Enter coupon code"
|
||||||
@ -145,69 +366,214 @@ export const CartScreen: React.FC = () => {
|
|||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.applyButton}
|
style={[
|
||||||
onPress={handleCouponPress}
|
styles.applyButton,
|
||||||
activeOpacity={0.7}
|
!couponInput && styles.applyButtonDisabled,
|
||||||
|
]}
|
||||||
disabled={!couponInput}
|
disabled={!couponInput}
|
||||||
|
activeOpacity={0.85}
|
||||||
>
|
>
|
||||||
<Text style={styles.applyButtonText}>Apply</Text>
|
<Text style={styles.applyButtonText}>Apply</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
<TouchableOpacity style={styles.viewCouponsRow} activeOpacity={0.7} onPress={() => navigation.navigate('MainTabs', { screen: 'OffersScreen' })}>
|
||||||
|
<View style={styles.viewCouponsLeft}>
|
||||||
|
<Text style={styles.viewCouponsTag}>🔖</Text>
|
||||||
|
<Text style={styles.viewCouponsText}>View all available offers</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.viewCouponsChevron}>›</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.sectionTitle}>Bill Details</Text>
|
{/* ── Order Instructions ─────────────────────────────────── */}
|
||||||
|
{/* <View style={styles.section}>
|
||||||
|
<SectionTitle label="Order Instructions" styles={styles} />
|
||||||
|
<View style={styles.instructionsCard}>
|
||||||
|
{showInstructionsInput ? (
|
||||||
|
<TextInput
|
||||||
|
style={styles.instructionsInput}
|
||||||
|
placeholder="e.g. No onions, leave at door, don't ring bell…"
|
||||||
|
placeholderTextColor={colors.placeholder}
|
||||||
|
value={instructions}
|
||||||
|
onChangeText={setInstructions}
|
||||||
|
multiline
|
||||||
|
autoFocus
|
||||||
|
onBlur={() => setShowInstructionsInput(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.instructionsRow}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={() => setShowInstructionsInput(true)}
|
||||||
|
>
|
||||||
|
<Text style={styles.instructionsIcon}>📝</Text>
|
||||||
|
<Text
|
||||||
|
style={
|
||||||
|
instructions
|
||||||
|
? styles.instructionsFilledText
|
||||||
|
: styles.instructionsPlaceholder
|
||||||
|
}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{instructions || 'Add a note for the rider or restaurant'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.instructionsChevron}>›</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View> */}
|
||||||
|
|
||||||
|
{/* ── Tip Your Delivery Partner ──────────────────────────── */}
|
||||||
|
{/* <View style={styles.section}>
|
||||||
|
<SectionTitle label="Tip Your Delivery Partner 💛" styles={styles} />
|
||||||
|
<View style={styles.tipCard}>
|
||||||
|
<Text style={styles.tipSubtitle}>
|
||||||
|
100% of the tip goes directly to your delivery partner. They
|
||||||
|
work hard so your food arrives hot!
|
||||||
|
</Text>
|
||||||
|
<View style={styles.tipOptionsRow}>
|
||||||
|
{TIP_OPTIONS.map(amount => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={amount}
|
||||||
|
style={[
|
||||||
|
styles.tipOption,
|
||||||
|
selectedTip === amount && styles.tipOptionSelected,
|
||||||
|
]}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
onPress={() =>
|
||||||
|
setSelectedTip(prev => (prev === amount ? null : amount))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedTip === amount && (
|
||||||
|
<Text style={styles.tipSelectedCheck}>✓ </Text>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tipOptionText,
|
||||||
|
selectedTip === amount && styles.tipOptionTextSelected,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
₹{amount}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{selectedTip !== null && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.tipRemove}
|
||||||
|
onPress={() => setSelectedTip(null)}
|
||||||
|
>
|
||||||
|
<Text style={styles.tipRemoveText}>✕ Remove tip</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View> */}
|
||||||
|
|
||||||
|
{/* ── Bill Details ───────────────────────────────────────── */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<SectionTitle label="Bill Details" styles={styles} />
|
||||||
<View style={styles.billCard}>
|
<View style={styles.billCard}>
|
||||||
<View style={styles.feeRow}>
|
<View style={styles.feeRow}>
|
||||||
<Text style={styles.feeLabel}>Subtotal</Text>
|
<Text style={styles.feeLabel}>Item Total</Text>
|
||||||
<Text style={styles.feeValue}>₹{totals.subtotal}</Text>
|
<Text style={styles.feeValue}>₹{totals.subtotal}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.feeRow}>
|
<View style={styles.feeRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.feeLabelRow}
|
||||||
|
onPress={toggleBillExpanded}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
<Text style={styles.feeLabel}>Delivery Fee</Text>
|
<Text style={styles.feeLabel}>Delivery Fee</Text>
|
||||||
|
<Text style={styles.infoIcon}> ⓘ</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
<Text style={styles.feeValue}>₹{totals.deliveryFee}</Text>
|
<Text style={styles.feeValue}>₹{totals.deliveryFee}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{billExpanded && (
|
||||||
|
<View style={styles.gstNote}>
|
||||||
|
<Text style={styles.gstNoteText}>
|
||||||
|
Delivery fee helps cover your delivery partner's costs. GST as
|
||||||
|
applicable is included in the item total.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={styles.feeRow}>
|
<View style={styles.feeRow}>
|
||||||
<Text style={styles.feeLabel}>Platform Fee</Text>
|
<Text style={styles.feeLabel}>Platform Fee</Text>
|
||||||
<Text style={styles.feeValue}>₹{totals.platformFee}</Text>
|
<Text style={styles.feeValue}>₹{totals.platformFee}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{tipAmount > 0 && (
|
||||||
|
<View style={styles.feeRow}>
|
||||||
|
<Text style={styles.feeLabel}>Delivery Tip 💛</Text>
|
||||||
|
<Text style={styles.feeValue}>₹{tipAmount}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{totals.discount > 0 && (
|
{totals.discount > 0 && (
|
||||||
<View style={styles.feeRow}>
|
<View style={styles.feeRow}>
|
||||||
<Text style={[styles.feeLabel, { color: colors.primary }]}>
|
<Text style={[styles.feeLabel, styles.discountLabel]}>
|
||||||
Discount
|
Coupon Discount
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.feeValue, { color: colors.primary }]}>
|
<Text style={[styles.feeValue, styles.discountValue]}>
|
||||||
-₹{totals.discount}
|
− ₹{totals.discount}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View style={[styles.feeRow, styles.totalRow]}>
|
|
||||||
<Text style={styles.totalLabel}>Total</Text>
|
<View style={styles.billDivider} />
|
||||||
<Text style={styles.totalValue}>₹{totals.total}</Text>
|
|
||||||
|
<View style={styles.totalRow}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.totalLabel}>To Pay</Text>
|
||||||
|
{totals.discount > 0 && (
|
||||||
|
<Text style={styles.totalSaved}>
|
||||||
|
Saved ₹{totals.discount}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.totalValue}>₹{grandTotal}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{totals.discount > 0 && (
|
{/* ── Cancellation Policy ────────────────────────────────── */}
|
||||||
<View style={styles.savingsBanner}>
|
<View style={styles.policyCard}>
|
||||||
<Text style={styles.savingsIcon}>🎉</Text>
|
<Text style={styles.policyIcon}>🛈</Text>
|
||||||
<Text style={styles.savingsText}>
|
<Text style={styles.policyText}>
|
||||||
You're saving ₹{totals.discount} on this order
|
Orders cannot be cancelled once packed for delivery. In case of
|
||||||
|
unexpected delays, a refund will be provided, if applicable.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
<View style={styles.bottomPanel}>
|
{/* ── Bottom Panel ───────────────────────────────────────────── */}
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.bottomPanel, { transform: [{ translateY: bottomPanelAnim }] }]}
|
||||||
|
>
|
||||||
<View style={styles.bottomTotalWrap}>
|
<View style={styles.bottomTotalWrap}>
|
||||||
<Text style={styles.bottomTotalLabel}>Total</Text>
|
<Text style={styles.bottomTotalLabel}>
|
||||||
<Text style={styles.bottomTotal}>₹{totals.total}</Text>
|
{itemCount} {itemCount === 1 ? 'item' : 'items'}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.bottomTotalRow}>
|
||||||
|
<Text style={styles.bottomTotal}>₹{grandTotal}</Text>
|
||||||
|
{totals.discount > 0 && (
|
||||||
|
<View style={styles.bottomSavedBadge}>
|
||||||
|
<Text style={styles.bottomSavedText}>saved ₹{totals.discount}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
title="Proceed to Checkout"
|
title="Proceed to Checkout →"
|
||||||
onPress={() => navigation.navigate('CheckoutAddressScreen')}
|
onPress={() => navigation.navigate('CheckoutAddressScreen')}
|
||||||
style={styles.checkoutButton}
|
style={styles.checkoutButton}
|
||||||
/>
|
/>
|
||||||
</View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -1,37 +1,15 @@
|
|||||||
import { StyleSheet } from 'react-native';
|
import { StyleSheet } from 'react-native';
|
||||||
import { typography } from '@theme';
|
import { typography } from '@theme';
|
||||||
|
|
||||||
export const getStyles = (colors: any) => StyleSheet.create({
|
export const getStyles = (colors: any) =>
|
||||||
|
StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: colors.background,
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
padding: 16,
|
padding: 16,
|
||||||
},
|
paddingBottom: 24,
|
||||||
addressCard: {
|
|
||||||
backgroundColor: colors.cardBg,
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 16,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.border,
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
addressLabel: {
|
|
||||||
fontSize: typography.fontSize.xs,
|
|
||||||
color: colors.textSecondary,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
addressText: {
|
|
||||||
fontSize: typography.fontSize.md,
|
|
||||||
fontWeight: typography.fontWeight.semibold,
|
|
||||||
color: colors.text,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
addressSubtext: {
|
|
||||||
fontSize: typography.fontSize.sm,
|
|
||||||
color: colors.primary,
|
|
||||||
fontWeight: typography.fontWeight.medium,
|
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: typography.fontSize.md,
|
fontSize: typography.fontSize.md,
|
||||||
@ -39,32 +17,61 @@ export const getStyles = (colors: any) => StyleSheet.create({
|
|||||||
color: colors.text,
|
color: colors.text,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
optionCard: {
|
emptyState: {
|
||||||
flexDirection: 'row',
|
padding: 24,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: 16,
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
emptyStateText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
addressCard: {
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
marginBottom: 10,
|
marginBottom: 12,
|
||||||
backgroundColor: colors.background,
|
|
||||||
},
|
},
|
||||||
optionCardSelected: {
|
addressCardSelected: {
|
||||||
borderColor: colors.primary,
|
borderColor: colors.primary,
|
||||||
backgroundColor: '#E8F5E9',
|
backgroundColor: '#E8F5E9',
|
||||||
},
|
},
|
||||||
optionInfo: {
|
addressCardHeader: {
|
||||||
flex: 1,
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
optionTitle: {
|
labelBadge: {
|
||||||
fontSize: typography.fontSize.md,
|
flexDirection: 'row',
|
||||||
fontWeight: typography.fontWeight.medium,
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderRadius: 20,
|
||||||
|
paddingVertical: 4,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
labelBadgeIcon: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
labelBadgeText: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
marginBottom: 2,
|
|
||||||
},
|
},
|
||||||
optionSubtext: {
|
defaultBadge: {
|
||||||
fontSize: typography.fontSize.sm,
|
backgroundColor: colors.primary,
|
||||||
color: colors.textSecondary,
|
borderRadius: 20,
|
||||||
|
paddingVertical: 3,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
defaultBadgeText: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: '#fff',
|
||||||
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
radio: {
|
radio: {
|
||||||
width: 22,
|
width: 22,
|
||||||
@ -74,6 +81,7 @@ export const getStyles = (colors: any) => StyleSheet.create({
|
|||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
marginLeft: 'auto',
|
||||||
},
|
},
|
||||||
radioSelected: {
|
radioSelected: {
|
||||||
borderColor: colors.primary,
|
borderColor: colors.primary,
|
||||||
@ -84,6 +92,36 @@ export const getStyles = (colors: any) => StyleSheet.create({
|
|||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: colors.primary,
|
||||||
},
|
},
|
||||||
|
addressText: {
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
addressSubtext: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
addressPhone: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
addAddressButton: {
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
addAddressButtonText: {
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
padding: 16,
|
padding: 16,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import {
|
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
TouchableOpacity,
|
|
||||||
ScrollView,
|
|
||||||
} from 'react-native';
|
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
import { getStyles } from './checkoutAddressScreen.styles';
|
import { getStyles } from './checkoutAddressScreen.styles';
|
||||||
import { Header, StepProgress, PrimaryButton } from '@components';
|
import { Header, StepProgress, PrimaryButton } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { AppStackParamList } from '../../../navigation/appStack';
|
import { useAppSelector } from '@store';
|
||||||
|
import { AddressLabel, CustomerAddress } from '@interfaces';
|
||||||
|
import { AppStackParamList } from '@navigation/appStack';
|
||||||
|
|
||||||
type CheckoutAddressNavProp = StackNavigationProp<AppStackParamList, 'CheckoutAddressScreen'>;
|
type CheckoutAddressNavProp = StackNavigationProp<
|
||||||
|
AppStackParamList,
|
||||||
|
'CheckoutAddressScreen'
|
||||||
|
>;
|
||||||
|
|
||||||
const CHECKOUT_STEPS = [
|
const CHECKOUT_STEPS = [
|
||||||
{ key: 'address', label: 'Address' },
|
{ key: 'address', label: 'Address' },
|
||||||
@ -20,62 +20,118 @@ const CHECKOUT_STEPS = [
|
|||||||
{ key: 'confirm', label: 'Confirm' },
|
{ key: 'confirm', label: 'Confirm' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const LABEL_ICON: Record<AddressLabel, string> = {
|
||||||
|
Home: '🏠',
|
||||||
|
Work: '💼',
|
||||||
|
Other: '📍',
|
||||||
|
};
|
||||||
|
|
||||||
export const CheckoutAddressScreen: React.FC = () => {
|
export const CheckoutAddressScreen: React.FC = () => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
const styles = getStyles(colors);
|
const styles = getStyles(colors);
|
||||||
const navigation = useNavigation<CheckoutAddressNavProp>();
|
const navigation = useNavigation<CheckoutAddressNavProp>();
|
||||||
const [deliveryOption, setDeliveryOption] = useState<'standard' | 'express'>('standard');
|
const { customerDetails } = useAppSelector(state => state.customerProfile);
|
||||||
|
|
||||||
|
const addresses: CustomerAddress[] = useMemo(() => customerDetails?.addresses ?? [], [customerDetails]);
|
||||||
|
|
||||||
|
const defaultAddressId = useMemo(() => {
|
||||||
|
const def = addresses.find(a => a.isDefault);
|
||||||
|
return def?.id ?? addresses[0]?.id ?? null;
|
||||||
|
}, [addresses]);
|
||||||
|
|
||||||
|
const [selectedAddressId, setSelectedAddressId] = useState<string | null>(
|
||||||
|
defaultAddressId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
if (!selectedAddressId) return;
|
||||||
|
navigation.navigate('CheckoutPaymentScreen', {
|
||||||
|
selectedAddressId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Header title="Checkout" onBack={() => navigation.goBack()} />
|
<Header title="Checkout" onBack={() => navigation.goBack()} />
|
||||||
<StepProgress steps={CHECKOUT_STEPS} activeStep={0} />
|
<StepProgress steps={CHECKOUT_STEPS} activeStep={0} />
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
<View style={styles.addressCard}>
|
<Text style={styles.sectionTitle}>Deliver to</Text>
|
||||||
<Text style={styles.addressLabel}>Deliver to</Text>
|
|
||||||
<Text style={styles.addressText}>
|
{addresses.length === 0 && (
|
||||||
Koramangala 4th Block, Bengaluru, 560034
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={styles.emptyStateText}>No saved addresses yet.</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addresses.map(address => {
|
||||||
|
const isSelected = selectedAddressId === address.id;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={address.id}
|
||||||
|
style={[
|
||||||
|
styles.addressCard,
|
||||||
|
isSelected && styles.addressCardSelected,
|
||||||
|
]}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={() => setSelectedAddressId(address.id)}
|
||||||
|
>
|
||||||
|
<View style={styles.addressCardHeader}>
|
||||||
|
<View style={styles.labelBadge}>
|
||||||
|
<Text style={styles.labelBadgeIcon}>
|
||||||
|
{LABEL_ICON[address.label] ?? '📍'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.addressSubtext}>Home</Text>
|
<Text style={styles.labelBadgeText}>{address.label}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.sectionTitle}>Delivery Option</Text>
|
{address.isDefault && (
|
||||||
<TouchableOpacity
|
<View style={styles.defaultBadge}>
|
||||||
style={[
|
<Text style={styles.defaultBadgeText}>DEFAULT</Text>
|
||||||
styles.optionCard,
|
</View>
|
||||||
deliveryOption === 'standard' && styles.optionCardSelected,
|
)}
|
||||||
]}
|
|
||||||
onPress={() => setDeliveryOption('standard')}
|
<View
|
||||||
activeOpacity={0.7}
|
style={[styles.radio, isSelected && styles.radioSelected]}
|
||||||
>
|
>
|
||||||
<View style={styles.optionInfo}>
|
{isSelected && <View style={styles.radioInner} />}
|
||||||
<Text style={styles.optionTitle}>Standard Delivery</Text>
|
|
||||||
<Text style={styles.optionSubtext}>Free • 25-30 min</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<View style={[styles.radio, deliveryOption === 'standard' && styles.radioSelected]}>
|
|
||||||
{deliveryOption === 'standard' && <View style={styles.radioInner} />}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.addressText} numberOfLines={2}>
|
||||||
|
{address.houseNumber ? `${address.houseNumber}, ` : ''}
|
||||||
|
{address.addressLine1}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{!!address.landmark && (
|
||||||
|
<Text style={styles.addressSubtext} numberOfLines={1}>
|
||||||
|
Landmark: {address.landmark}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.addressSubtext} numberOfLines={1}>
|
||||||
|
{address.city}, {address.state} - {address.postalCode}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={styles.addressPhone}>📞 {address.phone}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={styles.addAddressButton}
|
||||||
styles.optionCard,
|
|
||||||
deliveryOption === 'express' && styles.optionCardSelected,
|
|
||||||
]}
|
|
||||||
onPress={() => setDeliveryOption('express')}
|
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
onPress={() => {
|
||||||
|
// navigation.navigate('AddAddressScreen');
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<View style={styles.optionInfo}>
|
<Text style={styles.addAddressButtonText}>+ Add New Address</Text>
|
||||||
<Text style={styles.optionTitle}>Express Delivery</Text>
|
|
||||||
<Text style={styles.optionSubtext}>₹40 • 10-15 min</Text>
|
|
||||||
</View>
|
|
||||||
<View style={[styles.radio, deliveryOption === 'express' && styles.radioSelected]}>
|
|
||||||
{deliveryOption === 'express' && <View style={styles.radioInner} />}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<PrimaryButton title="Continue to Payment" onPress={() => navigation.navigate('CheckoutPaymentScreen')} />
|
<PrimaryButton
|
||||||
|
title="Continue to Payment"
|
||||||
|
onPress={handleContinue}
|
||||||
|
disabled={!selectedAddressId}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,13 +1,30 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { View, Text, ScrollView } from 'react-native';
|
import { View, Text, ScrollView, Alert } from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
import { getStyles } from './checkoutPaymentScreen.styles';
|
import { getStyles } from './checkoutPaymentScreen.styles';
|
||||||
import { Header, StepProgress, PaymentOption, PrimaryButton } from '@components';
|
import {
|
||||||
|
Header,
|
||||||
|
StepProgress,
|
||||||
|
PaymentOption,
|
||||||
|
PrimaryButton,
|
||||||
|
} from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { AppStackParamList } from '../../../navigation/appStack';
|
import { AppStackParamList } from '../../../navigation/appStack';
|
||||||
|
import { RootState, useAppDispatch, useAppSelector } from '@store';
|
||||||
|
import { getAllPaymentMethodsThunk, placeOrderThunk } from './thunk';
|
||||||
|
import { v4 } from 'react-native-uuid/dist/v4';
|
||||||
|
import { useStripePayment } from './hooks/useStripePayment';
|
||||||
|
import { useRazorPayment } from './hooks/useRazorPayment';
|
||||||
|
|
||||||
type CheckoutPaymentNavProp = StackNavigationProp<AppStackParamList, 'CheckoutPaymentScreen'>;
|
type CheckoutPaymentNavProp = StackNavigationProp<
|
||||||
|
AppStackParamList,
|
||||||
|
'CheckoutPaymentScreen'
|
||||||
|
>;
|
||||||
|
type CheckoutPaymentRouteProp = RouteProp<
|
||||||
|
AppStackParamList,
|
||||||
|
'CheckoutPaymentScreen'
|
||||||
|
>;
|
||||||
|
|
||||||
const CHECKOUT_STEPS = [
|
const CHECKOUT_STEPS = [
|
||||||
{ key: 'address', label: 'Address' },
|
{ key: 'address', label: 'Address' },
|
||||||
@ -15,18 +32,96 @@ const CHECKOUT_STEPS = [
|
|||||||
{ key: 'confirm', label: 'Confirm' },
|
{ key: 'confirm', label: 'Confirm' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const PAYMENT_METHODS = [
|
// const PAYMENT_METHODS = [
|
||||||
{ id: 'upi', type: 'UPI' as const, label: 'UPI (Google Pay, PhonePe)' },
|
// { id: 'upi', type: 'UPI' as const, label: 'UPI (Google Pay, PhonePe)' },
|
||||||
{ id: 'card', type: 'Card' as const, label: 'Credit / Debit Card' },
|
// { id: 'card', type: 'Card' as const, label: 'Credit / Debit Card' },
|
||||||
{ id: 'wallet', type: 'Wallet' as const, label: 'Wallet' },
|
// { id: 'wallet', type: 'Wallet' as const, label: 'Wallet' },
|
||||||
{ id: 'cod', type: 'COD' as const, label: 'Cash on Delivery' },
|
// { id: 'cod', type: 'COD' as const, label: 'Cash on Delivery' },
|
||||||
];
|
// ];
|
||||||
|
|
||||||
export const CheckoutPaymentScreen: React.FC = () => {
|
export const CheckoutPaymentScreen: React.FC = () => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
const styles = getStyles(colors);
|
const styles = getStyles(colors);
|
||||||
|
const route = useRoute<CheckoutPaymentRouteProp>();
|
||||||
const navigation = useNavigation<CheckoutPaymentNavProp>();
|
const navigation = useNavigation<CheckoutPaymentNavProp>();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const [selectedMethod, setSelectedMethod] = useState('upi');
|
const [selectedMethod, setSelectedMethod] = useState('upi');
|
||||||
|
const selectedAddressId = route.params?.selectedAddressId;
|
||||||
|
// console.log(selectedAddressId);
|
||||||
|
const uuid = v4();
|
||||||
|
// console.log(uuid);
|
||||||
|
const { paymentMethods, placeOrderLoading } = useAppSelector(
|
||||||
|
(state: RootState) => state.paymentMethods,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { processCardPayment, isPaymentProcessing } = useStripePayment();
|
||||||
|
const { processRazorpayPayment, isPaymentProcessing: isRazorpayProcessing } =
|
||||||
|
useRazorPayment();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(getAllPaymentMethodsThunk());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// console.log('paymentMethods', paymentMethods);
|
||||||
|
|
||||||
|
const selectedPaymentMethod = paymentMethods.find(
|
||||||
|
item => item.id === selectedMethod,
|
||||||
|
);
|
||||||
|
|
||||||
|
const methodName = selectedPaymentMethod?.code;
|
||||||
|
// console.log('methodName', methodName);
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
|
||||||
|
// },);
|
||||||
|
|
||||||
|
const handlePlaceOrder = async () => {
|
||||||
|
try {
|
||||||
|
const response = await dispatch(
|
||||||
|
placeOrderThunk({
|
||||||
|
addressId: selectedAddressId || '',
|
||||||
|
paymentMethodId: selectedMethod,
|
||||||
|
paymentMethod: methodName || '',
|
||||||
|
orderType: 'DELIVERY',
|
||||||
|
idempotencyKey: `idemp-key-${uuid}`,
|
||||||
|
}),
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
if (methodName === 'CARD') {
|
||||||
|
// Delegate entire Stripe flow to the hook
|
||||||
|
const success = await processCardPayment(response);
|
||||||
|
if (success) {
|
||||||
|
navigation.navigate('OrderConfirmedScreen', {
|
||||||
|
orderId:
|
||||||
|
response.orders[0]?.orderNumber ||
|
||||||
|
'ORD-' + Math.floor(Math.random() * 900000 + 100000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (methodName === 'UPI') {
|
||||||
|
// Delegate entire Razorpay UPI flow to the hook
|
||||||
|
const success = await processRazorpayPayment(response, 'upi');
|
||||||
|
if (success) {
|
||||||
|
navigation.navigate('OrderConfirmedScreen', {
|
||||||
|
orderId:
|
||||||
|
response.orders[0]?.orderNumber ||
|
||||||
|
'ORD-' + Math.floor(Math.random() * 900000 + 100000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-card methods (COD, Wallet) – navigate directly
|
||||||
|
navigation.replace('OrderConfirmedScreen', {
|
||||||
|
orderId:
|
||||||
|
response.orders[0]?.orderNumber ||
|
||||||
|
'ORD-' + Math.floor(Math.random() * 900000 + 100000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
Alert.alert('Error', err || 'Failed to place order');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
placeOrderLoading || isPaymentProcessing || isRazorpayProcessing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@ -34,18 +129,22 @@ export const CheckoutPaymentScreen: React.FC = () => {
|
|||||||
<StepProgress steps={CHECKOUT_STEPS} activeStep={1} />
|
<StepProgress steps={CHECKOUT_STEPS} activeStep={1} />
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
<Text style={styles.sectionTitle}>Select Payment Method</Text>
|
<Text style={styles.sectionTitle}>Select Payment Method</Text>
|
||||||
{PAYMENT_METHODS.map((method) => (
|
{paymentMethods?.map(method => (
|
||||||
<PaymentOption
|
<PaymentOption
|
||||||
key={method.id}
|
key={method.id}
|
||||||
type={method.type}
|
label={method.code}
|
||||||
label={method.label}
|
icon={method.iconUrl}
|
||||||
isSelected={selectedMethod === method.id}
|
isSelected={selectedMethod === method.id}
|
||||||
onSelect={() => setSelectedMethod(method.id)}
|
onSelect={() => setSelectedMethod(method.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<PrimaryButton title="Place Order" onPress={() => navigation.navigate('OrderConfirmedScreen', { orderId: 'ORD-' + Math.floor(Math.random() * 900000 + 100000) })} />
|
<PrimaryButton
|
||||||
|
title={isLoading ? 'Processing...' : 'Place Order'}
|
||||||
|
onPress={handlePlaceOrder}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,120 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Alert } from 'react-native';
|
||||||
|
// @ts-ignore
|
||||||
|
import RazorpayCheckout from 'react-native-razorpay';
|
||||||
|
import { PlaceOrderResponse } from '@interfaces';
|
||||||
|
import { verifyPaymentApi } from '@api';
|
||||||
|
import { useAppSelector } from '@store';
|
||||||
|
import { ENV } from '../../../../config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook that encapsulates the entire Razorpay UPI payment flow:
|
||||||
|
* 1. Extract session details (gatewayOrderId, amount) from the place-order response
|
||||||
|
* 2. Configure Razorpay checkout options with prefilled customer profile details
|
||||||
|
* 3. Present the Razorpay Checkout overlay to the user
|
||||||
|
* 4. Verify the payment signature on the backend via /payments/process
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - processRazorpayPayment: async function to execute the Razorpay flow
|
||||||
|
* - isPaymentProcessing: loading flag for UPI payment step
|
||||||
|
*/
|
||||||
|
export const useRazorPayment = () => {
|
||||||
|
const [isPaymentProcessing, setIsPaymentProcessing] = useState(false);
|
||||||
|
const user = useAppSelector(
|
||||||
|
state => state.customerProfile.customerDetails?.user,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the complete Razorpay payment flow.
|
||||||
|
* @param response – the PlaceOrderResponse from the backend
|
||||||
|
* @returns `true` if payment was completed and verified successfully, `false` otherwise
|
||||||
|
*/
|
||||||
|
const processRazorpayPayment = useCallback(
|
||||||
|
async (
|
||||||
|
response: PlaceOrderResponse,
|
||||||
|
paymentMethod: 'upi' | 'card' | undefined = undefined,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
setIsPaymentProcessing(true);
|
||||||
|
|
||||||
|
// ── 1. Extract session details ─────────────────────────────────────
|
||||||
|
const session = response.checkoutSessions?.[0];
|
||||||
|
if (!session) {
|
||||||
|
Alert.alert('Error', 'Payment session not found.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { gatewayOrderId, amount } = session;
|
||||||
|
if (!gatewayOrderId) {
|
||||||
|
Alert.alert('Error', 'Razorpay Order ID not found.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amount in checkoutSessions represents standard rupees.
|
||||||
|
// Fallback to first order's total amount if needed.
|
||||||
|
const amountVal =
|
||||||
|
amount ?? parseFloat(response.orders?.[0]?.totalAmount || '0');
|
||||||
|
// Razorpay SDK requires the amount in the smallest currency sub-units (paise for INR)
|
||||||
|
const amountInPaise = Math.round(amountVal * 100);
|
||||||
|
|
||||||
|
// ── 2. Configure Checkout Options ──────────────────────────────────
|
||||||
|
const options = {
|
||||||
|
key: ENV.RAZORPAY_KEY_ID,
|
||||||
|
amount: amountInPaise,
|
||||||
|
currency: 'INR',
|
||||||
|
name: 'SG Delivery',
|
||||||
|
description: `Payment for Order #${
|
||||||
|
session.orderNumber || response.orders?.[0]?.orderNumber || ''
|
||||||
|
}`,
|
||||||
|
order_id: gatewayOrderId,
|
||||||
|
method: 'upi',
|
||||||
|
prefill: {
|
||||||
|
name: user?.name || '',
|
||||||
|
email: user?.email || '',
|
||||||
|
contact: user?.phone || '',
|
||||||
|
},
|
||||||
|
theme: { color: '#05824C' }, // Matching brand green primary color
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 3. Present Razorpay Checkout SDK ───────────────────────────────
|
||||||
|
const rzpData = await RazorpayCheckout.open(options);
|
||||||
|
|
||||||
|
// ── 4. Verify payment cryptographically on backend ────────────────
|
||||||
|
const verifyRes = await verifyPaymentApi({
|
||||||
|
gatewayOrderId: rzpData.razorpay_order_id || gatewayOrderId,
|
||||||
|
gatewayPaymentId: rzpData.razorpay_payment_id,
|
||||||
|
gatewaySignature: rzpData.razorpay_signature,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (verifyRes.success) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
'Verification Failed',
|
||||||
|
verifyRes.message || 'Unable to confirm payment.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// Razorpay Checkout library throws an error if user cancels or transaction fails
|
||||||
|
if (error && error.code) {
|
||||||
|
Alert.alert(
|
||||||
|
'Payment Cancelled',
|
||||||
|
error.description || 'You cancelled the payment process.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
'Payment Error',
|
||||||
|
error?.message || 'Something went wrong during payment.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsPaymentProcessing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[user],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { processRazorpayPayment, isPaymentProcessing };
|
||||||
|
};
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Alert } from 'react-native';
|
||||||
|
import { useStripe } from '@stripe/stripe-react-native';
|
||||||
|
import { PlaceOrderResponse } from '@interfaces';
|
||||||
|
import { verifyPaymentApi } from '@api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook that encapsulates the entire Stripe card payment flow:
|
||||||
|
* 1. Extract session info from the place-order response
|
||||||
|
* 2. Initialize the Stripe Payment Sheet
|
||||||
|
* 3. Present the Payment Sheet to the user
|
||||||
|
* 4. Verify the payment on the backend via /payments/process
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - processCardPayment: async function to run the full flow
|
||||||
|
* - isPaymentProcessing: loading flag for UI
|
||||||
|
*/
|
||||||
|
export const useStripePayment = () => {
|
||||||
|
const { initPaymentSheet, presentPaymentSheet } = useStripe();
|
||||||
|
const [isPaymentProcessing, setIsPaymentProcessing] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the complete Stripe card payment flow.
|
||||||
|
* @param response – the PlaceOrderResponse from the backend (contains checkoutSessions)
|
||||||
|
* @returns `true` if payment was verified successfully, `false` otherwise
|
||||||
|
*/
|
||||||
|
const processCardPayment = useCallback(
|
||||||
|
async (response: PlaceOrderResponse): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
setIsPaymentProcessing(true);
|
||||||
|
|
||||||
|
// ── 1. Extract session details ─────────────────────────────────────
|
||||||
|
const session = response.checkoutSessions?.[0];
|
||||||
|
// console.log('session', session);
|
||||||
|
const clientSecret = session?.gatewayToken;
|
||||||
|
// console.log('clientSecret', clientSecret);
|
||||||
|
const gatewayOrderId = session?.gatewayOrderId;
|
||||||
|
// console.log('gatewayOrderId', gatewayOrderId);
|
||||||
|
|
||||||
|
if (!clientSecret) {
|
||||||
|
Alert.alert('Error', 'Payment session token not found.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Initialize Payment Sheet ────────────────────────────────────
|
||||||
|
const { error: initError } = await initPaymentSheet({
|
||||||
|
paymentIntentClientSecret: clientSecret,
|
||||||
|
merchantDisplayName: 'SG Delivery',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (initError) {
|
||||||
|
Alert.alert('Error', `Stripe init failed: ${initError.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Present Payment Sheet ───────────────────────────────────────
|
||||||
|
const { error: presentError } = await presentPaymentSheet();
|
||||||
|
|
||||||
|
if (presentError) {
|
||||||
|
if (presentError.code === 'Canceled') {
|
||||||
|
Alert.alert(
|
||||||
|
'Payment Cancelled',
|
||||||
|
'You cancelled the payment process.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(presentError.message);
|
||||||
|
Alert.alert('Payment Error', presentError.message);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Verify payment on backend ───────────────────────────────────
|
||||||
|
const verifyRes = await verifyPaymentApi({
|
||||||
|
gatewayOrderId: gatewayOrderId || '',
|
||||||
|
gatewayPaymentId: gatewayOrderId || '',
|
||||||
|
gatewaySignature: 'stripe_signature_verified',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (verifyRes.success) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
'Verification Failed',
|
||||||
|
verifyRes.message || 'Unable to confirm payment.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
Alert.alert(
|
||||||
|
'Payment Error',
|
||||||
|
error?.message || 'Something went wrong during payment.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsPaymentProcessing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[initPaymentSheet, presentPaymentSheet],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { processCardPayment, isPaymentProcessing };
|
||||||
|
};
|
||||||
@ -1 +1,3 @@
|
|||||||
export * from './checkoutPaymentScreen';
|
export * from './checkoutPaymentScreen';
|
||||||
|
export * from './thunk';
|
||||||
|
export * from './reducer';
|
||||||
|
|||||||
94
app/features/screens/checkoutPaymentScreen/reducer.ts
Normal file
94
app/features/screens/checkoutPaymentScreen/reducer.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { PaymentMethodsResponse, PlaceOrderResponse } from '@interfaces';
|
||||||
|
import { createReducer } from '@reduxjs/toolkit';
|
||||||
|
import {
|
||||||
|
getAllPaymentMethodsThunk,
|
||||||
|
getOrderByIdThunk,
|
||||||
|
getOrderHistoryThunk,
|
||||||
|
placeOrderThunk,
|
||||||
|
} from './thunk';
|
||||||
|
import { Order } from '@interfaces/order';
|
||||||
|
|
||||||
|
export interface PaymentMethodsState {
|
||||||
|
paymentMethods: PaymentMethodsResponse;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
placeOrderLoading: boolean;
|
||||||
|
placeOrderSuccess: boolean;
|
||||||
|
placeOrderError: string | null;
|
||||||
|
orderHistory: Order[] | null;
|
||||||
|
placeOrderResponse: PlaceOrderResponse | null;
|
||||||
|
orderDetails: Order | null;
|
||||||
|
orderDetailsLoading: boolean;
|
||||||
|
orderDetailsError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: PaymentMethodsState = {
|
||||||
|
paymentMethods: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
placeOrderLoading: false,
|
||||||
|
placeOrderSuccess: false,
|
||||||
|
placeOrderError: null,
|
||||||
|
orderHistory: null,
|
||||||
|
placeOrderResponse: null,
|
||||||
|
orderDetails: null,
|
||||||
|
orderDetailsLoading: false,
|
||||||
|
orderDetailsError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const paymentMethodsReducer = createReducer(initialState, builder => {
|
||||||
|
builder
|
||||||
|
.addCase(getAllPaymentMethodsThunk.pending, state => {
|
||||||
|
state.isLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(getAllPaymentMethodsThunk.fulfilled, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.paymentMethods = action.payload;
|
||||||
|
})
|
||||||
|
.addCase(getAllPaymentMethodsThunk.rejected, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
.addCase(placeOrderThunk.pending, state => {
|
||||||
|
state.placeOrderLoading = true;
|
||||||
|
state.placeOrderSuccess = false;
|
||||||
|
state.placeOrderError = null;
|
||||||
|
})
|
||||||
|
.addCase(placeOrderThunk.fulfilled, (state, action) => {
|
||||||
|
state.placeOrderLoading = false;
|
||||||
|
state.placeOrderSuccess = true;
|
||||||
|
state.placeOrderResponse = action.payload;
|
||||||
|
})
|
||||||
|
.addCase(placeOrderThunk.rejected, (state, action) => {
|
||||||
|
state.placeOrderLoading = false;
|
||||||
|
state.placeOrderSuccess = false;
|
||||||
|
state.placeOrderError = action.payload as string;
|
||||||
|
})
|
||||||
|
.addCase(getOrderHistoryThunk.pending, state => {
|
||||||
|
state.isLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(getOrderHistoryThunk.fulfilled, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.orderHistory = action.payload;
|
||||||
|
})
|
||||||
|
.addCase(getOrderHistoryThunk.rejected, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
.addCase(getOrderByIdThunk.pending, state => {
|
||||||
|
state.orderDetailsLoading = true;
|
||||||
|
state.orderDetailsError = null;
|
||||||
|
})
|
||||||
|
.addCase(getOrderByIdThunk.fulfilled, (state, action) => {
|
||||||
|
state.orderDetailsLoading = false;
|
||||||
|
state.orderDetails = action.payload;
|
||||||
|
})
|
||||||
|
.addCase(getOrderByIdThunk.rejected, (state, action) => {
|
||||||
|
state.orderDetailsLoading = false;
|
||||||
|
state.orderDetailsError = action.payload as string;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default paymentMethodsReducer;
|
||||||
74
app/features/screens/checkoutPaymentScreen/thunk.ts
Normal file
74
app/features/screens/checkoutPaymentScreen/thunk.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
OrderRequest,
|
||||||
|
PaymentMethodsResponse,
|
||||||
|
PlaceOrderResponse,
|
||||||
|
Order,
|
||||||
|
} from '@interfaces';
|
||||||
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import {
|
||||||
|
getAllPaymentMethodsApi,
|
||||||
|
getOrderByIdApi,
|
||||||
|
getOrderHistoryApi,
|
||||||
|
placeOrderApi,
|
||||||
|
} from '@api';
|
||||||
|
|
||||||
|
export const getAllPaymentMethodsThunk = createAsyncThunk<
|
||||||
|
PaymentMethodsResponse,
|
||||||
|
void,
|
||||||
|
{ rejectValue: string }
|
||||||
|
>('paymentMethods/getAll', async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await getAllPaymentMethodsApi();
|
||||||
|
// console.log(response.data);
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(
|
||||||
|
error.response?.data?.error || 'Failed to get payment methods',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const placeOrderThunk = createAsyncThunk<
|
||||||
|
PlaceOrderResponse,
|
||||||
|
OrderRequest,
|
||||||
|
{ rejectValue: string }
|
||||||
|
>('orders/placeOrder', async (payload: OrderRequest, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await placeOrderApi(payload);
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(
|
||||||
|
error.response?.data?.error || 'Failed to place order',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getOrderHistoryThunk = createAsyncThunk<
|
||||||
|
Order[],
|
||||||
|
void,
|
||||||
|
{ rejectValue: string }
|
||||||
|
>('orders/getOrderHistory', async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await getOrderHistoryApi();
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(
|
||||||
|
error.response?.data?.error || 'Failed to get order history',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getOrderByIdThunk = createAsyncThunk<
|
||||||
|
Order,
|
||||||
|
string,
|
||||||
|
{ rejectValue: string }
|
||||||
|
>('orders/getOrderById', async (orderId: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await getOrderByIdApi(orderId);
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(
|
||||||
|
error.response?.data?.error || 'Failed to get order',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -12,11 +12,14 @@ import { StackNavigationProp } from '@react-navigation/stack';
|
|||||||
import { getStyles } from './completeProfileScreen.styles';
|
import { getStyles } from './completeProfileScreen.styles';
|
||||||
import { CustomInput, PrimaryButton } from '@components';
|
import { CustomInput, PrimaryButton } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { useAppDispatch } from '../../../store';
|
import { useAppDispatch, useAppSelector } from '@store';
|
||||||
import { completeProfile } from '../../../store/commonreducers/auth';
|
import { saveProfileData } from './reducer';
|
||||||
import { AuthStackParamList } from '../../../navigation/authStack';
|
import { OnboardingStackParamList } from '@navigation/onboardingStack';
|
||||||
|
|
||||||
type NavProp = StackNavigationProp<AuthStackParamList, 'CompleteProfileScreen'>;
|
type NavProp = StackNavigationProp<
|
||||||
|
OnboardingStackParamList,
|
||||||
|
'CompleteProfileScreen'
|
||||||
|
>;
|
||||||
|
|
||||||
const LOCATION_LABELS = ['Home', 'Work', 'Other'];
|
const LOCATION_LABELS = ['Home', 'Work', 'Other'];
|
||||||
|
|
||||||
@ -28,10 +31,42 @@ export const CompleteProfileScreen: React.FC = () => {
|
|||||||
|
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [email, setEmail] = 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 [selectedLabel, setSelectedLabel] = useState('Home');
|
||||||
|
|
||||||
|
const locationData = useAppSelector(state => state.setLocation);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (locationData.city) setCity(locationData.city);
|
||||||
|
if (locationData.state) setState(locationData.state);
|
||||||
|
if (locationData.postalCode) setPostalCode(locationData.postalCode);
|
||||||
|
}, [locationData]);
|
||||||
|
|
||||||
const handleSave = () => {
|
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');
|
navigation.navigate('PreferencesScreen');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -40,28 +75,117 @@ export const CompleteProfileScreen: React.FC = () => {
|
|||||||
style={styles.container}
|
style={styles.container}
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
>
|
>
|
||||||
<ScrollView contentContainerStyle={styles.scrollContainer}>
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContainer}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
<Text style={styles.title}>Complete Profile</Text>
|
<Text style={styles.title}>Complete Profile</Text>
|
||||||
<Text style={styles.subtitle}>Tell us about yourself</Text>
|
<Text style={styles.subtitle}>Tell us about yourself</Text>
|
||||||
|
|
||||||
<View style={styles.form}>
|
<View style={styles.form}>
|
||||||
|
{/* Personal Information */}
|
||||||
|
|
||||||
<CustomInput
|
<CustomInput
|
||||||
label="Name"
|
label="Full Name"
|
||||||
placeholder="John Doe"
|
placeholder="John Doe"
|
||||||
value={name}
|
value={name}
|
||||||
onChangeText={setName}
|
onChangeText={setName}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CustomInput
|
<CustomInput
|
||||||
label="Email"
|
label="Email"
|
||||||
placeholder="john@example.com"
|
placeholder="john@example.com"
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
value={email}
|
value={email}
|
||||||
onChangeText={setEmail}
|
onChangeText={setEmail}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text style={styles.labelText}>Location Label</Text>
|
{/* Gender */}
|
||||||
|
|
||||||
|
<Text style={styles.labelText}>Gender</Text>
|
||||||
|
|
||||||
<View style={styles.labelRow}>
|
<View style={styles.labelRow}>
|
||||||
{LOCATION_LABELS.map((label) => (
|
{['MALE', 'FEMALE', 'OTHER'].map(item => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item}
|
||||||
|
style={[
|
||||||
|
styles.labelChip,
|
||||||
|
gender === item && styles.labelChipSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => setGender(item as 'MALE' | 'FEMALE' | 'OTHER')}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.labelChipText,
|
||||||
|
gender === item && styles.labelChipTextSelected,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.charAt(0) + item.slice(1).toLowerCase()}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label="Address Line 1"
|
||||||
|
placeholder="123 Main Street"
|
||||||
|
value={addressLine1}
|
||||||
|
onChangeText={setAddressLine1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label="House / Flat Number"
|
||||||
|
placeholder="Apt 4B"
|
||||||
|
value={houseNumber}
|
||||||
|
onChangeText={setHouseNumber}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label="Landmark"
|
||||||
|
placeholder="Near Central Park"
|
||||||
|
value={landmark}
|
||||||
|
onChangeText={setLandmark}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label="City"
|
||||||
|
placeholder="New York"
|
||||||
|
value={city}
|
||||||
|
onChangeText={setCity}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label="State"
|
||||||
|
placeholder="NY"
|
||||||
|
value={state}
|
||||||
|
onChangeText={setState}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label="Postal Code"
|
||||||
|
placeholder="10001"
|
||||||
|
keyboardType="number-pad"
|
||||||
|
value={postalCode}
|
||||||
|
onChangeText={setPostalCode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label="Phone Number"
|
||||||
|
placeholder="+1 9999999999"
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
value={addressPhone}
|
||||||
|
onChangeText={setAddressPhone}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Location Label */}
|
||||||
|
|
||||||
|
<Text style={styles.labelText}>Location Label</Text>
|
||||||
|
|
||||||
|
<View style={styles.labelRow}>
|
||||||
|
{LOCATION_LABELS.map(label => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={label}
|
key={label}
|
||||||
style={[
|
style={[
|
||||||
@ -69,7 +193,6 @@ export const CompleteProfileScreen: React.FC = () => {
|
|||||||
selectedLabel === label && styles.labelChipSelected,
|
selectedLabel === label && styles.labelChipSelected,
|
||||||
]}
|
]}
|
||||||
onPress={() => setSelectedLabel(label)}
|
onPress={() => setSelectedLabel(label)}
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export * from './completeProfileScreen';
|
export * from './completeProfileScreen';
|
||||||
|
export * from './reducer';
|
||||||
|
|||||||
71
app/features/screens/completeProfileScreen/reducer.ts
Normal file
71
app/features/screens/completeProfileScreen/reducer.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { createReducer, createAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
// ─── Actions ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const saveProfileData = createAction<{
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
gender: 'MALE' | 'FEMALE' | 'OTHER';
|
||||||
|
addressLine1: string;
|
||||||
|
houseNumber: string;
|
||||||
|
landmark: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
addressPhone: string;
|
||||||
|
addressLabel: string;
|
||||||
|
}>('completeProfile/saveProfileData');
|
||||||
|
|
||||||
|
export const clearProfileData = createAction('completeProfile/clearProfileData');
|
||||||
|
|
||||||
|
// ─── State ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CompleteProfileState {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
gender: 'MALE' | 'FEMALE' | 'OTHER';
|
||||||
|
addressLine1: string;
|
||||||
|
houseNumber: string;
|
||||||
|
landmark: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
addressPhone: string;
|
||||||
|
addressLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: CompleteProfileState = {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
gender: 'MALE',
|
||||||
|
addressLine1: '',
|
||||||
|
houseNumber: '',
|
||||||
|
landmark: '',
|
||||||
|
city: '',
|
||||||
|
state: '',
|
||||||
|
postalCode: '',
|
||||||
|
addressPhone: '',
|
||||||
|
addressLabel: 'Home',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Reducer ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const completeProfileReducer = createReducer(initialState, builder => {
|
||||||
|
builder
|
||||||
|
.addCase(saveProfileData, (state, action) => {
|
||||||
|
state.name = action.payload.name;
|
||||||
|
state.email = action.payload.email;
|
||||||
|
state.gender = action.payload.gender;
|
||||||
|
state.addressLine1 = action.payload.addressLine1;
|
||||||
|
state.houseNumber = action.payload.houseNumber;
|
||||||
|
state.landmark = action.payload.landmark;
|
||||||
|
state.city = action.payload.city;
|
||||||
|
state.state = action.payload.state;
|
||||||
|
state.postalCode = action.payload.postalCode;
|
||||||
|
state.addressPhone = action.payload.addressPhone;
|
||||||
|
state.addressLabel = action.payload.addressLabel;
|
||||||
|
})
|
||||||
|
.addCase(clearProfileData, () => initialState);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default completeProfileReducer;
|
||||||
0
app/features/screens/completeProfileScreen/thunk.ts
Normal file
0
app/features/screens/completeProfileScreen/thunk.ts
Normal file
@ -59,4 +59,190 @@ export const getStyles = (colors: any) => StyleSheet.create({
|
|||||||
fontWeight: typography.fontWeight.semibold,
|
fontWeight: typography.fontWeight.semibold,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
},
|
},
|
||||||
|
tabContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 4,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
tabButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
activeTabButton: {
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
tabText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
activeTabText: {
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
ticketCard: {
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
ticketHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
ticketNumber: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
ticketDate: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
ticketSubjectText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
ticketFooter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
paddingTop: 8,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
categoryBadge: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
categoryText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
},
|
||||||
|
createTicketBtn: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 12,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
createTicketBtnText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderRadius: 16,
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: '90%',
|
||||||
|
padding: 20,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: typography.fontSize.lg,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 6,
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
textArea: {
|
||||||
|
minHeight: 80,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
},
|
||||||
|
categorySelect: {
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
categorySelectText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
modalBtn: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
cancelBtn: {
|
||||||
|
backgroundColor: colors.border,
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
submitBtn: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
marginLeft: 10,
|
||||||
|
},
|
||||||
|
cancelBtnText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
},
|
||||||
|
submitBtnText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,24 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { View, Text, ScrollView, TouchableOpacity } from 'react-native';
|
import {
|
||||||
import { useNavigation } from '@react-navigation/native';
|
View,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
FlatList,
|
||||||
|
Modal,
|
||||||
|
TextInput,
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useNavigation, useIsFocused } from '@react-navigation/native';
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
import { getStyles } from './helpSupportScreen.styles';
|
import { getStyles } from './helpSupportScreen.styles';
|
||||||
import { Header } from '@components';
|
import { Header } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { AppStackParamList } from '../../../navigation/appStack';
|
import { AppStackParamList } from '../../../navigation/appStack';
|
||||||
|
import { useCustomerSupport } from '../../../hooks/useCustomerSupport';
|
||||||
|
import { TicketCategory, TicketPriority } from '@interfaces';
|
||||||
|
import { formatDate } from '../../../utils/helper';
|
||||||
|
|
||||||
type HelpSupportNavProp = StackNavigationProp<AppStackParamList, 'HelpSupportScreen'>;
|
type HelpSupportNavProp = StackNavigationProp<AppStackParamList, 'HelpSupportScreen'>;
|
||||||
|
|
||||||
@ -16,15 +29,87 @@ const FAQS = [
|
|||||||
{ q: 'What payment methods are accepted?', a: 'UPI, Credit/Debit Card, Wallet, and Cash on Delivery.' },
|
{ q: 'What payment methods are accepted?', a: 'UPI, Credit/Debit Card, Wallet, and Cash on Delivery.' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const TICKET_CATEGORIES: { value: TicketCategory; label: string }[] = [
|
||||||
|
{ value: 'ORDER', label: 'Order' },
|
||||||
|
{ value: 'PAYMENT', label: 'Payment' },
|
||||||
|
{ value: 'PAYOUT', label: 'Payout' },
|
||||||
|
{ value: 'TECHNICAL_ISSUE', label: 'Technical Issue' },
|
||||||
|
{ value: 'ACCOUNT', label: 'Account' },
|
||||||
|
{ value: 'OTHER', label: 'Other' },
|
||||||
|
];
|
||||||
|
|
||||||
export const HelpSupportScreen: React.FC = () => {
|
export const HelpSupportScreen: React.FC = () => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
const styles = getStyles(colors);
|
const styles = getStyles(colors);
|
||||||
const navigation = useNavigation<HelpSupportNavProp>();
|
const navigation = useNavigation<HelpSupportNavProp>();
|
||||||
|
const isFocused = useIsFocused();
|
||||||
|
|
||||||
return (
|
// Tab State
|
||||||
<View style={styles.container}>
|
const [activeTab, setActiveTab] = useState<'faq' | 'tickets'>('faq');
|
||||||
<Header title="Help & Support" onBack={() => navigation.goBack()} />
|
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
// Custom Support Hook
|
||||||
|
const { tickets, loading, fetchTickets, createTicket } = useCustomerSupport();
|
||||||
|
|
||||||
|
// Create Ticket Modal State
|
||||||
|
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||||
|
const [categorySelectVisible, setCategorySelectVisible] = useState(false);
|
||||||
|
|
||||||
|
const [category, setCategory] = useState<TicketCategory>('ORDER');
|
||||||
|
const [priority] = useState<TicketPriority>('MEDIUM');
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFocused && activeTab === 'tickets') {
|
||||||
|
fetchTickets();
|
||||||
|
}
|
||||||
|
}, [isFocused, activeTab, fetchTickets]);
|
||||||
|
|
||||||
|
const handleCreateTicketSubmit = async () => {
|
||||||
|
if (!subject.trim()) {
|
||||||
|
Alert.alert('Error', 'Please enter a subject.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!description.trim()) {
|
||||||
|
Alert.alert('Error', 'Please enter ticket details.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newTicket = await createTicket({
|
||||||
|
category,
|
||||||
|
priority,
|
||||||
|
subject: subject.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
});
|
||||||
|
setCreateModalVisible(false);
|
||||||
|
setSubject('');
|
||||||
|
setDescription('');
|
||||||
|
Alert.alert('Success', 'Support Ticket created successfully!');
|
||||||
|
// Navigate to chat
|
||||||
|
navigation.navigate('SupportChatScreen', { ticketId: newTicket.id });
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Error', 'Failed to create support ticket. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'OPEN':
|
||||||
|
return { bg: '#E3F2FD', text: '#1E88E5' }; // Blue
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return { bg: '#FFF3E0', text: '#FB8C00' }; // Amber
|
||||||
|
case 'RESOLVED':
|
||||||
|
return { bg: '#E8F5E9', text: '#43A047' }; // Green
|
||||||
|
case 'CLOSED':
|
||||||
|
return { bg: '#ECEFF1', text: '#546E7A' }; // Grey
|
||||||
|
default:
|
||||||
|
return { bg: '#F5F5F5', text: '#9E9E9E' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFaqTab = () => (
|
||||||
|
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||||
<Text style={styles.sectionTitle}>Frequently Asked Questions</Text>
|
<Text style={styles.sectionTitle}>Frequently Asked Questions</Text>
|
||||||
{FAQS.map((faq, index) => (
|
{FAQS.map((faq, index) => (
|
||||||
<View key={index} style={styles.faqItem}>
|
<View key={index} style={styles.faqItem}>
|
||||||
@ -49,6 +134,192 @@ export const HelpSupportScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTicketsTab = () => (
|
||||||
|
<View style={[styles.container, { padding: 16 }]}>
|
||||||
|
{loading && tickets.length === 0 ? (
|
||||||
|
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
<Text style={{ marginTop: 8, color: colors.textSecondary }}>Fetching tickets...</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={tickets}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={{ paddingVertical: 40, alignItems: 'center' }}>
|
||||||
|
<Text style={{ fontSize: 16, color: colors.textSecondary, marginBottom: 12 }}>
|
||||||
|
No support tickets found
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 12, color: colors.textSecondary, textAlign: 'center' }}>
|
||||||
|
Need help with your account or order? Create a ticket below!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const statusStyle = getStatusColor(item.status);
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.ticketCard}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={() => navigation.navigate('SupportChatScreen', { ticketId: item.id })}
|
||||||
|
>
|
||||||
|
<View style={styles.ticketHeader}>
|
||||||
|
<Text style={styles.ticketNumber}>Ticket #{item.ticketNumber}</Text>
|
||||||
|
<Text style={styles.ticketDate}>{formatDate(item.createdAt)}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.ticketSubjectText} numberOfLines={1}>
|
||||||
|
{item.subject}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.ticketFooter}>
|
||||||
|
<View style={styles.categoryBadge}>
|
||||||
|
<Text style={styles.categoryText}>{item.category.replace('_', ' ')}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.statusBadge, { backgroundColor: statusStyle.bg }]}>
|
||||||
|
<Text style={[styles.statusText, { color: statusStyle.text }]}>
|
||||||
|
{item.status.replace('_', ' ')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.createTicketBtn}
|
||||||
|
onPress={() => setCreateModalVisible(true)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.createTicketBtnText}>+ Create Support Ticket</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Header title="Help & Support" onBack={() => navigation.goBack()} />
|
||||||
|
|
||||||
|
<View style={{ paddingHorizontal: 16, paddingTop: 16 }}>
|
||||||
|
<View style={styles.tabContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.tabButton, activeTab === 'faq' && styles.activeTabButton]}
|
||||||
|
onPress={() => setActiveTab('faq')}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabText, activeTab === 'faq' && styles.activeTabText]}>FAQs</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.tabButton, activeTab === 'tickets' && styles.activeTabButton]}
|
||||||
|
onPress={() => setActiveTab('tickets')}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabText, activeTab === 'tickets' && styles.activeTabText]}>
|
||||||
|
My Support Tickets
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{activeTab === 'faq' ? renderFaqTab() : renderTicketsTab()}
|
||||||
|
|
||||||
|
{/* Create Ticket Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={createModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setCreateModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<Text style={styles.modalTitle}>Create Support Ticket</Text>
|
||||||
|
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
|
<Text style={styles.label}>Category</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.categorySelect}
|
||||||
|
onPress={() => setCategorySelectVisible(true)}
|
||||||
|
>
|
||||||
|
<Text style={styles.categorySelectText}>
|
||||||
|
{TICKET_CATEGORIES.find((c) => c.value === category)?.label || 'Select category'}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: colors.textSecondary }}>▼</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Subject</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={subject}
|
||||||
|
onChangeText={setSubject}
|
||||||
|
placeholder="What is the issue about?"
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Description</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, styles.textArea]}
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder="Please describe your issue in detail..."
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalBtn, styles.cancelBtn]}
|
||||||
|
onPress={() => setCreateModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelBtnText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalBtn, styles.submitBtn]}
|
||||||
|
onPress={handleCreateTicketSubmit}
|
||||||
|
>
|
||||||
|
<Text style={styles.submitBtnText}>Submit</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Category Dropdown Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={categorySelectVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setCategorySelectVisible(false)}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', padding: 24 }}>
|
||||||
|
<View style={{ backgroundColor: colors.cardBg, borderRadius: 12, padding: 16 }}>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: 'bold', color: colors.text, marginBottom: 12, textAlign: 'center' }}>
|
||||||
|
Select Category
|
||||||
|
</Text>
|
||||||
|
{TICKET_CATEGORIES.map((item) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item.value}
|
||||||
|
style={{ paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.border }}
|
||||||
|
onPress={() => {
|
||||||
|
setCategory(item.value);
|
||||||
|
setCategorySelectVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 16, color: colors.text }}>{item.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ marginTop: 16, paddingVertical: 12, alignItems: 'center' }}
|
||||||
|
onPress={() => setCategorySelectVisible(false)}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.primary, fontWeight: 'bold' }}>Close</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
export default HelpSupportScreen;
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export * from './helpSupportScreen';
|
export * from './helpSupportScreen';
|
||||||
|
|
||||||
|
|||||||
@ -250,9 +250,12 @@ export const getStyles = (colors: any) =>
|
|||||||
color: colors.primary ?? '#05824C',
|
color: colors.primary ?? '#05824C',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- Provider cards ----------
|
// ---------- Product Grid ----------
|
||||||
providerList: {
|
providerList: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'space-between',
|
||||||
},
|
},
|
||||||
providerCardWrap: {
|
providerCardWrap: {
|
||||||
backgroundColor: colors.surface ?? '#FFFFFF',
|
backgroundColor: colors.surface ?? '#FFFFFF',
|
||||||
|
|||||||
@ -13,16 +13,25 @@ import {
|
|||||||
import {
|
import {
|
||||||
useNavigation,
|
useNavigation,
|
||||||
CompositeNavigationProp,
|
CompositeNavigationProp,
|
||||||
|
useFocusEffect,
|
||||||
} from '@react-navigation/native';
|
} from '@react-navigation/native';
|
||||||
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
import { getStyles } from './homeScreen.styles';
|
import { getStyles } from './homeScreen.styles';
|
||||||
import { ProviderCard, SearchBar } from '@components';
|
import { ProductCard, SearchBar } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { getProvidersApi } from '../../../api/deliveryApi';
|
import { Product } from '../../../interfaces';
|
||||||
import { Provider } from '../../../interfaces';
|
|
||||||
import { AppStackParamList } from '../../../navigation/appStack';
|
import { AppStackParamList } from '../../../navigation/appStack';
|
||||||
import { MainTabParamList } from '../../../navigation/mainTabNavigator';
|
import { MainTabParamList } from '../../../navigation/mainTabNavigator';
|
||||||
|
import {
|
||||||
|
fetchCustomerDetails,
|
||||||
|
getAllOffersThunk,
|
||||||
|
useAppDispatch,
|
||||||
|
useAppSelector,
|
||||||
|
} from '@store';
|
||||||
|
import { getAllCategoriesThunk, getAllProductsThunk } from './thunk';
|
||||||
|
import { getCategoryEmoji } from '@utils';
|
||||||
|
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
|
||||||
type NavProp = CompositeNavigationProp<
|
type NavProp = CompositeNavigationProp<
|
||||||
BottomTabNavigationProp<MainTabParamList, 'HomeScreen'>,
|
BottomTabNavigationProp<MainTabParamList, 'HomeScreen'>,
|
||||||
@ -32,15 +41,9 @@ type NavProp = CompositeNavigationProp<
|
|||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const BANNER_STEP = width - 32 + 12; // card width + margin
|
const BANNER_STEP = width - 32 + 12; // card width + margin
|
||||||
|
|
||||||
const CATEGORIES = [
|
const ALL_CATEGORY = { id: 'all', name: 'All', slug: 'all', imageUrl: null, isActive: true, parentId: null };
|
||||||
{ key: 'all', icon: '🏠', label: 'All' },
|
|
||||||
{ key: 'Food', icon: '🍔', label: 'Food' },
|
|
||||||
{ key: 'Groceries', icon: '🛒', label: 'Grocery' },
|
|
||||||
{ key: 'Pharmacy', icon: '💊', label: 'Pharmacy' },
|
|
||||||
{ key: 'Meat', icon: '🥩', label: 'Meat' },
|
|
||||||
{ key: 'Flowers', icon: '💐', label: 'Flowers' },
|
|
||||||
{ key: 'More', icon: '📦', label: 'More' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const PROMOS = [
|
const PROMOS = [
|
||||||
{
|
{
|
||||||
@ -74,18 +77,32 @@ export const HomeScreen: React.FC = () => {
|
|||||||
const styles = getStyles(colors);
|
const styles = getStyles(colors);
|
||||||
const navigation = useNavigation<NavProp>();
|
const navigation = useNavigation<NavProp>();
|
||||||
|
|
||||||
const [providers, setProviders] = useState<Provider[]>([]);
|
const dispatch = useAppDispatch();
|
||||||
|
const { products, isLoading, categories } = useAppSelector(
|
||||||
|
state => state.home,
|
||||||
|
);
|
||||||
|
const { customerDetails } = useAppSelector(state => state.customerProfile);
|
||||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||||
const [activeBanner, setActiveBanner] = useState(0);
|
const [activeBanner, setActiveBanner] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getProvidersApi().then(setProviders);
|
dispatch(getAllProductsThunk());
|
||||||
}, []);
|
dispatch(fetchCustomerDetails());
|
||||||
|
dispatch(getAllCategoriesThunk());
|
||||||
|
}, [dispatch]);
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
dispatch(getAllOffersThunk());
|
||||||
|
}, [dispatch]),
|
||||||
|
);
|
||||||
|
|
||||||
const filteredProviders =
|
// Build category list: prepend synthetic 'All', then append API categories
|
||||||
|
const dynamicCategories = [ALL_CATEGORY, ...categories];
|
||||||
|
|
||||||
|
const filteredProducts =
|
||||||
selectedCategory === 'all'
|
selectedCategory === 'all'
|
||||||
? providers
|
? products
|
||||||
: providers.filter(p => p.tag === selectedCategory);
|
: products.filter(p => p.category?.name === selectedCategory);
|
||||||
|
|
||||||
const handleSearchFocus = useCallback(() => {
|
const handleSearchFocus = useCallback(() => {
|
||||||
navigation.navigate('SearchScreen');
|
navigation.navigate('SearchScreen');
|
||||||
@ -105,21 +122,33 @@ export const HomeScreen: React.FC = () => {
|
|||||||
<View style={styles.topBar}>
|
<View style={styles.topBar}>
|
||||||
<TouchableOpacity style={styles.addressBar} activeOpacity={0.7}>
|
<TouchableOpacity style={styles.addressBar} activeOpacity={0.7}>
|
||||||
<View style={styles.pinBadge}>
|
<View style={styles.pinBadge}>
|
||||||
<Text style={styles.pinEmoji}>📍</Text>
|
<MaterialCommunityIcons
|
||||||
|
name="map-marker"
|
||||||
|
size={22}
|
||||||
|
color="#05824C"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.addressTextWrap}>
|
<View style={styles.addressTextWrap}>
|
||||||
<Text style={styles.addressLabel}>Deliver to</Text>
|
<Text style={styles.addressLabel}>Deliver to</Text>
|
||||||
<View style={styles.addressRow}>
|
<View style={styles.addressRow}>
|
||||||
<Text style={styles.addressText} numberOfLines={1}>
|
<Text style={styles.addressText} numberOfLines={1}>
|
||||||
Koramangala 4th Block
|
{customerDetails?.addresses[0]?.mapAddress}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.dropdownArrow}>▼</Text>
|
<Text style={styles.dropdownArrow}>▼</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity style={styles.avatarButton} activeOpacity={0.7}>
|
<TouchableOpacity
|
||||||
<Text style={styles.avatarEmoji}>🔔</Text>
|
style={styles.avatarButton}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={() => navigation.navigate('CartScreen')}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="cart"
|
||||||
|
size={26}
|
||||||
|
color="#666"
|
||||||
|
/>
|
||||||
<View style={styles.notifDot} />
|
<View style={styles.notifDot} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@ -175,21 +204,25 @@ export const HomeScreen: React.FC = () => {
|
|||||||
<View style={styles.categorySection}>
|
<View style={styles.categorySection}>
|
||||||
<FlatList
|
<FlatList
|
||||||
horizontal
|
horizontal
|
||||||
data={CATEGORIES}
|
data={dynamicCategories}
|
||||||
keyExtractor={item => item.key}
|
keyExtractor={item => item.id}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.chipRow}
|
contentContainerStyle={styles.chipRow}
|
||||||
renderItem={({ item }) => {
|
renderItem={({ item }) => {
|
||||||
const isSelected = selectedCategory === item.key;
|
const isSelected = selectedCategory === item.id;
|
||||||
|
const emoji =
|
||||||
|
item.id === 'all' ? '🏠' : getCategoryEmoji(item.name);
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.categoryItem}
|
style={styles.categoryItem}
|
||||||
activeOpacity={0.75}
|
activeOpacity={0.75}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setSelectedCategory(item.key);
|
if (item.id === 'all') {
|
||||||
if (item.key !== 'all') {
|
setSelectedCategory('all');
|
||||||
navigation.navigate('ProviderListScreen', {
|
} else {
|
||||||
category: item.key,
|
navigation.navigate('SearchScreen', {
|
||||||
|
categoryId: item.id,
|
||||||
|
categoryName: item.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -200,7 +233,7 @@ export const HomeScreen: React.FC = () => {
|
|||||||
isSelected && styles.categoryCircleSelected,
|
isSelected && styles.categoryCircleSelected,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.categoryIcon}>{item.icon}</Text>
|
<Text style={styles.categoryIcon}>{emoji}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@ -209,7 +242,7 @@ export const HomeScreen: React.FC = () => {
|
|||||||
]}
|
]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.name}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
@ -224,8 +257,8 @@ export const HomeScreen: React.FC = () => {
|
|||||||
{selectedCategory === 'all' ? 'Popular near you' : selectedCategory}
|
{selectedCategory === 'all' ? 'Popular near you' : selectedCategory}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.sectionSubtitle}>
|
<Text style={styles.sectionSubtitle}>
|
||||||
{filteredProviders.length} place
|
{filteredProducts.length} product
|
||||||
{filteredProviders.length === 1 ? '' : 's'} delivering to you
|
{filteredProducts.length === 1 ? '' : 's'} available
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity activeOpacity={0.7}>
|
<TouchableOpacity activeOpacity={0.7}>
|
||||||
@ -233,31 +266,31 @@ export const HomeScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Provider list */}
|
{/* Product list */}
|
||||||
<View style={styles.providerList}>
|
<View style={styles.providerList}>
|
||||||
{filteredProviders.length === 0 && (
|
{filteredProducts.length === 0 && (
|
||||||
<View style={styles.emptyWrap}>
|
<View style={[styles.emptyWrap, { width: '100%' }]}>
|
||||||
<Text style={styles.emptyEmoji}>🍽️</Text>
|
<Text style={styles.emptyEmoji}>🛍️</Text>
|
||||||
<Text style={styles.emptyText}>
|
<Text style={styles.emptyText}>No products found</Text>
|
||||||
No providers in this category yet
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredProviders.map(provider => (
|
{filteredProducts.map(product => (
|
||||||
<ProviderCard
|
<ProductCard
|
||||||
key={provider.id}
|
key={product.id}
|
||||||
imageUrl={provider.imageUrl}
|
id={product.id}
|
||||||
name={provider.name}
|
imageUrl={product.imageUrl}
|
||||||
rating={provider.rating}
|
name={product.name}
|
||||||
deliveryTime={provider.deliveryTime}
|
price={product.price}
|
||||||
tag={provider.tag}
|
compareAtPrice={product.compareAtPrice}
|
||||||
discountText={provider.discountText}
|
brand={product.brand || product.merchant?.name}
|
||||||
onPress={() =>
|
currency={product.currency === 'INR' ? '₹' : product.currency}
|
||||||
|
onPress={() => {
|
||||||
navigation.navigate('ProviderDetailsScreen', {
|
navigation.navigate('ProviderDetailsScreen', {
|
||||||
providerId: provider.id,
|
providerId: product.id,
|
||||||
})
|
providerName: product.name,
|
||||||
}
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -1 +1,3 @@
|
|||||||
export * from './homeScreen';
|
export * from './homeScreen';
|
||||||
|
export { default as homeReducer } from './reducer';
|
||||||
|
export * from './thunk';
|
||||||
|
|||||||
46
app/features/screens/homeScreen/reducer.ts
Normal file
46
app/features/screens/homeScreen/reducer.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { createReducer } from '@reduxjs/toolkit';
|
||||||
|
import { Categories, Product, Products } from '@interfaces';
|
||||||
|
import { getAllCategoriesThunk, getAllProductsThunk } from './thunk';
|
||||||
|
|
||||||
|
export interface HomeState {
|
||||||
|
products: Products[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
categories: Categories[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: HomeState = {
|
||||||
|
products: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
categories: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
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';
|
||||||
|
})
|
||||||
|
.addCase(getAllCategoriesThunk.fulfilled, (state, action) => {
|
||||||
|
state.categories = action.payload || [];
|
||||||
|
state.isLoading = false;
|
||||||
|
})
|
||||||
|
.addCase(getAllCategoriesThunk.rejected, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = (action.payload as string) || 'Failed to fetch categories';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default homeReducer;
|
||||||
29
app/features/screens/homeScreen/thunk.ts
Normal file
29
app/features/screens/homeScreen/thunk.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import { getCategoriesApi, 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getAllCategoriesThunk = createAsyncThunk(
|
||||||
|
'categories/getAll',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await getCategoriesApi();
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -19,3 +19,7 @@ export * from './myOrdersScreen';
|
|||||||
export * from './offersScreen';
|
export * from './offersScreen';
|
||||||
export * from './helpSupportScreen';
|
export * from './helpSupportScreen';
|
||||||
export * from './accountScreen';
|
export * from './accountScreen';
|
||||||
|
export * from './writeReviewScreen';
|
||||||
|
export * from './orderDetailsScreen';
|
||||||
|
export * from './walletScreen';
|
||||||
|
export * from './supportChatScreen'
|
||||||
|
|||||||
1
app/features/screens/liveTrackingScreen/hooks/index.ts
Normal file
1
app/features/screens/liveTrackingScreen/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './useOrderTracking';
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { socketService, DriverLocationUpdate } from '@services';
|
||||||
|
|
||||||
|
export function useOrderTracking(orderId: string, userJwtToken: string | null) {
|
||||||
|
const [driverLocation, setDriverLocation] =
|
||||||
|
useState<DriverLocationUpdate | null>(null);
|
||||||
|
const [orderStatus, setOrderStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orderId || !userJwtToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[useOrderTracking] Connecting socket for orderId: ${orderId}`);
|
||||||
|
socketService.connect(userJwtToken);
|
||||||
|
socketService.joinOrderTracking(orderId);
|
||||||
|
|
||||||
|
const handleDriverLocationUpdate = (data: DriverLocationUpdate) => {
|
||||||
|
console.log('[useOrderTracking] handleDriverLocationUpdate:', data);
|
||||||
|
if (data.orderId === orderId) {
|
||||||
|
setDriverLocation(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOrderStatusUpdate = (data: any) => {
|
||||||
|
if (data.orderId === orderId) {
|
||||||
|
setOrderStatus(data.status);
|
||||||
|
if (data.status === 'DELIVERED') {
|
||||||
|
console.log(
|
||||||
|
'[useOrderTracking] Order delivered. Disconnecting socket.',
|
||||||
|
);
|
||||||
|
socketService.disconnect(orderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socketService.onDriverLocationUpdate(handleDriverLocationUpdate);
|
||||||
|
socketService.onOrderStatusUpdate(handleOrderStatusUpdate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log(
|
||||||
|
`[useOrderTracking] Cleaning up tracking socket for orderId: ${orderId}`,
|
||||||
|
);
|
||||||
|
socketService.disconnect(orderId);
|
||||||
|
};
|
||||||
|
}, [orderId, userJwtToken]);
|
||||||
|
|
||||||
|
return { driverLocation, orderStatus };
|
||||||
|
}
|
||||||
@ -1 +1,2 @@
|
|||||||
export * from './liveTrackingScreen';
|
export * from './liveTrackingScreen';
|
||||||
|
export * from './hooks';
|
||||||
|
|||||||
@ -1,13 +1,19 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { View, Text } from 'react-native';
|
import { View, Text, ActivityIndicator, Linking } from 'react-native';
|
||||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
import { getStyles } from './liveTrackingScreen.styles';
|
import { getStyles } from './liveTrackingScreen.styles';
|
||||||
import { Header, PrimaryButton } from '@components';
|
import { Header, PrimaryButton, TrackingMap } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { AppStackParamList } from '../../../navigation/appStack';
|
import { AppStackParamList } from '../../../navigation/appStack';
|
||||||
|
import { RootState, useAppDispatch, useAppSelector } from '@store';
|
||||||
|
import { getOrderByIdThunk } from '../checkoutPaymentScreen';
|
||||||
|
import { useOrderTracking } from './hooks';
|
||||||
|
|
||||||
type LiveTrackingNavProp = StackNavigationProp<AppStackParamList, 'LiveTrackingScreen'>;
|
type LiveTrackingNavProp = StackNavigationProp<
|
||||||
|
AppStackParamList,
|
||||||
|
'LiveTrackingScreen'
|
||||||
|
>;
|
||||||
type LiveTrackingRouteProp = RouteProp<AppStackParamList, 'LiveTrackingScreen'>;
|
type LiveTrackingRouteProp = RouteProp<AppStackParamList, 'LiveTrackingScreen'>;
|
||||||
|
|
||||||
export const LiveTrackingScreen: React.FC = () => {
|
export const LiveTrackingScreen: React.FC = () => {
|
||||||
@ -17,32 +23,86 @@ export const LiveTrackingScreen: React.FC = () => {
|
|||||||
const route = useRoute<LiveTrackingRouteProp>();
|
const route = useRoute<LiveTrackingRouteProp>();
|
||||||
const orderId = route.params?.orderId || 'ORD-123456';
|
const orderId = route.params?.orderId || 'ORD-123456';
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { accessToken } = useAppSelector((state: RootState) => state.auth);
|
||||||
|
const { orderDetails, orderDetailsLoading } = useAppSelector(
|
||||||
|
(state: RootState) => state.paymentMethods,
|
||||||
|
);
|
||||||
|
// console.log('[LiveTrackingScreen] orderDetails:', orderDetails);
|
||||||
|
|
||||||
|
const { driverLocation, orderStatus } = useOrderTracking(
|
||||||
|
orderId,
|
||||||
|
accessToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch order details if not matching or missing
|
||||||
|
useEffect(() => {
|
||||||
|
if (orderId && (!orderDetails || orderDetails.id !== orderId)) {
|
||||||
|
dispatch(getOrderByIdThunk(orderId));
|
||||||
|
}
|
||||||
|
}, [dispatch, orderId, orderDetails]);
|
||||||
|
|
||||||
|
// Navigate to delivered screen once status transitions to DELIVERED
|
||||||
|
useEffect(() => {
|
||||||
|
if (orderStatus === 'DELIVERED') {
|
||||||
|
console.log(
|
||||||
|
`[LiveTrackingScreen] Order ${orderId} delivered! Navigating to OrderDeliveredScreen.`,
|
||||||
|
);
|
||||||
|
navigation.navigate('OrderDeliveredScreen', { orderId });
|
||||||
|
}
|
||||||
|
}, [orderStatus, navigation, orderId]);
|
||||||
|
|
||||||
|
const handleCall = () => {
|
||||||
|
Linking.openURL(`tel:${orderDetails?.delivery?.deliveryPartner?.user?.phone}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (orderDetailsLoading || !orderDetails) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Header title="Live Tracking" onBack={() => navigation.goBack()} />
|
<Header title="Live Tracking" onBack={() => navigation.goBack()} />
|
||||||
<View style={styles.mapPlaceholder}>
|
<View
|
||||||
<Text style={styles.mapIcon}>🗺️</Text>
|
style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}
|
||||||
<Text style={styles.mapText}>Live Map</Text>
|
>
|
||||||
<Text style={styles.mapSubtext}>
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
Delivery partner location tracking for {orderId}
|
<Text style={{ marginTop: 8, color: colors.textSecondary }}>
|
||||||
|
Loading tracking details...
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.blinkingBadge}>
|
|
||||||
<Text style={styles.blinkingText}>● Live</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get drop address coordinates from orderDetails
|
||||||
|
const dropCoords = {
|
||||||
|
latitude: orderDetails.dropAddress?.latitude || 12.9352,
|
||||||
|
longitude: orderDetails.dropAddress?.longitude || 77.6245,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Header title="Live Tracking" onBack={() => navigation.goBack()} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<TrackingMap
|
||||||
|
customerDropoff={dropCoords}
|
||||||
|
driverLocation={driverLocation}
|
||||||
|
showRoute={true}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
<View style={styles.agentSheet}>
|
<View style={styles.agentSheet}>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
title="Simulate Order Delivered"
|
title="Simulate Order Delivered"
|
||||||
onPress={() => navigation.navigate('OrderDeliveredScreen', { orderId })}
|
onPress={() =>
|
||||||
|
navigation.navigate('OrderDeliveredScreen', { orderId })
|
||||||
|
}
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.agentName}>Rahul Sharma</Text>
|
<Text style={styles.agentName}>{orderDetails?.delivery?.deliveryPartner?.user?.name}</Text>
|
||||||
<Text style={styles.agentRating}>⭐ 4.8</Text>
|
<Text style={styles.agentRating}>⭐ 4.8</Text>
|
||||||
<Text style={styles.agentVehicle}>KA-01-AB-1234 • Honda Activa</Text>
|
<Text style={styles.agentVehicle}>{orderDetails?.delivery?.deliveryPartner?.user?.phone}</Text>
|
||||||
<View style={styles.agentActions}>
|
<View style={styles.agentActions}>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
title="Call"
|
title="Call"
|
||||||
onPress={() => {}}
|
onPress={handleCall}
|
||||||
style={{ flex: 1, marginRight: 8 }}
|
style={{ flex: 1, marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
@ -55,3 +115,4 @@ export const LiveTrackingScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
export default LiveTrackingScreen;
|
||||||
|
|||||||
@ -43,4 +43,9 @@ export const getStyles = (colors: any) => StyleSheet.create({
|
|||||||
fontSize: typography.fontSize.md,
|
fontSize: typography.fontSize.md,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
},
|
},
|
||||||
|
loaderContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { View, Text, FlatList, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||||
import {
|
import {
|
||||||
View,
|
useNavigation,
|
||||||
Text,
|
CompositeNavigationProp,
|
||||||
FlatList,
|
useFocusEffect,
|
||||||
TouchableOpacity,
|
} from '@react-navigation/native';
|
||||||
} from 'react-native';
|
|
||||||
import { useNavigation, CompositeNavigationProp } from '@react-navigation/native';
|
|
||||||
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
import { getStyles } from './myOrdersScreen.styles';
|
import { getStyles } from './myOrdersScreen.styles';
|
||||||
@ -13,6 +12,9 @@ import { Header, OrderHistoryCard } from '@components';
|
|||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { AppStackParamList } from '../../../navigation/appStack';
|
import { AppStackParamList } from '../../../navigation/appStack';
|
||||||
import { MainTabParamList } from '../../../navigation/mainTabNavigator';
|
import { MainTabParamList } from '../../../navigation/mainTabNavigator';
|
||||||
|
import { RootState, useAppDispatch, useAppSelector } from '@store';
|
||||||
|
import { getOrderHistoryThunk } from '../checkoutPaymentScreen';
|
||||||
|
import { formatDate } from '@utils/helper';
|
||||||
|
|
||||||
type MyOrdersNavProp = CompositeNavigationProp<
|
type MyOrdersNavProp = CompositeNavigationProp<
|
||||||
BottomTabNavigationProp<MainTabParamList, 'MyOrdersScreen'>,
|
BottomTabNavigationProp<MainTabParamList, 'MyOrdersScreen'>,
|
||||||
@ -20,35 +22,74 @@ type MyOrdersNavProp = CompositeNavigationProp<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
const TABS = ['All', 'Ongoing', 'Completed'];
|
const TABS = ['All', 'Ongoing', 'Completed'];
|
||||||
const mockOrders = [
|
// const mockOrders = [
|
||||||
{ id: 'ORD-591283', providerName: 'Pizza Planet', providerImage: '', orderDate: '29 Jun 2026', status: 'Delivered', total: 599 },
|
// {
|
||||||
{ id: 'ORD-771239', providerName: 'Fresh Mart', providerImage: '', orderDate: '28 Jun 2026', status: 'Ongoing', total: 349 },
|
// id: 'ORD-591283',
|
||||||
{ id: 'ORD-108239', providerName: 'Burger Barn', providerImage: '', orderDate: '27 Jun 2026', status: 'Delivered', total: 449 },
|
// providerName: 'Pizza Planet',
|
||||||
];
|
// providerImage: '',
|
||||||
|
// orderDate: '29 Jun 2026',
|
||||||
|
// status: 'Delivered',
|
||||||
|
// total: 599,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// id: 'ORD-771239',
|
||||||
|
// providerName: 'Fresh Mart',
|
||||||
|
// providerImage: '',
|
||||||
|
// orderDate: '28 Jun 2026',
|
||||||
|
// status: 'Ongoing',
|
||||||
|
// total: 349,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// id: 'ORD-108239',
|
||||||
|
// providerName: 'Burger Barn',
|
||||||
|
// providerImage: '',
|
||||||
|
// orderDate: '27 Jun 2026',
|
||||||
|
// status: 'Delivered',
|
||||||
|
// total: 449,
|
||||||
|
// },
|
||||||
|
// ];
|
||||||
|
|
||||||
export const MyOrdersScreen: React.FC = () => {
|
export const MyOrdersScreen: React.FC = () => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
const styles = getStyles(colors);
|
const styles = getStyles(colors);
|
||||||
const navigation = useNavigation<MyOrdersNavProp>();
|
const navigation = useNavigation<MyOrdersNavProp>();
|
||||||
const [activeTab, setActiveTab] = useState('All');
|
const [activeTab, setActiveTab] = useState('All');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { orderHistory, isLoading } = useAppSelector(
|
||||||
|
(state: RootState) => state.paymentMethods,
|
||||||
|
);
|
||||||
|
// console.log(orderHistory);
|
||||||
|
|
||||||
const filteredOrders = activeTab === 'All'
|
useFocusEffect(useCallback(() => {
|
||||||
? mockOrders
|
dispatch(getOrderHistoryThunk());
|
||||||
|
}, []));
|
||||||
|
|
||||||
|
const ONGOING_STATUSES = [
|
||||||
|
'PENDING',
|
||||||
|
'CONFIRMED',
|
||||||
|
'PREPARING',
|
||||||
|
'READY_FOR_PICKUP',
|
||||||
|
'OUT_FOR_DELIVERY',
|
||||||
|
];
|
||||||
|
const COMPLETED_STATUSES = ['DELIVERED', 'CANCELLED'];
|
||||||
|
|
||||||
|
const orders = Array.isArray(orderHistory) ? orderHistory : [];
|
||||||
|
|
||||||
|
const filteredOrders =
|
||||||
|
activeTab === 'All'
|
||||||
|
? orders
|
||||||
: activeTab === 'Ongoing'
|
: activeTab === 'Ongoing'
|
||||||
? mockOrders.filter((o) => o.status === 'Ongoing')
|
? orders.filter(o => ONGOING_STATUSES.includes(o.status))
|
||||||
: mockOrders.filter((o) => o.status === 'Delivered');
|
: orders.filter(o => COMPLETED_STATUSES.includes(o.status));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Header title="My Orders" />
|
<Header title="My Orders" />
|
||||||
<View style={styles.tabRow}>
|
<View style={styles.tabRow}>
|
||||||
{TABS.map((tab) => (
|
{TABS.map(tab => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={tab}
|
key={tab}
|
||||||
style={[
|
style={[styles.tab, activeTab === tab && styles.tabActive]}
|
||||||
styles.tab,
|
|
||||||
activeTab === tab && styles.tabActive,
|
|
||||||
]}
|
|
||||||
onPress={() => setActiveTab(tab)}
|
onPress={() => setActiveTab(tab)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
@ -63,28 +104,49 @@ export const MyOrdersScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={styles.loaderContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={filteredOrders}
|
data={filteredOrders}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={item => item.id}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<OrderHistoryCard
|
<OrderHistoryCard
|
||||||
providerName={item.providerName}
|
providerName={item?.merchant?.name || ''}
|
||||||
providerImage={item.providerImage}
|
providerImage={item?.merchant?.imageUrl || ''}
|
||||||
orderDate={item.orderDate}
|
orderDate={formatDate(item?.createdAt)}
|
||||||
status={item.status}
|
status={item.status}
|
||||||
total={item.total}
|
total={Number(item?.totalAmount) || 0}
|
||||||
onReorder={() => {
|
items={item.orderItems}
|
||||||
navigation.navigate('ProviderDetailsScreen', {
|
onTrack={() => {
|
||||||
providerId: 'p1',
|
if (item?.status === 'OUT_FOR_DELIVERY') {
|
||||||
providerName: item.providerName,
|
// navigation.navigate('ProviderDetailsScreen', {
|
||||||
|
// providerName: item?.merchant?.name || '',
|
||||||
|
// });
|
||||||
|
// } else {
|
||||||
|
navigation.navigate('LiveTrackingScreen', {
|
||||||
|
orderId: item?.id || '',
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onDetails={() => {
|
onDetails={() => {
|
||||||
if (item.status === 'Ongoing') {
|
// if (
|
||||||
navigation.navigate('OrderTrackingScreen', { orderId: item.id });
|
// item?.status === 'PENDING' ||
|
||||||
} else {
|
// item?.status === 'CONFIRMED' ||
|
||||||
navigation.navigate('OrderDeliveredScreen', { orderId: item.id });
|
// item?.status === 'PREPARING' ||
|
||||||
}
|
// item?.status === 'READY_FOR_PICKUP' ||
|
||||||
|
// item?.status === 'OUT_FOR_DELIVERY'
|
||||||
|
// ) {
|
||||||
|
navigation.navigate('OrderDetailsScreen', {
|
||||||
|
orderId: item.id,
|
||||||
|
});
|
||||||
|
// } else {
|
||||||
|
// navigation.navigate('OrderDeliveredScreen', {
|
||||||
|
// orderId: item.id,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -94,7 +156,7 @@ export const MyOrdersScreen: React.FC = () => {
|
|||||||
<Text style={styles.emptyText}>No orders found</Text>
|
<Text style={styles.emptyText}>No orders found</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,51 +1,178 @@
|
|||||||
import { StyleSheet } from 'react-native';
|
import { StyleSheet } from 'react-native';
|
||||||
import { typography } from '@theme';
|
import { typography } from '@theme';
|
||||||
|
|
||||||
export const getStyles = (colors: any) => StyleSheet.create({
|
export const getStyles = (colors: any) =>
|
||||||
|
StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: colors.background,
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
padding: 16,
|
padding: 16,
|
||||||
|
paddingBottom: 40,
|
||||||
},
|
},
|
||||||
offerCard: {
|
offerCard: {
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: colors.cardBg,
|
backgroundColor: colors.cardBg,
|
||||||
borderRadius: 12,
|
borderRadius: 16,
|
||||||
padding: 16,
|
marginBottom: 16,
|
||||||
marginBottom: 12,
|
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
|
overflow: 'hidden',
|
||||||
|
elevation: 2,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 8,
|
||||||
|
},
|
||||||
|
cardHeaderRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
iconWrap: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 12,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
iconEmoji: {
|
||||||
|
fontSize: 22,
|
||||||
},
|
},
|
||||||
offerInfo: {
|
offerInfo: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
paddingRight: 8,
|
||||||
},
|
},
|
||||||
offerCode: {
|
badgeContainer: {
|
||||||
fontSize: typography.fontSize.lg,
|
alignSelf: 'flex-start',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
fontWeight: typography.fontWeight.bold,
|
fontWeight: typography.fontWeight.bold,
|
||||||
color: colors.primary,
|
letterSpacing: 0.4,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
offerTitle: {
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
|
lineHeight: 22,
|
||||||
},
|
},
|
||||||
offerDescription: {
|
offerDescription: {
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
color: colors.text,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
offerExpiry: {
|
|
||||||
fontSize: typography.fontSize.xs,
|
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 18,
|
||||||
},
|
},
|
||||||
copyButton: {
|
copyButton: {
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 10,
|
paddingVertical: 9,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: colors.primary,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
copyButtonSuccess: {
|
||||||
|
backgroundColor: '#2E7D32',
|
||||||
},
|
},
|
||||||
copyButtonText: {
|
copyButtonText: {
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.xs,
|
||||||
fontWeight: typography.fontWeight.semibold,
|
fontWeight: typography.fontWeight.bold,
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
},
|
},
|
||||||
|
cardFooter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 12,
|
||||||
|
paddingTop: 10,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
backgroundColor: colors.inputBg || '#FAFAFA',
|
||||||
|
},
|
||||||
|
metaChip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
marginRight: 8,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
metaChipText: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
},
|
||||||
|
emptyWrap: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 60,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
emptyIconWrap: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
backgroundColor: colors.inputBg || '#F5F5F5',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
emptyEmoji: {
|
||||||
|
fontSize: 36,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
fontSize: typography.fontSize.lg,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
emptySubtitle: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
skeletonCard: {
|
||||||
|
height: 110,
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderRadius: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
skeletonLineShort: {
|
||||||
|
height: 16,
|
||||||
|
width: '40%',
|
||||||
|
backgroundColor: colors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
skeletonLineLong: {
|
||||||
|
height: 14,
|
||||||
|
width: '80%',
|
||||||
|
backgroundColor: colors.border,
|
||||||
|
borderRadius: 7,
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
skeletonLineSub: {
|
||||||
|
height: 12,
|
||||||
|
width: '60%',
|
||||||
|
backgroundColor: colors.border,
|
||||||
|
borderRadius: 6,
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,43 +1,227 @@
|
|||||||
import React from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { View, Text, FlatList, TouchableOpacity } from 'react-native';
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
StatusBar,
|
||||||
|
ToastAndroid,
|
||||||
|
Platform,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { getStyles } from './offersScreen.styles';
|
import { getStyles } from './offersScreen.styles';
|
||||||
import { Header } from '@components';
|
import { Header } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
|
import { useAppDispatch, useAppSelector, getAllOffersThunk } from '@store';
|
||||||
|
import { Promotion, PromotionType } from '@interfaces';
|
||||||
|
import Clipboard from '@react-native-clipboard/clipboard';
|
||||||
|
|
||||||
const OFFERS = [
|
const getPromoVisuals = (type: PromotionType) => {
|
||||||
{ id: '1', code: 'WELCOME50', description: '50% off on your first order', expiry: '30 Jul 2026' },
|
switch (type) {
|
||||||
{ id: '2', code: 'FLAT20', description: 'Flat ₹20 off on orders above ₹199', expiry: '15 Aug 2026' },
|
case 'FREE_DELIVERY':
|
||||||
{ id: '3', code: 'FREEDEL', description: 'Free delivery on orders above ₹299', expiry: '31 Jul 2026' },
|
return {
|
||||||
];
|
emoji: '🛵',
|
||||||
|
bgColor: '#E8F5E9',
|
||||||
|
badgeBg: '#C8E6C9',
|
||||||
|
textColor: '#1B5E20',
|
||||||
|
};
|
||||||
|
case 'PERCENTAGE_DISCOUNT':
|
||||||
|
return {
|
||||||
|
emoji: '🏷️',
|
||||||
|
bgColor: '#FFF3E0',
|
||||||
|
badgeBg: '#FFE0B2',
|
||||||
|
textColor: '#E65100',
|
||||||
|
};
|
||||||
|
case 'FLAT_DISCOUNT':
|
||||||
|
return {
|
||||||
|
emoji: '💰',
|
||||||
|
bgColor: '#E3F2FD',
|
||||||
|
badgeBg: '#BBDEFB',
|
||||||
|
textColor: '#0D47A1',
|
||||||
|
};
|
||||||
|
case 'BUY_ONE_GET_ONE':
|
||||||
|
return {
|
||||||
|
emoji: '🎁',
|
||||||
|
bgColor: '#F3E5F5',
|
||||||
|
badgeBg: '#E1BEE7',
|
||||||
|
textColor: '#4A148C',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
emoji: '🎉',
|
||||||
|
bgColor: '#F5F5F5',
|
||||||
|
badgeBg: '#E0E0E0',
|
||||||
|
textColor: '#333333',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const OffersScreen: React.FC = () => {
|
export const OffersScreen: React.FC = () => {
|
||||||
const { colors } = useAppTheme();
|
const { colors, isDarkMode } = useAppTheme();
|
||||||
const styles = getStyles(colors);
|
const styles = getStyles(colors);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { offers, isLoading } = useAppSelector(state => state.offer);
|
||||||
|
|
||||||
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
dispatch(getAllOffersThunk());
|
||||||
|
}, [dispatch]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopy = (item: Promotion) => {
|
||||||
|
|
||||||
|
Clipboard.setString(item.badgeText);
|
||||||
|
setCopiedId(item.id);
|
||||||
|
// const textToCopy = item.badgeText || item.title;
|
||||||
|
// if (Platform.OS === 'android') {
|
||||||
|
// ToastAndroid.show(`Copied "${textToCopy}"`, ToastAndroid.SHORT);
|
||||||
|
// }
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopiedId(prev => (prev === item.id ? null : prev));
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderOfferCard = ({ item }: { item: Promotion }) => {
|
||||||
|
const visuals = getPromoVisuals(item.promotionType);
|
||||||
|
const isCopied = copiedId === item.id;
|
||||||
|
|
||||||
|
const hasFooter =
|
||||||
|
(item.minimumOrderAmount && item.minimumOrderAmount > 0) ||
|
||||||
|
(item.maximumDiscount && item.maximumDiscount > 0) ||
|
||||||
|
(item.applicableCategories && item.applicableCategories.length > 0) ||
|
||||||
|
(item.applicableProducts && item.applicableProducts.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
|
||||||
<Header title="Offers" />
|
|
||||||
<FlatList
|
|
||||||
data={OFFERS}
|
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<View style={styles.offerCard}>
|
<View style={styles.offerCard}>
|
||||||
<View style={styles.offerInfo}>
|
{/* Main Card Header */}
|
||||||
<Text style={styles.offerCode}>{item.code}</Text>
|
<View style={styles.cardHeaderRow}>
|
||||||
<Text style={styles.offerDescription}>{item.description}</Text>
|
<View style={[styles.iconWrap, { backgroundColor: visuals.bgColor }]}>
|
||||||
<Text style={styles.offerExpiry}>Expires: {item.expiry}</Text>
|
<Text style={styles.iconEmoji}>{visuals.emoji}</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.copyButton}
|
<View style={styles.offerInfo}>
|
||||||
onPress={() => {}}
|
{!!item.badgeText && (
|
||||||
activeOpacity={0.7}
|
<View
|
||||||
|
style={[
|
||||||
|
styles.badgeContainer,
|
||||||
|
{ backgroundColor: visuals.badgeBg },
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.copyButtonText}>Copy</Text>
|
<Text style={[styles.badgeText, { color: visuals.textColor }]}>
|
||||||
</TouchableOpacity>
|
{item.badgeText}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={styles.offerTitle}>{item.title}</Text>
|
||||||
|
{!!item.description && (
|
||||||
|
<Text style={styles.offerDescription}>{item.description}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.copyButton, isCopied && styles.copyButtonSuccess]}
|
||||||
|
onPress={() => handleCopy(item)}
|
||||||
|
// onPress={() => { }}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.copyButtonText}>
|
||||||
|
{isCopied ? 'Copied ✓' : 'Copy'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Dynamic Meta Details Footer (Only if real data exists) */}
|
||||||
|
{hasFooter && (
|
||||||
|
<View style={styles.cardFooter}>
|
||||||
|
{!!item.minimumOrderAmount && item.minimumOrderAmount > 0 && (
|
||||||
|
<View style={styles.metaChip}>
|
||||||
|
<Text style={styles.metaChipText}>
|
||||||
|
Min order ₹{item.minimumOrderAmount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!item.maximumDiscount && item.maximumDiscount > 0 && (
|
||||||
|
<View style={styles.metaChip}>
|
||||||
|
<Text style={styles.metaChipText}>
|
||||||
|
Max discount ₹{item.maximumDiscount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!item.applicableCategories?.length && (
|
||||||
|
<View style={styles.metaChip}>
|
||||||
|
<Text style={styles.metaChipText}>
|
||||||
|
{item.applicableCategories.length}{' '}
|
||||||
|
{item.applicableCategories.length === 1
|
||||||
|
? 'category'
|
||||||
|
: 'categories'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!item.applicableProducts?.length && (
|
||||||
|
<View style={styles.metaChip}>
|
||||||
|
<Text style={styles.metaChipText}>
|
||||||
|
{item.applicableProducts.length}{' '}
|
||||||
|
{item.applicableProducts.length === 1 ? 'item' : 'items'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
contentContainerStyle={styles.list}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderSkeleton = () => (
|
||||||
|
<View style={styles.list}>
|
||||||
|
{[1, 2, 3].map(key => (
|
||||||
|
<View key={key} style={styles.skeletonCard}>
|
||||||
|
<View style={styles.skeletonLineShort} />
|
||||||
|
<View style={styles.skeletonLineLong} />
|
||||||
|
<View style={styles.skeletonLineSub} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderEmpty = () => (
|
||||||
|
<View style={styles.emptyWrap}>
|
||||||
|
<View style={styles.emptyIconWrap}>
|
||||||
|
<Text style={styles.emptyEmoji}>🏷️</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.emptyTitle}>No Offers Available</Text>
|
||||||
|
<Text style={styles.emptySubtitle}>
|
||||||
|
There are currently no active promotions.{'\n'}Check back later for
|
||||||
|
exciting discounts and deals!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar
|
||||||
|
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
|
||||||
|
backgroundColor={colors.background}
|
||||||
|
/>
|
||||||
|
<Header title="Offers" />
|
||||||
|
|
||||||
|
{isLoading && (!offers || offers.length === 0) ? (
|
||||||
|
renderSkeleton()
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={offers || []}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
renderItem={renderOfferCard}
|
||||||
|
ListEmptyComponent={renderEmpty}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,8 +3,9 @@ import { View, Text } from 'react-native';
|
|||||||
import { getStyles } from './onboardingCompleteScreen.styles';
|
import { getStyles } from './onboardingCompleteScreen.styles';
|
||||||
import { PrimaryButton } from '@components';
|
import { PrimaryButton } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { useAppDispatch } from '../../../store';
|
|
||||||
import { completeOnboarding } from '../../../store/commonreducers/auth';
|
import { useAppDispatch } from '@store';
|
||||||
|
import { completeOnboarding } from '@store/commonreducers/auth';
|
||||||
|
|
||||||
export const OnboardingCompleteScreen: React.FC = () => {
|
export const OnboardingCompleteScreen: React.FC = () => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
@ -12,6 +13,9 @@ export const OnboardingCompleteScreen: React.FC = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const handleExplore = () => {
|
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());
|
dispatch(completeOnboarding());
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -25,10 +29,7 @@ export const OnboardingCompleteScreen: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<PrimaryButton
|
<PrimaryButton title="Explore Now" onPress={handleExplore} />
|
||||||
title="Explore Now"
|
|
||||||
onPress={handleExplore}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export const OrderConfirmedScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<PrimaryButton title="View Tracking" onPress={() => navigation.navigate('OrderTrackingScreen', { orderId })} />
|
<PrimaryButton title="Done" onPress={() => navigation.replace('MainTabs')} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
3
app/features/screens/orderDetailsScreen/index.ts
Normal file
3
app/features/screens/orderDetailsScreen/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './orderDetailsScreen';
|
||||||
|
export * from './thunk';
|
||||||
|
export * from './reducer';
|
||||||
@ -0,0 +1,355 @@
|
|||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
import { typography } from '@theme';
|
||||||
|
|
||||||
|
export const getStyles = (colors: any) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
color: colors.error,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
// Used for the OrderStatusTimeline section — no inner padding as the
|
||||||
|
// component renders its own self-contained card with rounded borders.
|
||||||
|
timelineSection: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
orderNumber: {
|
||||||
|
fontSize: typography.fontSize.lg,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#E8F5E9',
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
merchantRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
paddingTop: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
merchantIcon: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
merchantName: {
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
itemRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
itemInfo: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
itemQuantityBadge: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 4,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
marginRight: 10,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
itemQuantityText: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
itemName: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
itemPrice: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
billRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
billLabel: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
billValue: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
},
|
||||||
|
billDivider: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: colors.border,
|
||||||
|
marginVertical: 12,
|
||||||
|
},
|
||||||
|
billTotalLabel: {
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
billTotalValue: {
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
addressType: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
addressText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
paymentRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
paymentIcon: {
|
||||||
|
marginRight: 8,
|
||||||
|
fontSize: 20,
|
||||||
|
},
|
||||||
|
paymentText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Rating integration ──────────────────────────────────────────────────
|
||||||
|
/** Subtle highlight applied to item rows when the order is DELIVERED */
|
||||||
|
itemRowTappable: {
|
||||||
|
backgroundColor: colors.surface ?? '#F8F9FA',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 8,
|
||||||
|
marginHorizontal: -10,
|
||||||
|
},
|
||||||
|
ratingPillRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
/** Green "✓ Rated" pill */
|
||||||
|
ratedPill: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: (colors.primaryMuted ?? '#E9F7EF'),
|
||||||
|
borderRadius: 20,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
},
|
||||||
|
ratedPillText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.primary ?? '#05824C',
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
/** Amber "⭐ Rate this" pill */
|
||||||
|
ratePill: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFF8E1',
|
||||||
|
borderRadius: 20,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
},
|
||||||
|
ratePillText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#F59E0B',
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
/** Banner shown at the top of Order Items when DELIVERED */
|
||||||
|
rateHintBanner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFF8E1',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
rateHintEmoji: {
|
||||||
|
fontSize: 16,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
rateHintText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: '#92400E',
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
},
|
||||||
|
orderNeedHelpBtn: {
|
||||||
|
marginTop: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
backgroundColor: '#E8F5E9',
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#C8E6C9',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
orderNeedHelpText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
},
|
||||||
|
// Help modal specific styles
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderRadius: 16,
|
||||||
|
width: '100%',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 6,
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
textArea: {
|
||||||
|
minHeight: 80,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
},
|
||||||
|
categorySelect: {
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
categorySelectText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
modalBtn: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
cancelBtn: {
|
||||||
|
backgroundColor: colors.border,
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
submitBtn: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
marginLeft: 10,
|
||||||
|
},
|
||||||
|
cancelBtnText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
},
|
||||||
|
submitBtnText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
453
app/features/screens/orderDetailsScreen/orderDetailsScreen.tsx
Normal file
453
app/features/screens/orderDetailsScreen/orderDetailsScreen.tsx
Normal file
@ -0,0 +1,453 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
ActivityIndicator,
|
||||||
|
TouchableOpacity,
|
||||||
|
Modal,
|
||||||
|
TextInput,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useRoute, useNavigation } from '@react-navigation/native';
|
||||||
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
|
import { RouteProp } from '@react-navigation/native';
|
||||||
|
import { Header, OrderStatusTimeline, ProductRatingModal } from '@components';
|
||||||
|
import { useAppTheme } from '@theme';
|
||||||
|
import { getStyles } from './orderDetailsScreen.styles';
|
||||||
|
import { RootState, useAppDispatch, useAppSelector } from '@store';
|
||||||
|
import { getOrderByIdThunk } from '../checkoutPaymentScreen';
|
||||||
|
import { formatDate, formatTime } from '@utils/helper';
|
||||||
|
import { submitReview } from './thunk';
|
||||||
|
import { giveRatingPayload, OrderItem, TicketCategory } from '@interfaces';
|
||||||
|
import { AppStackParamList } from '@navigation';
|
||||||
|
import { useCustomerSupport } from '../../../hooks/useCustomerSupport';
|
||||||
|
|
||||||
|
type OrderDetailsRouteProp = RouteProp<AppStackParamList, 'OrderDetailsScreen'>;
|
||||||
|
type OrderDetailsNavProp = StackNavigationProp<AppStackParamList>;
|
||||||
|
|
||||||
|
export const OrderDetailsScreen: React.FC = () => {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const styles = getStyles(colors);
|
||||||
|
const route = useRoute<OrderDetailsRouteProp>();
|
||||||
|
const navigation = useNavigation<OrderDetailsNavProp>();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const orderId = route.params?.orderId;
|
||||||
|
|
||||||
|
const { orderDetails, orderDetailsLoading, orderDetailsError } =
|
||||||
|
useAppSelector((state: RootState) => state.paymentMethods);
|
||||||
|
|
||||||
|
// ── Rating modal state ────────────────────────────────────────────────────
|
||||||
|
const [ratingModalItem, setRatingModalItem] = useState<OrderItem | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
/** Tracks which productIds have already been rated this session */
|
||||||
|
const [ratedProductIds, setRatedProductIds] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isDelivered = orderDetails?.status === 'DELIVERED';
|
||||||
|
|
||||||
|
// ── Support / Need Help modal state ───────────────────────────────────────
|
||||||
|
const [helpModalVisible, setHelpModalVisible] = useState(false);
|
||||||
|
const [categorySelectVisible, setCategorySelectVisible] = useState(false);
|
||||||
|
const [helpCategory, setHelpCategory] = useState<TicketCategory>('ORDER');
|
||||||
|
const [helpDescription, setHelpDescription] = useState('');
|
||||||
|
const [submittingTicket, setSubmittingTicket] = useState(false);
|
||||||
|
|
||||||
|
const { createTicket } = useCustomerSupport();
|
||||||
|
|
||||||
|
const handleHelpSubmit = async () => {
|
||||||
|
if (!orderDetails) return;
|
||||||
|
if (!helpDescription.trim()) {
|
||||||
|
Alert.alert('Error', 'Please describe the issue in detail.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmittingTicket(true);
|
||||||
|
try {
|
||||||
|
const orderShortId = orderDetails.orderNumber?.split('-').pop() || orderDetails.id;
|
||||||
|
const newTicket = await createTicket({
|
||||||
|
category: helpCategory,
|
||||||
|
priority: 'MEDIUM',
|
||||||
|
subject: `Issue with Order #${orderShortId}`,
|
||||||
|
description: helpDescription.trim(),
|
||||||
|
orderId: orderDetails.id,
|
||||||
|
});
|
||||||
|
setHelpModalVisible(false);
|
||||||
|
setHelpDescription('');
|
||||||
|
Alert.alert('Success', 'Support ticket created successfully!');
|
||||||
|
navigation.navigate('SupportChatScreen', { ticketId: newTicket.id });
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Error', 'Failed to create support ticket. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmittingTicket(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (orderId) {
|
||||||
|
dispatch(getOrderByIdThunk(orderId));
|
||||||
|
}
|
||||||
|
}, [dispatch, orderId]);
|
||||||
|
|
||||||
|
const handleRatingSubmit = useCallback(
|
||||||
|
(payload: giveRatingPayload) => {
|
||||||
|
if (!ratingModalItem) return;
|
||||||
|
// Dispatch the API call: POST /products/:productId/ratings
|
||||||
|
dispatch(
|
||||||
|
submitReview({
|
||||||
|
id: ratingModalItem.productId,
|
||||||
|
payload: {
|
||||||
|
rating: payload.rating,
|
||||||
|
comment: payload.comment,
|
||||||
|
images: payload.images,
|
||||||
|
orderId: orderDetails?.id ?? '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.then(() => {
|
||||||
|
setRatedProductIds(prev =>
|
||||||
|
new Set(prev).add(ratingModalItem.productId),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[dispatch, ratingModalItem, orderDetails?.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleModalClose = useCallback(() => {
|
||||||
|
setRatingModalItem(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Loading ───────────────────────────────────────────────────────────────
|
||||||
|
if (orderDetailsLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Header title="Order Details" onBack={() => navigation.goBack()} />
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
<Text style={styles.loadingText}>Fetching order details...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orderDetailsError || !orderDetails) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Header title="Order Details" onBack={() => navigation.goBack()} />
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{orderDetailsError || 'Could not load order details.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderDate = orderDetails.createdAt
|
||||||
|
? `${formatDate(orderDetails.createdAt)} at ${formatTime(
|
||||||
|
orderDetails.createdAt,
|
||||||
|
)}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Header title="Order Details" onBack={() => navigation.goBack()} />
|
||||||
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
{/* Top Info Section */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<Text style={styles.orderNumber}>
|
||||||
|
Order {orderDetails.orderNumber?.split('-').pop() || ''}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.statusBadge}>
|
||||||
|
<Text style={styles.statusText}>{orderDetails.status}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.dateText}>Placed on {orderDate}</Text>
|
||||||
|
<Text style={styles.dateText}>
|
||||||
|
Payment: {orderDetails.paymentMethod}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Merchant Info */}
|
||||||
|
<View style={styles.merchantRow}>
|
||||||
|
<View style={styles.merchantIcon}>
|
||||||
|
<Text>🏪</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.merchantName}>
|
||||||
|
{orderDetails.merchant?.name || 'Store'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Need Help Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.orderNeedHelpBtn}
|
||||||
|
onPress={() => setHelpModalVisible(true)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.orderNeedHelpText}>❓ Need help with this order?</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Order Status Timeline Section */}
|
||||||
|
<View style={styles.timelineSection}>
|
||||||
|
<Text style={styles.sectionTitle}>Order Status</Text>
|
||||||
|
<OrderStatusTimeline
|
||||||
|
tracking={orderDetails.tracking}
|
||||||
|
currentStatus={orderDetails.status}
|
||||||
|
deliveryPartner={orderDetails?.delivery?.deliveryPartner?.user}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Order Items Section */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Order Items</Text>
|
||||||
|
|
||||||
|
{/* Rate all hint — shown only when delivered */}
|
||||||
|
{isDelivered && (
|
||||||
|
<View style={styles.rateHintBanner}>
|
||||||
|
<Text style={styles.rateHintEmoji}>⭐</Text>
|
||||||
|
<Text style={styles.rateHintText}>
|
||||||
|
Tap any item below to rate it
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{orderDetails.orderItems?.map((item, index) => {
|
||||||
|
const isRated = ratedProductIds.has(item.productId);
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item.id || index}
|
||||||
|
style={[styles.itemRow, isDelivered && styles.itemRowTappable]}
|
||||||
|
activeOpacity={isDelivered ? 0.65 : 1}
|
||||||
|
onPress={() => {
|
||||||
|
if (isDelivered) {
|
||||||
|
setRatingModalItem(item);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!isDelivered}
|
||||||
|
>
|
||||||
|
<View style={styles.itemInfo}>
|
||||||
|
<View style={styles.itemQuantityBadge}>
|
||||||
|
<Text style={styles.itemQuantityText}>
|
||||||
|
{item.quantity}x
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.itemName}>{item.name}</Text>
|
||||||
|
{/* Rating status pill — only shown when DELIVERED */}
|
||||||
|
{isDelivered && (
|
||||||
|
<View style={styles.ratingPillRow}>
|
||||||
|
{isRated ? (
|
||||||
|
<View style={styles.ratedPill}>
|
||||||
|
<Text style={styles.ratedPillText}>✓ Rated</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.ratePill}>
|
||||||
|
<Text style={styles.ratePillText}>
|
||||||
|
⭐ Rate this
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.itemPrice}>₹{item.totalAmount}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bill Summary Section */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Bill Summary</Text>
|
||||||
|
<View style={styles.billRow}>
|
||||||
|
<Text style={styles.billLabel}>Item Total</Text>
|
||||||
|
<Text style={styles.billValue}>₹{orderDetails.subtotal}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.billRow}>
|
||||||
|
<Text style={styles.billLabel}>Delivery Fee</Text>
|
||||||
|
<Text style={styles.billValue}>₹{orderDetails.deliveryFee}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.billRow}>
|
||||||
|
<Text style={styles.billLabel}>Platform Fee</Text>
|
||||||
|
<Text style={styles.billValue}>₹{orderDetails.platformFee}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.billRow}>
|
||||||
|
<Text style={styles.billLabel}>Taxes</Text>
|
||||||
|
<Text style={styles.billValue}>₹{orderDetails.taxAmount}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.billDivider} />
|
||||||
|
|
||||||
|
<View style={styles.billRow}>
|
||||||
|
<Text style={styles.billTotalLabel}>Grand Total</Text>
|
||||||
|
<Text style={styles.billTotalValue}>
|
||||||
|
₹{orderDetails.totalAmount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Delivery Address Section */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Delivery Details</Text>
|
||||||
|
<Text style={styles.addressType}>
|
||||||
|
{orderDetails.dropAddress?.label || 'Home'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.addressText}>
|
||||||
|
{orderDetails.dropAddress?.houseNumber
|
||||||
|
? `${orderDetails.dropAddress.houseNumber}, `
|
||||||
|
: ''}
|
||||||
|
{orderDetails.dropAddress?.addressLine1}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.addressText}>
|
||||||
|
{orderDetails.dropAddress?.landmark
|
||||||
|
? `Landmark: ${orderDetails.dropAddress.landmark}`
|
||||||
|
: ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.addressText}>
|
||||||
|
Phone: {orderDetails.dropAddress?.phone}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Payment Info */}
|
||||||
|
{orderDetails.payments && orderDetails.payments.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Payment Details</Text>
|
||||||
|
<View style={styles.paymentRow}>
|
||||||
|
<Text style={styles.paymentIcon}>💳</Text>
|
||||||
|
<Text style={styles.paymentText}>
|
||||||
|
Paid via {orderDetails.payments[0].method} (
|
||||||
|
{orderDetails.payments[0].status})
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Product Rating Modal */}
|
||||||
|
<ProductRatingModal
|
||||||
|
visible={ratingModalItem !== null}
|
||||||
|
productId={ratingModalItem?.productId ?? ''}
|
||||||
|
productName={ratingModalItem?.name ?? ''}
|
||||||
|
orderId={orderDetails?.id}
|
||||||
|
quantity={ratingModalItem?.quantity}
|
||||||
|
onSubmit={handleRatingSubmit}
|
||||||
|
onClose={handleModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Need Help / Ticket Creation Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={helpModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setHelpModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<Text style={styles.modalTitle}>Report Issue with Order</Text>
|
||||||
|
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
|
<Text style={styles.label}>Category</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.categorySelect}
|
||||||
|
onPress={() => setCategorySelectVisible(true)}
|
||||||
|
>
|
||||||
|
<Text style={styles.categorySelectText}>
|
||||||
|
{helpCategory.replace('_', ' ')}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: colors.textSecondary }}>▼</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Order ID</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, { opacity: 0.6 }]}
|
||||||
|
value={orderDetails.orderNumber || orderDetails.id}
|
||||||
|
editable={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Details</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, styles.textArea]}
|
||||||
|
value={helpDescription}
|
||||||
|
onChangeText={setHelpDescription}
|
||||||
|
placeholder="What seems to be the problem with this order?"
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalBtn, styles.cancelBtn]}
|
||||||
|
onPress={() => setHelpModalVisible(false)}
|
||||||
|
disabled={submittingTicket}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelBtnText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalBtn, styles.submitBtn]}
|
||||||
|
onPress={handleHelpSubmit}
|
||||||
|
disabled={submittingTicket}
|
||||||
|
>
|
||||||
|
{submittingTicket ? (
|
||||||
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.submitBtnText}>Submit</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Help Category Select Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={categorySelectVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setCategorySelectVisible(false)}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', padding: 24 }}>
|
||||||
|
<View style={{ backgroundColor: colors.cardBg, borderRadius: 12, padding: 16 }}>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: 'bold', color: colors.text, marginBottom: 12, textAlign: 'center' }}>
|
||||||
|
Select Issue Category
|
||||||
|
</Text>
|
||||||
|
{[
|
||||||
|
{ value: 'ORDER', label: 'Order' },
|
||||||
|
{ value: 'PAYMENT', label: 'Payment' },
|
||||||
|
{ value: 'PAYOUT', label: 'Payout' },
|
||||||
|
{ value: 'TECHNICAL_ISSUE', label: 'Technical Issue' },
|
||||||
|
{ value: 'ACCOUNT', label: 'Account' },
|
||||||
|
{ value: 'OTHER', label: 'Other' },
|
||||||
|
].map((item) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item.value}
|
||||||
|
style={{ paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.border }}
|
||||||
|
onPress={() => {
|
||||||
|
setHelpCategory(item.value as TicketCategory);
|
||||||
|
setCategorySelectVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 16, color: colors.text }}>{item.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ marginTop: 16, paddingVertical: 12, alignItems: 'center' }}
|
||||||
|
onPress={() => setCategorySelectVisible(false)}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.primary, fontWeight: 'bold' }}>Close</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OrderDetailsScreen;
|
||||||
25
app/features/screens/orderDetailsScreen/reducer.ts
Normal file
25
app/features/screens/orderDetailsScreen/reducer.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { createReducer } from '@reduxjs/toolkit';
|
||||||
|
import { submitReview } from './thunk';
|
||||||
|
|
||||||
|
export interface OrderDetailsState {
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: OrderDetailsState = {
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const orderDetailsReducer = createReducer(initialState, builder => {
|
||||||
|
builder.addCase(submitReview.pending, state => {
|
||||||
|
state.loading = true;
|
||||||
|
});
|
||||||
|
builder.addCase(submitReview.fulfilled, state => {
|
||||||
|
state.loading = false;
|
||||||
|
});
|
||||||
|
builder.addCase(submitReview.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
});
|
||||||
|
});
|
||||||
18
app/features/screens/orderDetailsScreen/thunk.ts
Normal file
18
app/features/screens/orderDetailsScreen/thunk.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import { giveRatingPayload } from '@interfaces';
|
||||||
|
import { giveRatingApi } from '@api';
|
||||||
|
|
||||||
|
export const submitReview = createAsyncThunk(
|
||||||
|
'order/submitReview',
|
||||||
|
async (
|
||||||
|
{ id, payload }: { id: string; payload: giveRatingPayload },
|
||||||
|
{ rejectWithValue },
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await giveRatingApi(id, payload);
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(error.response?.data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
108
app/features/screens/otpScreen/hooks/useOtpScreen.ts
Normal file
108
app/features/screens/otpScreen/hooks/useOtpScreen.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Alert } from 'react-native';
|
||||||
|
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||||
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
|
import { RootState, useAppDispatch, useAppSelector, verifyOtp } from '@store';
|
||||||
|
import { AuthStackParamList } from '@navigation/authStack';
|
||||||
|
|
||||||
|
type OtpNavProp = StackNavigationProp<AuthStackParamList, 'OtpScreen'>;
|
||||||
|
type OtpRouteProp = RouteProp<AuthStackParamList, 'OtpScreen'>;
|
||||||
|
|
||||||
|
const OTP_LENGTH = 6;
|
||||||
|
const RESEND_TIMER = 30;
|
||||||
|
|
||||||
|
export const useOtpScreen = () => {
|
||||||
|
const navigation = useNavigation<OtpNavProp>();
|
||||||
|
const route = useRoute<OtpRouteProp>();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { mobileNumber } = route.params;
|
||||||
|
|
||||||
|
const { loginData, user } = useAppSelector((state: RootState) => state.auth);
|
||||||
|
|
||||||
|
const [otp, setOtp] = useState<string[]>(Array(OTP_LENGTH).fill(''));
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const [timer, setTimer] = useState(RESEND_TIMER);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer > 0) {
|
||||||
|
const interval = setInterval(() => setTimer(t => t - 1), 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [timer]);
|
||||||
|
|
||||||
|
const resetOtp = useCallback(() => {
|
||||||
|
setOtp(Array(OTP_LENGTH).fill(''));
|
||||||
|
setActiveIndex(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyPress = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
if (key === 'backspace') {
|
||||||
|
if (otp[activeIndex] || activeIndex > 0) {
|
||||||
|
const newOtp = [...otp];
|
||||||
|
if (otp[activeIndex]) {
|
||||||
|
newOtp[activeIndex] = '';
|
||||||
|
} else if (activeIndex > 0) {
|
||||||
|
newOtp[activeIndex - 1] = '';
|
||||||
|
setActiveIndex(activeIndex - 1);
|
||||||
|
}
|
||||||
|
setOtp(newOtp);
|
||||||
|
}
|
||||||
|
} else if (key >= '0' && key <= '9' && activeIndex < OTP_LENGTH) {
|
||||||
|
const newOtp = [...otp];
|
||||||
|
newOtp[activeIndex] = key;
|
||||||
|
setOtp(newOtp);
|
||||||
|
if (activeIndex < OTP_LENGTH - 1) {
|
||||||
|
setActiveIndex(activeIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[otp, activeIndex],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResend = useCallback(() => {
|
||||||
|
setTimer(RESEND_TIMER);
|
||||||
|
resetOtp();
|
||||||
|
// TODO: dispatch resend/loginWithPhone thunk here if needed
|
||||||
|
}, [resetOtp]);
|
||||||
|
|
||||||
|
const handleVerify = useCallback(async () => {
|
||||||
|
const otpString = otp.join('');
|
||||||
|
if (otpString.length !== OTP_LENGTH) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await dispatch(
|
||||||
|
verifyOtp({ phone: mobileNumber, code: otpString, role: 'CUSTOMER' }),
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
// navigation.navigate('SetLocationScreen');
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
typeof error === 'string' ? error : 'Invalid OTP. Please try again.';
|
||||||
|
Alert.alert('Verification Failed', message);
|
||||||
|
resetOtp();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [dispatch, mobileNumber, navigation, otp, resetOtp]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// data
|
||||||
|
mobileNumber,
|
||||||
|
loginData,
|
||||||
|
user,
|
||||||
|
otp,
|
||||||
|
activeIndex,
|
||||||
|
timer,
|
||||||
|
isLoading,
|
||||||
|
otpLength: OTP_LENGTH,
|
||||||
|
isOtpComplete: otp.join('').length === OTP_LENGTH,
|
||||||
|
|
||||||
|
// actions
|
||||||
|
handleKeyPress,
|
||||||
|
handleVerify,
|
||||||
|
handleResend,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -6,71 +6,27 @@ import {
|
|||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
|
||||||
import { getStyles } from './otpScreen.styles';
|
import { getStyles } from './otpScreen.styles';
|
||||||
import { PrimaryButton } from '@components';
|
import { PrimaryButton } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { useAppDispatch } from '../../../store';
|
import { useOtpScreen } from './hooks/useOtpScreen';
|
||||||
import { verifyOtp } from '../../../store/commonreducers/auth';
|
|
||||||
import { AuthStackParamList } from '../../../navigation/authStack';
|
|
||||||
|
|
||||||
type OtpNavProp = StackNavigationProp<AuthStackParamList, 'OtpScreen'>;
|
|
||||||
type OtpRouteProp = RouteProp<AuthStackParamList, 'OtpScreen'>;
|
|
||||||
|
|
||||||
const OTP_LENGTH = 6;
|
|
||||||
const RESEND_TIMER = 30;
|
|
||||||
|
|
||||||
export const OtpScreen: React.FC = () => {
|
export const OtpScreen: React.FC = () => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
const styles = getStyles(colors);
|
const styles = getStyles(colors);
|
||||||
const navigation = useNavigation<OtpNavProp>();
|
|
||||||
const route = useRoute<OtpRouteProp>();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const { mobileNumber } = route.params;
|
|
||||||
|
|
||||||
const [otp, setOtp] = useState<string[]>(Array(OTP_LENGTH).fill(''));
|
const {
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
mobileNumber,
|
||||||
const [timer, setTimer] = useState(RESEND_TIMER);
|
otp,
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
activeIndex,
|
||||||
|
timer,
|
||||||
useEffect(() => {
|
isLoading,
|
||||||
if (timer > 0) {
|
otpLength,
|
||||||
const interval = setInterval(() => setTimer((t) => t - 1), 1000);
|
isOtpComplete,
|
||||||
return () => clearInterval(interval);
|
handleKeyPress,
|
||||||
}
|
handleVerify,
|
||||||
}, [timer]);
|
handleResend,
|
||||||
|
} = useOtpScreen();
|
||||||
const handleKeyPress = (key: string) => {
|
|
||||||
if (key === 'backspace') {
|
|
||||||
if (otp[activeIndex] || activeIndex > 0) {
|
|
||||||
const newOtp = [...otp];
|
|
||||||
if (otp[activeIndex]) {
|
|
||||||
newOtp[activeIndex] = '';
|
|
||||||
} else if (activeIndex > 0) {
|
|
||||||
newOtp[activeIndex - 1] = '';
|
|
||||||
setActiveIndex(activeIndex - 1);
|
|
||||||
}
|
|
||||||
setOtp(newOtp);
|
|
||||||
}
|
|
||||||
} else if (key >= '0' && key <= '9' && activeIndex < OTP_LENGTH) {
|
|
||||||
const newOtp = [...otp];
|
|
||||||
newOtp[activeIndex] = key;
|
|
||||||
setOtp(newOtp);
|
|
||||||
if (activeIndex < OTP_LENGTH - 1) {
|
|
||||||
setActiveIndex(activeIndex + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVerify = async () => {
|
|
||||||
const otpString = otp.join('');
|
|
||||||
if (otpString.length !== OTP_LENGTH) return;
|
|
||||||
setIsLoading(true);
|
|
||||||
await dispatch(verifyOtp({ mobileNumber, otp: otpString }));
|
|
||||||
setIsLoading(false);
|
|
||||||
navigation.navigate('SetLocationScreen');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
@ -79,12 +35,40 @@ export const OtpScreen: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<Text style={styles.title}>Verify OTP</Text>
|
<Text style={styles.title}>Verify OTP</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>Code sent to {mobileNumber}</Text>
|
||||||
Code sent to {mobileNumber}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View style={styles.otpContainer}>
|
<View style={styles.otpContainer}>
|
||||||
{otp.map((digit, index) => (
|
{otp.map(
|
||||||
|
(
|
||||||
|
digit:
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| bigint
|
||||||
|
| boolean
|
||||||
|
| React.ReactElement<
|
||||||
|
unknown,
|
||||||
|
string | React.JSXElementConstructor<any>
|
||||||
|
>
|
||||||
|
| Iterable<React.ReactNode>
|
||||||
|
| React.ReactPortal
|
||||||
|
| Promise<
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| bigint
|
||||||
|
| boolean
|
||||||
|
| React.ReactPortal
|
||||||
|
| React.ReactElement<
|
||||||
|
unknown,
|
||||||
|
string | React.JSXElementConstructor<any>
|
||||||
|
>
|
||||||
|
| Iterable<React.ReactNode>
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
>
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
|
index: React.Key | null | undefined,
|
||||||
|
) => (
|
||||||
<View
|
<View
|
||||||
key={index}
|
key={index}
|
||||||
style={[
|
style={[
|
||||||
@ -95,11 +79,12 @@ export const OtpScreen: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Text style={styles.otpCellText}>{digit}</Text>
|
<Text style={styles.otpCellText}>{digit}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.keypad}>
|
<View style={styles.keypad}>
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => (
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map(num => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={num}
|
key={num}
|
||||||
style={styles.key}
|
style={styles.key}
|
||||||
@ -129,14 +114,7 @@ export const OtpScreen: React.FC = () => {
|
|||||||
{timer > 0 ? (
|
{timer > 0 ? (
|
||||||
<Text style={styles.timerText}>Resend code in {timer}s</Text>
|
<Text style={styles.timerText}>Resend code in {timer}s</Text>
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={handleResend} activeOpacity={0.7}>
|
||||||
onPress={() => {
|
|
||||||
setTimer(RESEND_TIMER);
|
|
||||||
setOtp(Array(OTP_LENGTH).fill(''));
|
|
||||||
setActiveIndex(0);
|
|
||||||
}}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Text style={styles.resendText}>Resend OTP</Text>
|
<Text style={styles.resendText}>Resend OTP</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
@ -145,7 +123,7 @@ export const OtpScreen: React.FC = () => {
|
|||||||
title="Verify"
|
title="Verify"
|
||||||
onPress={handleVerify}
|
onPress={handleVerify}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
disabled={otp.join('').length !== OTP_LENGTH}
|
disabled={!isOtpComplete}
|
||||||
style={{ marginTop: 24 }}
|
style={{ marginTop: 24 }}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -1 +1,3 @@
|
|||||||
export * from './preferencesScreen';
|
export * from './preferencesScreen';
|
||||||
|
export * from './thunk';
|
||||||
|
export * from './reducer';
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Switch,
|
Switch,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
@ -12,60 +14,138 @@ import { getStyles } from './preferencesScreen.styles';
|
|||||||
import { PrimaryButton } from '@components';
|
import { PrimaryButton } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { AuthStackParamList } from '../../../navigation/authStack';
|
import { AuthStackParamList } from '../../../navigation/authStack';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@store';
|
||||||
|
import { fetchCategories, completeOnboard } from './thunk';
|
||||||
|
import { OnboardingStackParamList } from '@navigation/onboardingStack';
|
||||||
|
|
||||||
type NavProp = StackNavigationProp<AuthStackParamList, 'PreferencesScreen'>;
|
type NavProp = StackNavigationProp<
|
||||||
|
OnboardingStackParamList,
|
||||||
const CATEGORIES = [
|
'PreferencesScreen'
|
||||||
{ key: 'food', icon: '🍔', label: 'Food' },
|
>;
|
||||||
{ key: 'groceries', icon: '🛒', label: 'Groceries' },
|
|
||||||
{ key: 'pharmacy', icon: '💊', label: 'Pharmacy' },
|
|
||||||
{ key: 'others', icon: '📦', label: 'Others' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const PreferencesScreen: React.FC = () => {
|
export const PreferencesScreen: React.FC = () => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
const styles = getStyles(colors);
|
const styles = getStyles(colors);
|
||||||
const navigation = useNavigation<NavProp>();
|
const navigation = useNavigation<NavProp>();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>(['food']);
|
// ─── Redux state ──────────────────────────────────────────────────────────
|
||||||
|
const { categories, isLoading, error, isSubmitting, submitError } =
|
||||||
|
useAppSelector(state => state.preferences);
|
||||||
|
|
||||||
|
const locationData = useAppSelector(state => state.setLocation);
|
||||||
|
const profileData = useAppSelector(state => state.completeProfile);
|
||||||
|
|
||||||
|
// ─── Local state ──────────────────────────────────────────────────────────
|
||||||
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||||
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
|
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
|
||||||
|
|
||||||
const toggleCategory = (key: string) => {
|
// ─── Fetch categories on mount ────────────────────────────────────────────
|
||||||
setSelectedCategories((prev) =>
|
useEffect(() => {
|
||||||
prev.includes(key)
|
dispatch(fetchCategories());
|
||||||
? prev.filter((c) => c !== key)
|
}, [dispatch]);
|
||||||
: [...prev, key],
|
|
||||||
|
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 (
|
return (
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
<Text style={styles.title}>Preferences</Text>
|
<Text style={styles.title}>Preferences</Text>
|
||||||
<Text style={styles.subtitle}>Select your interests</Text>
|
<Text style={styles.subtitle}>Select your interests</Text>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<ActivityIndicator
|
||||||
|
size="large"
|
||||||
|
color={colors.primary}
|
||||||
|
style={{ marginVertical: 32 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!error && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: colors.error ?? 'red',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!submitError && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: colors.error ?? 'red',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && (
|
||||||
<View style={styles.grid}>
|
<View style={styles.grid}>
|
||||||
{CATEGORIES.map((cat) => (
|
{(categories || []).map(cat => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={cat.key}
|
key={cat.id}
|
||||||
style={[
|
style={[
|
||||||
styles.gridItem,
|
styles.gridItem,
|
||||||
selectedCategories.includes(cat.key) && styles.gridItemSelected,
|
selectedCategories.includes(cat.id) && styles.gridItemSelected,
|
||||||
]}
|
]}
|
||||||
onPress={() => toggleCategory(cat.key)}
|
onPress={() => toggleCategory(cat.id)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={styles.gridIcon}>{cat.icon}</Text>
|
<Text style={styles.gridLabel}>{cat.name}</Text>
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.gridLabel,
|
|
||||||
selectedCategories.includes(cat.key) && styles.gridLabelSelected,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{cat.label}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={styles.toggleRow}>
|
<View style={styles.toggleRow}>
|
||||||
<View>
|
<View>
|
||||||
@ -81,8 +161,9 @@ export const PreferencesScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
title="Continue"
|
title={isSubmitting ? 'Please wait…' : 'Continue'}
|
||||||
onPress={() => navigation.navigate('OnboardingCompleteScreen')}
|
onPress={handleContinue}
|
||||||
|
disabled={isSubmitting}
|
||||||
style={{ marginTop: 32 }}
|
style={{ marginTop: 32 }}
|
||||||
/>
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
59
app/features/screens/preferencesScreen/reducer.ts
Normal file
59
app/features/screens/preferencesScreen/reducer.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { createReducer } from '@reduxjs/toolkit';
|
||||||
|
import { Category } from '@interfaces';
|
||||||
|
import { fetchCategories, completeOnboard } from './thunk';
|
||||||
|
|
||||||
|
// ─── State ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PreferencesState {
|
||||||
|
categories: Category[];
|
||||||
|
selectedCategories: string[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
error: string | null;
|
||||||
|
submitError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: PreferencesState = {
|
||||||
|
categories: [],
|
||||||
|
selectedCategories: [],
|
||||||
|
isLoading: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
error: null,
|
||||||
|
submitError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Reducer ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const preferencesReducer = createReducer(initialState, builder => {
|
||||||
|
builder
|
||||||
|
// Fetch categories
|
||||||
|
.addCase(fetchCategories.pending, state => {
|
||||||
|
state.isLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchCategories.fulfilled, (state, action) => {
|
||||||
|
state.categories = action.payload;
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchCategories.rejected, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
})
|
||||||
|
|
||||||
|
// Complete onboard
|
||||||
|
.addCase(completeOnboard.pending, state => {
|
||||||
|
state.isSubmitting = true;
|
||||||
|
state.submitError = null;
|
||||||
|
})
|
||||||
|
.addCase(completeOnboard.fulfilled, state => {
|
||||||
|
state.isSubmitting = false;
|
||||||
|
state.submitError = null;
|
||||||
|
})
|
||||||
|
.addCase(completeOnboard.rejected, (state, action) => {
|
||||||
|
state.isSubmitting = false;
|
||||||
|
state.submitError = action.payload as string;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default preferencesReducer;
|
||||||
33
app/features/screens/preferencesScreen/thunk.ts
Normal file
33
app/features/screens/preferencesScreen/thunk.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import { getCatagories, onBoardComplete } from '../../../api/onboardApi';
|
||||||
|
import { onBoardPayload } from '@interfaces/onboard';
|
||||||
|
|
||||||
|
// ─── Fetch Categories ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const fetchCategories = createAsyncThunk(
|
||||||
|
'preferences/fetchCategories',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await getCatagories();
|
||||||
|
return response;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Failed to fetch categories';
|
||||||
|
return rejectWithValue(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const completeOnboard = createAsyncThunk(
|
||||||
|
'preferences/completeOnboard',
|
||||||
|
async (payload: onBoardPayload, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await onBoardComplete(payload);
|
||||||
|
return response;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Failed to complete onboard';
|
||||||
|
return rejectWithValue(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -1 +1,3 @@
|
|||||||
export * from './providerDetailsScreen';
|
export * from './providerDetailsScreen';
|
||||||
|
export { default as providerDetailsReducer } from './reducer';
|
||||||
|
export * from './thunk';
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { StyleSheet, Dimensions, Platform } from 'react-native';
|
import { StyleSheet, Dimensions, Platform } from 'react-native';
|
||||||
import { typography } from '@theme';
|
import { typography } from '@theme';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
const HERO_HEIGHT = 220;
|
const HERO_HEIGHT = height * 0.45;
|
||||||
|
|
||||||
export const getStyles = (colors: any) =>
|
export const getStyles = (colors: any) =>
|
||||||
StyleSheet.create({
|
StyleSheet.create({
|
||||||
@ -14,36 +14,49 @@ export const getStyles = (colors: any) =>
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
contentBody: {
|
contentBody: {
|
||||||
paddingBottom: 32,
|
paddingBottom: 100, // Space for sticky bottom bar
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- Hero ----------
|
// ---------- Image Carousel ----------
|
||||||
heroWrap: {
|
carouselWrap: {
|
||||||
width,
|
width,
|
||||||
height: HERO_HEIGHT,
|
height: HERO_HEIGHT,
|
||||||
backgroundColor: colors.inputBg,
|
backgroundColor: colors.background,
|
||||||
},
|
},
|
||||||
heroImage: {
|
carouselImage: {
|
||||||
width: '100%',
|
width,
|
||||||
height: '100%',
|
height: HERO_HEIGHT,
|
||||||
|
resizeMode: 'contain',
|
||||||
},
|
},
|
||||||
heroFallback: {
|
paginationDots: {
|
||||||
width: '100%',
|
position: 'absolute',
|
||||||
height: '100%',
|
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',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: colors.inputBg,
|
backgroundColor: colors.surface,
|
||||||
},
|
},
|
||||||
heroFallbackEmoji: {
|
fallbackIcon: {
|
||||||
fontSize: 56,
|
fontSize: 60,
|
||||||
},
|
|
||||||
heroOverlay: {
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: 90,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.28)',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Floating top icon row (back / share / favorite)
|
// Floating top icon row (back / share / favorite)
|
||||||
@ -55,40 +68,42 @@ export const getStyles = (colors: any) =>
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
zIndex: 10,
|
||||||
},
|
},
|
||||||
iconButton: {
|
iconButton: {
|
||||||
width: 38,
|
width: 42,
|
||||||
height: 38,
|
height: 42,
|
||||||
borderRadius: 19,
|
borderRadius: 21,
|
||||||
backgroundColor: 'rgba(255,255,255,0.92)',
|
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
...Platform.select({
|
...Platform.select({
|
||||||
ios: {
|
ios: {
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 3 },
|
||||||
shadowOpacity: 0.15,
|
shadowOpacity: 0.15,
|
||||||
shadowRadius: 4,
|
shadowRadius: 5,
|
||||||
},
|
},
|
||||||
android: { elevation: 3 },
|
android: { elevation: 4 },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
iconButtonText: {
|
iconButtonText: {
|
||||||
fontSize: 16,
|
fontSize: 18,
|
||||||
|
color: colors.text,
|
||||||
},
|
},
|
||||||
iconButtonGroup: {
|
iconButtonGroup: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- Info card ----------
|
// ---------- Product Info Box ----------
|
||||||
infoCard: {
|
infoBox: {
|
||||||
backgroundColor: colors.background,
|
backgroundColor: colors.background,
|
||||||
marginTop: -20,
|
marginTop: -20,
|
||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: 24,
|
||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: 24,
|
||||||
paddingTop: 20,
|
paddingTop: 24,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingBottom: 16,
|
paddingBottom: 20,
|
||||||
...Platform.select({
|
...Platform.select({
|
||||||
ios: {
|
ios: {
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
@ -99,247 +114,389 @@ export const getStyles = (colors: any) =>
|
|||||||
android: { elevation: 4 },
|
android: { elevation: 4 },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
infoTopRow: {
|
brandRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
},
|
},
|
||||||
heroName: {
|
brandText: {
|
||||||
fontSize: typography.fontSize.xl,
|
fontSize: typography.fontSize.xs,
|
||||||
fontWeight: typography.fontWeight.bold,
|
fontWeight: typography.fontWeight.bold,
|
||||||
color: colors.text,
|
color: colors.textSecondary,
|
||||||
flex: 1,
|
textTransform: 'uppercase',
|
||||||
marginRight: 12,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
ratingBadge: {
|
ratingBadge: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: colors.primaryMuted ?? '#E9F7EF',
|
||||||
borderRadius: 8,
|
borderRadius: 6,
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 5,
|
paddingVertical: 3,
|
||||||
},
|
},
|
||||||
ratingBadgeText: {
|
ratingBadgeText: {
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.xs,
|
||||||
fontWeight: typography.fontWeight.bold,
|
fontWeight: typography.fontWeight.bold,
|
||||||
color: '#FFFFFF',
|
color: colors.primary,
|
||||||
marginLeft: 3,
|
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,
|
fontSize: typography.fontSize.sm,
|
||||||
color: colors.textSecondary,
|
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',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
flexWrap: 'wrap',
|
||||||
marginTop: 12,
|
backgroundColor: colors.surface ?? '#F5F6F8',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
},
|
},
|
||||||
metaItem: {
|
metaItem: {
|
||||||
flexDirection: 'row',
|
width: '50%',
|
||||||
alignItems: 'center',
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
metaIcon: {
|
metaLabel: {
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
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,
|
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
metaValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- Offers ----------
|
// ---------- Sticky Bottom Bar ----------
|
||||||
offersSection: {
|
bottomBarWrap: {
|
||||||
marginTop: 18,
|
position: 'absolute',
|
||||||
},
|
left: 0,
|
||||||
offersScrollContent: {
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: colors.background,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 34 : 20,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border ?? '#ECECEC',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
offerChip: {
|
qtySelector: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderWidth: 1,
|
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,
|
borderColor: colors.border,
|
||||||
marginRight: 10,
|
borderRadius: 12,
|
||||||
backgroundColor: colors.background,
|
marginRight: 16,
|
||||||
},
|
},
|
||||||
categoryTabActive: {
|
qtyBtn: {
|
||||||
borderColor: colors.primary,
|
width: 44,
|
||||||
backgroundColor: colors.primary,
|
height: 44,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
categoryTabText: {
|
qtyBtnText: {
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: 20,
|
||||||
color: colors.textSecondary,
|
color: colors.primary,
|
||||||
fontWeight: typography.fontWeight.medium,
|
fontWeight: typography.fontWeight.medium,
|
||||||
},
|
},
|
||||||
categoryTabTextActive: {
|
qtyValue: {
|
||||||
color: '#FFFFFF',
|
fontSize: 16,
|
||||||
fontWeight: typography.fontWeight.bold,
|
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 ----------
|
// Skeleton loaders
|
||||||
menuList: {
|
skeletonTitle: {
|
||||||
paddingHorizontal: 20,
|
width: '80%',
|
||||||
|
height: 32,
|
||||||
|
backgroundColor: colors.surface ?? '#EEEEEE',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
emptyMenuWrap: {
|
skeletonPrice: {
|
||||||
alignItems: 'center',
|
width: '40%',
|
||||||
paddingVertical: 40,
|
height: 28,
|
||||||
|
backgroundColor: colors.surface ?? '#EEEEEE',
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 10,
|
||||||
},
|
},
|
||||||
emptyMenuEmoji: {
|
skeletonText: {
|
||||||
fontSize: 34,
|
width: '100%',
|
||||||
|
height: 16,
|
||||||
|
backgroundColor: colors.surface ?? '#EEEEEE',
|
||||||
|
borderRadius: 4,
|
||||||
marginBottom: 8,
|
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,
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.primary,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- Cart strip ----------
|
// Summary card (big number + stars + optional breakdown bars)
|
||||||
cartStripWrap: {
|
ratingsSummaryCard: {
|
||||||
position: 'absolute',
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
bottom: Platform.OS === 'ios' ? 28 : 16,
|
|
||||||
},
|
|
||||||
cartStrip: {
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
backgroundColor: colors.surface ?? '#F5F6F8',
|
||||||
justifyContent: 'space-between',
|
|
||||||
backgroundColor: colors.primary,
|
|
||||||
paddingHorizontal: 18,
|
|
||||||
paddingVertical: 14,
|
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
|
padding: 18,
|
||||||
|
marginBottom: 16,
|
||||||
...Platform.select({
|
...Platform.select({
|
||||||
ios: {
|
ios: {
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 6 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.25,
|
shadowOpacity: 0.04,
|
||||||
shadowRadius: 12,
|
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',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
},
|
},
|
||||||
cartBagIcon: {
|
barLabel: {
|
||||||
fontSize: 18,
|
width: 26,
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: 'right',
|
||||||
marginRight: 8,
|
marginRight: 8,
|
||||||
},
|
},
|
||||||
cartStripTextWrap: {},
|
barTrack: {
|
||||||
cartStripCount: {
|
flex: 1,
|
||||||
fontSize: typography.fontSize.xs,
|
height: 6,
|
||||||
color: 'rgba(255,255,255,0.85)',
|
borderRadius: 3,
|
||||||
|
backgroundColor: colors.border ?? '#E5E5EA',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
cartStripPrice: {
|
barFill: {
|
||||||
fontSize: typography.fontSize.md,
|
height: '100%',
|
||||||
fontWeight: typography.fontWeight.bold,
|
borderRadius: 3,
|
||||||
color: '#FFFFFF',
|
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',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: '#FFFFFF',
|
marginBottom: 10,
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 9,
|
|
||||||
borderRadius: 10,
|
|
||||||
},
|
},
|
||||||
viewCartText: {
|
reviewerAvatar: {
|
||||||
fontSize: typography.fontSize.sm,
|
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,
|
fontWeight: typography.fontWeight.bold,
|
||||||
color: colors.primary,
|
|
||||||
marginRight: 4,
|
|
||||||
},
|
},
|
||||||
viewCartArrow: {
|
reviewerName: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: colors.primary,
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 3,
|
||||||
|
},
|
||||||
|
reviewDate: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
reviewComment: {
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 19,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
|
||||||
|
skeletonBlock: {
|
||||||
|
height: 90,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: colors.surface ?? '#EEEEEE',
|
||||||
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,22 +1,28 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Image,
|
Image,
|
||||||
ImageBackground,
|
Dimensions,
|
||||||
|
NativeSyntheticEvent,
|
||||||
|
NativeScrollEvent,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
import { getStyles } from './providerDetailsScreen.styles';
|
import { getStyles } from './providerDetailsScreen.styles';
|
||||||
import { CatalogItemRow } from '@components';
|
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../store';
|
import {
|
||||||
import { addItem } from '../../../store/commonreducers/cart';
|
addToCartThunk,
|
||||||
import { getProviderCatalogApi, getProvidersApi } from '../../../api/deliveryApi';
|
updateCartItemThunk,
|
||||||
import { CatalogItem, Provider } from '../../../interfaces';
|
} from '../../../store/commonreducers/cart';
|
||||||
import { AppStackParamList } from '../../../navigation/appStack';
|
import { AppStackParamList } from '../../../navigation/appStack';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@store';
|
||||||
|
import { getProductDetailsThunk } from './thunk';
|
||||||
|
import { getDiscountPercentage, formatPrice } from '@components';
|
||||||
|
import { getFullUrl } from '@utils';
|
||||||
|
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
|
||||||
type ProviderDetailsNavProp = StackNavigationProp<
|
type ProviderDetailsNavProp = StackNavigationProp<
|
||||||
AppStackParamList,
|
AppStackParamList,
|
||||||
@ -27,215 +33,19 @@ type ProviderDetailsRouteProp = RouteProp<
|
|||||||
'ProviderDetailsScreen'
|
'ProviderDetailsScreen'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const FALLBACK_CATEGORIES = ['Pizza', 'Sides', 'Beverages'];
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
const OFFERS = [
|
// TODO: move these into your shared Product type once the backend
|
||||||
{ key: 'o1', icon: '🏷️', label: '50% OFF up to ₹100' },
|
// response includes them.
|
||||||
{ key: 'o2', icon: '🚚', label: 'Free delivery above ₹299' },
|
// interface ProductReview {
|
||||||
{ key: 'o3', icon: '🎁', label: 'Buy 1 Get 1 on combos' },
|
// id: string;
|
||||||
];
|
// userName?: string;
|
||||||
|
// rating: number;
|
||||||
|
// comment?: string;
|
||||||
|
// createdAt?: string;
|
||||||
|
// }
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
type RatingBreakdown = Partial<Record<1 | 2 | 3 | 4 | 5, number>>;
|
||||||
// Presentational subcomponents
|
|
||||||
// Kept local to this screen since none are reused elsewhere yet. Promote to
|
|
||||||
// @components if a second screen needs them.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface HeroSectionProps {
|
|
||||||
styles: ReturnType<typeof getStyles>;
|
|
||||||
imageUrl?: string;
|
|
||||||
onBack: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HeroSection: React.FC<HeroSectionProps> = ({
|
|
||||||
styles,
|
|
||||||
imageUrl,
|
|
||||||
onBack,
|
|
||||||
}) => (
|
|
||||||
<View style={styles.heroWrap}>
|
|
||||||
{imageUrl ? (
|
|
||||||
<ImageBackground source={{ uri: imageUrl }} style={styles.heroImage}>
|
|
||||||
<View style={styles.heroOverlay} />
|
|
||||||
</ImageBackground>
|
|
||||||
) : (
|
|
||||||
<View style={styles.heroFallback}>
|
|
||||||
<Text style={styles.heroFallbackEmoji}>🍽️</Text>
|
|
||||||
<View style={styles.heroOverlay} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={styles.topIconRow}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.iconButton}
|
|
||||||
activeOpacity={0.75}
|
|
||||||
onPress={onBack}
|
|
||||||
>
|
|
||||||
<Text style={styles.iconButtonText}>←</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={styles.iconButtonGroup}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.iconButton, { marginRight: 10 }]}
|
|
||||||
activeOpacity={0.75}
|
|
||||||
>
|
|
||||||
<Text style={styles.iconButtonText}>🔗</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity style={styles.iconButton} activeOpacity={0.75}>
|
|
||||||
<Text style={styles.iconButtonText}>🤍</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
interface InfoCardProps {
|
|
||||||
styles: ReturnType<typeof getStyles>;
|
|
||||||
name: string;
|
|
||||||
rating: number;
|
|
||||||
deliveryTime: string;
|
|
||||||
tag: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const InfoCard: React.FC<InfoCardProps> = ({
|
|
||||||
styles,
|
|
||||||
name,
|
|
||||||
rating,
|
|
||||||
deliveryTime,
|
|
||||||
tag,
|
|
||||||
}) => (
|
|
||||||
<View style={styles.infoCard}>
|
|
||||||
<View style={styles.infoTopRow}>
|
|
||||||
<Text style={styles.heroName} numberOfLines={1}>
|
|
||||||
{name}
|
|
||||||
</Text>
|
|
||||||
<View style={styles.ratingBadge}>
|
|
||||||
<Text style={{ fontSize: 11 }}>⭐</Text>
|
|
||||||
<Text style={styles.ratingBadgeText}>{rating.toFixed(1)}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text style={styles.cuisineText}>{tag} • Multi-cuisine</Text>
|
|
||||||
|
|
||||||
<View style={styles.metaRow}>
|
|
||||||
<View style={styles.metaItem}>
|
|
||||||
<Text style={styles.metaIcon}>🕐</Text>
|
|
||||||
<Text style={styles.metaText}>{deliveryTime}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.metaDivider} />
|
|
||||||
<View style={styles.metaItem}>
|
|
||||||
<Text style={styles.metaIcon}>📍</Text>
|
|
||||||
<Text style={styles.metaText}>2.4 km away</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.statusRow}>
|
|
||||||
<View style={styles.statusDot} />
|
|
||||||
<Text style={styles.statusText}>Open now</Text>
|
|
||||||
<Text style={styles.statusTextMuted}> • Closes 11:30 PM</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
interface OffersRowProps {
|
|
||||||
styles: ReturnType<typeof getStyles>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OffersRow: React.FC<OffersRowProps> = ({ styles }) => (
|
|
||||||
<View style={styles.offersSection}>
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
contentContainerStyle={styles.offersScrollContent}
|
|
||||||
>
|
|
||||||
{OFFERS.map(offer => (
|
|
||||||
<View key={offer.key} style={styles.offerChip}>
|
|
||||||
<Text style={styles.offerIcon}>{offer.icon}</Text>
|
|
||||||
<Text style={styles.offerText}>{offer.label}</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
interface CategoryTabsProps {
|
|
||||||
styles: ReturnType<typeof getStyles>;
|
|
||||||
categories: string[];
|
|
||||||
activeCategory: string;
|
|
||||||
onSelect: (category: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
|
||||||
styles,
|
|
||||||
categories,
|
|
||||||
activeCategory,
|
|
||||||
onSelect,
|
|
||||||
}) => (
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
contentContainerStyle={styles.categoryRow}
|
|
||||||
>
|
|
||||||
{categories.map(cat => {
|
|
||||||
const isActive = activeCategory === cat;
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={cat}
|
|
||||||
style={[styles.categoryTab, isActive && styles.categoryTabActive]}
|
|
||||||
onPress={() => onSelect(cat)}
|
|
||||||
activeOpacity={0.75}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.categoryTabText,
|
|
||||||
isActive && styles.categoryTabTextActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{cat}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
|
|
||||||
interface CartStripProps {
|
|
||||||
styles: ReturnType<typeof getStyles>;
|
|
||||||
totalItems: number;
|
|
||||||
totalPrice: number;
|
|
||||||
onPress: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CartStrip: React.FC<CartStripProps> = ({
|
|
||||||
styles,
|
|
||||||
totalItems,
|
|
||||||
totalPrice,
|
|
||||||
onPress,
|
|
||||||
}) => (
|
|
||||||
<View style={styles.cartStripWrap}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.cartStrip}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
onPress={onPress}
|
|
||||||
>
|
|
||||||
<View style={styles.cartStripLeft}>
|
|
||||||
<Text style={styles.cartBagIcon}>🛍️</Text>
|
|
||||||
<View style={styles.cartStripTextWrap}>
|
|
||||||
<Text style={styles.cartStripCount}>
|
|
||||||
{totalItems} item{totalItems === 1 ? '' : 's'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.cartStripPrice}>₹{totalPrice}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={styles.viewCartButton}>
|
|
||||||
<Text style={styles.viewCartText}>View Cart</Text>
|
|
||||||
<Text style={styles.viewCartArrow}>→</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Screen
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const ProviderDetailsScreen: React.FC = () => {
|
export const ProviderDetailsScreen: React.FC = () => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
@ -243,65 +53,312 @@ export const ProviderDetailsScreen: React.FC = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigation = useNavigation<ProviderDetailsNavProp>();
|
const navigation = useNavigation<ProviderDetailsNavProp>();
|
||||||
const route = useRoute<ProviderDetailsRouteProp>();
|
const route = useRoute<ProviderDetailsRouteProp>();
|
||||||
const { providerId, providerName } = route.params;
|
|
||||||
|
// Notice we still use providerId param name to avoid breaking routing everywhere
|
||||||
|
const { providerId } = route.params;
|
||||||
|
|
||||||
const cartItems = useAppSelector(state => state.cart.items);
|
const cartItems = useAppSelector(state => state.cart.items);
|
||||||
|
const { product, isLoading } = useAppSelector(state => state.providerDetails);
|
||||||
|
|
||||||
const [provider, setProvider] = useState<Provider | null>(null);
|
// Find this product in the cart (if already added)
|
||||||
const [catalog, setCatalog] = useState<CatalogItem[]>([]);
|
const cartItem = product
|
||||||
const [activeCategory, setActiveCategory] = useState(FALLBACK_CATEGORIES[0]);
|
? cartItems.find(i => i.productId === product.id)
|
||||||
|
: undefined;
|
||||||
|
const isInCart = !!cartItem;
|
||||||
|
|
||||||
const resolvedProviderId = providerId || 'p1';
|
const [activeImageIndex, setActiveImageIndex] = useState(0);
|
||||||
const resolvedProviderName = providerName || 'Pizza Planet';
|
const [localQty, setLocalQty] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getProviderCatalogApi(resolvedProviderId).then(setCatalog);
|
if (providerId) {
|
||||||
}, [resolvedProviderId]);
|
dispatch(getProductDetailsThunk(providerId));
|
||||||
|
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
}, [categories, activeCategory]);
|
}, [providerId, dispatch]);
|
||||||
|
|
||||||
const filteredItems = useMemo(
|
// Sync localQty with cart quantity when cart loads or product changes
|
||||||
() => catalog.filter(item => item.category === activeCategory),
|
useEffect(() => {
|
||||||
[catalog, activeCategory],
|
if (cartItem) {
|
||||||
);
|
setLocalQty(cartItem.quantity);
|
||||||
|
} else {
|
||||||
|
setLocalQty(0);
|
||||||
|
}
|
||||||
|
}, [cartItem?.quantity, cartItem?.productId]);
|
||||||
|
|
||||||
const getItemQuantity = (itemId: string) => {
|
const handleScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
const match = cartItems.find(i => i.item.id === itemId);
|
const slide = Math.round(e.nativeEvent.contentOffset.x / width);
|
||||||
return match ? match.quantity : 0;
|
setActiveImageIndex(slide);
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalItems = cartItems.reduce((sum, i) => sum + i.quantity, 0);
|
const handleAddToCart = () => {
|
||||||
const totalPrice = cartItems.reduce(
|
if (!product) return;
|
||||||
(sum, i) => sum + i.item.price * i.quantity,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddItem = (item: CatalogItem) => {
|
|
||||||
dispatch(
|
dispatch(
|
||||||
addItem({
|
addToCartThunk({
|
||||||
providerId: resolvedProviderId,
|
productId: product.id,
|
||||||
providerName: resolvedProviderName,
|
quantity: Math.max(1, localQty), // always add at least 1
|
||||||
item,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleIncrement = () => {
|
||||||
|
if (!product) return;
|
||||||
|
const newQty = localQty + 1;
|
||||||
|
dispatch(updateCartItemThunk({ productId: product.id, quantity: newQty })).unwrap().then(() => setLocalQty(newQty));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDecrement = () => {
|
||||||
|
if (!product || localQty <= 1) return;
|
||||||
|
const newQty = localQty - 1;
|
||||||
|
setLocalQty(newQty);
|
||||||
|
dispatch(updateCartItemThunk({ productId: product.id, quantity: newQty }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// const handleRateProduct = () => {
|
||||||
|
// navigation.navigate('WriteReviewScreen', { productId: product?.id ?? '' });
|
||||||
|
// };
|
||||||
|
|
||||||
|
const renderStars = (value: number, size = 14) => {
|
||||||
|
const rounded = Math.round(value);
|
||||||
|
const starColor = (colors as any).warning ?? '#F5A623';
|
||||||
|
return (
|
||||||
|
<View style={styles.starsRow}>
|
||||||
|
{[1, 2, 3, 4, 5].map(i => (
|
||||||
|
<Text
|
||||||
|
key={i}
|
||||||
|
style={[styles.starIcon, { fontSize: size, color: starColor }]}
|
||||||
|
>
|
||||||
|
{i <= rounded ? '★' : '☆'}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderImages = () => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.carouselWrap}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.fallbackIconWrap,
|
||||||
|
{ backgroundColor: (colors as any).surface ?? colors.cardBg },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = product?.media?.length
|
||||||
|
? product.media
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: 'fallback',
|
||||||
|
url: product?.imageUrl || '',
|
||||||
|
mediaType: 'IMAGE',
|
||||||
|
sortOrder: 0,
|
||||||
|
productId: '',
|
||||||
|
createdAt: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.carouselWrap}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
pagingEnabled
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
onMomentumScrollEnd={handleScroll}
|
||||||
|
>
|
||||||
|
{images.map(img => (
|
||||||
|
<Image
|
||||||
|
key={img.id}
|
||||||
|
source={{ uri: getFullUrl(img.url) }}
|
||||||
|
style={styles.carouselImage}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
{images.length > 1 && (
|
||||||
|
<View style={styles.paginationDots}>
|
||||||
|
{images.map((_, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={[styles.dot, i === activeImageIndex && styles.dotActive]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderProductInfo = () => {
|
||||||
|
if (isLoading || !product) {
|
||||||
|
return (
|
||||||
|
<View style={styles.infoBox}>
|
||||||
|
<View style={styles.skeletonTitle} />
|
||||||
|
<View style={styles.skeletonPrice} />
|
||||||
|
<View style={styles.skeletonText} />
|
||||||
|
<View style={styles.skeletonText} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const discount = getDiscountPercentage(
|
||||||
|
product.price,
|
||||||
|
product.compareAtPrice,
|
||||||
|
);
|
||||||
|
const currency = product.currency === 'INR' ? '₹' : product.currency;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.infoBox}>
|
||||||
|
<View style={styles.brandRow}>
|
||||||
|
<Text style={styles.brandText}>
|
||||||
|
{product.brand || product.merchant?.name || 'BRAND'}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.ratingBadge}>
|
||||||
|
<Text style={{ fontSize: 11 }}>⭐</Text>
|
||||||
|
<Text style={styles.ratingBadgeText}>
|
||||||
|
{parseFloat(product.avgRating).toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.titleText}>{product.name}</Text>
|
||||||
|
|
||||||
|
<View style={styles.priceRow}>
|
||||||
|
<Text style={styles.priceText}>
|
||||||
|
{formatPrice(product.price, currency)}
|
||||||
|
</Text>
|
||||||
|
{product.compareAtPrice &&
|
||||||
|
parseFloat(product.compareAtPrice) > parseFloat(product.price) && (
|
||||||
|
<Text style={styles.comparePriceText}>
|
||||||
|
{formatPrice(product.compareAtPrice, currency)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{discount && (
|
||||||
|
<View style={styles.discountBadgeWrap}>
|
||||||
|
<Text style={styles.discountBadgeText}>{discount}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.taxText}>Inclusive of all taxes</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRatingsSection = () => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.ratingsSection}>
|
||||||
|
<View style={styles.skeletonBlock} />
|
||||||
|
<View style={[styles.skeletonBlock, { height: 80 }]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgRating = product?.avgRating ? parseFloat(product.avgRating) : 0;
|
||||||
|
const reviews = product?.recentRatings ?? [];
|
||||||
|
const totalRatings = reviews.length;
|
||||||
|
const breakdown: RatingBreakdown | undefined = (product as any)
|
||||||
|
?.ratingBreakdown;
|
||||||
|
const hasRatings = totalRatings > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.ratingsSection}>
|
||||||
|
<View style={styles.ratingsHeaderRow}>
|
||||||
|
<Text style={styles.sectionTitle}>Ratings & Reviews</Text>
|
||||||
|
{reviews.length > 3 && (
|
||||||
|
<TouchableOpacity activeOpacity={0.7}>
|
||||||
|
<Text style={styles.seeAllText}>See all</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{hasRatings ? (
|
||||||
|
<View style={styles.ratingsSummaryCard}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.ratingsSummaryLeft,
|
||||||
|
breakdown && styles.ratingsSummaryLeftWithDivider,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.avgRatingNumber}>{avgRating.toFixed(1)}</Text>
|
||||||
|
{renderStars(avgRating, 16)}
|
||||||
|
<Text style={styles.totalRatingsText}>
|
||||||
|
{totalRatings} {totalRatings === 1 ? 'rating' : 'ratings'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{breakdown && (
|
||||||
|
<View style={styles.ratingsBarsWrap}>
|
||||||
|
{[5, 4, 3, 2, 1].map(star => {
|
||||||
|
const count = breakdown[star as 1 | 2 | 3 | 4 | 5] ?? 0;
|
||||||
|
const pct =
|
||||||
|
totalRatings > 0 ? (count / totalRatings) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<View key={star} style={styles.barRow}>
|
||||||
|
<Text style={styles.barLabel}>{star}★</Text>
|
||||||
|
<View style={styles.barTrack}>
|
||||||
|
<View style={[styles.barFill, { width: `${pct}%` }]} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyRatingsCard}>
|
||||||
|
<Text style={styles.emptyRatingsIcon}>⭐</Text>
|
||||||
|
<Text style={styles.emptyRatingsTitle}>No ratings yet</Text>
|
||||||
|
{/* <Text style={styles.emptyRatingsSubtitle}>
|
||||||
|
Be the first to share what you think of this product.
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.rateProductBtn}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
onPress={handleRateProduct}
|
||||||
|
>
|
||||||
|
<Text style={styles.rateProductBtnText}>Rate this product</Text>
|
||||||
|
</TouchableOpacity> */}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reviews.length > 0 && (
|
||||||
|
<View style={styles.reviewsList}>
|
||||||
|
{reviews.slice(0, 3).map(review => (
|
||||||
|
<View key={review.id} style={styles.reviewCard}>
|
||||||
|
<View style={styles.reviewCardHeader}>
|
||||||
|
<View style={styles.reviewerAvatar}>
|
||||||
|
<Text style={styles.reviewerAvatarText}>
|
||||||
|
{(review?.user?.name || 'U').charAt(0).toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.reviewerName}>
|
||||||
|
{review?.user?.name || 'Anonymous'}
|
||||||
|
</Text>
|
||||||
|
{renderStars(review.rating, 12)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.reviewDate}>
|
||||||
|
{review.createdAt
|
||||||
|
? new Date(review.createdAt).toLocaleDateString('en-IN', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
})
|
||||||
|
: ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{!!review.review && (
|
||||||
|
<Text style={styles.reviewComment}>{review.review}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@ -309,69 +366,147 @@ export const ProviderDetailsScreen: React.FC = () => {
|
|||||||
contentContainerStyle={styles.contentBody}
|
contentContainerStyle={styles.contentBody}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<HeroSection
|
<View style={{ position: 'relative' }}>
|
||||||
styles={styles}
|
{renderImages()}
|
||||||
imageUrl={provider?.imageUrl}
|
|
||||||
onBack={() => navigation.goBack()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InfoCard
|
<View style={styles.topIconRow}>
|
||||||
styles={styles}
|
<TouchableOpacity
|
||||||
name={resolvedProviderName}
|
style={styles.iconButton}
|
||||||
rating={provider?.rating ?? 4.5}
|
activeOpacity={0.8}
|
||||||
deliveryTime={provider?.deliveryTime ?? '25 min'}
|
onPress={() => navigation.goBack()}
|
||||||
tag={provider?.tag ?? 'Italian'}
|
>
|
||||||
/>
|
<MaterialIcons name="arrow-back" size={24} color="black" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={styles.iconButtonGroup}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.iconButton, { marginRight: 12 }]}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.iconButtonText}>🔗</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.iconButton} activeOpacity={0.8}>
|
||||||
|
<Text style={{ fontSize: 17 }}>🤍</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<OffersRow styles={styles} />
|
{renderProductInfo()}
|
||||||
|
|
||||||
<View style={styles.sectionDivider} />
|
<View style={styles.sectionDivider} />
|
||||||
|
|
||||||
<View style={styles.menuHeaderRow}>
|
<View style={styles.detailsSection}>
|
||||||
<Text style={styles.menuTitle}>Menu</Text>
|
<Text style={styles.sectionTitle}>Product Details</Text>
|
||||||
<Text style={styles.menuCount}>{catalog.length} items</Text>
|
<Text style={styles.descriptionText}>
|
||||||
</View>
|
{product?.description ||
|
||||||
|
'No description available for this product.'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<CategoryTabs
|
<View style={styles.metaGrid}>
|
||||||
styles={styles}
|
<View style={styles.metaItem}>
|
||||||
categories={categories}
|
<Text style={styles.metaLabel}>Category</Text>
|
||||||
activeCategory={activeCategory}
|
<Text style={styles.metaValue}>
|
||||||
onSelect={setActiveCategory}
|
{product?.category?.name || 'N/A'}
|
||||||
/>
|
|
||||||
|
|
||||||
<View style={styles.menuList}>
|
|
||||||
{filteredItems.length === 0 && (
|
|
||||||
<View style={styles.emptyMenuWrap}>
|
|
||||||
<Text style={styles.emptyMenuEmoji}>🍽️</Text>
|
|
||||||
<Text style={styles.emptyMenuText}>
|
|
||||||
No items in this category yet
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
<View style={styles.metaItem}>
|
||||||
|
<Text style={styles.metaLabel}>SKU</Text>
|
||||||
{filteredItems.map(item => (
|
<Text style={styles.metaValue}>{product?.sku || 'N/A'}</Text>
|
||||||
<CatalogItemRow
|
|
||||||
key={item.id}
|
|
||||||
imageUrl={item.imageUrl}
|
|
||||||
title={item.title}
|
|
||||||
description={item.description}
|
|
||||||
price={item.price}
|
|
||||||
quantity={getItemQuantity(item.id)}
|
|
||||||
onAdd={() => handleAddItem(item)}
|
|
||||||
onRemove={() => {}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
|
<View style={styles.metaItem}>
|
||||||
|
<Text style={styles.metaLabel}>Stock</Text>
|
||||||
|
<Text style={styles.metaValue}>
|
||||||
|
{product?.stockQuantity ? 'In Stock' : 'Out of Stock'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metaItem}>
|
||||||
|
<Text style={styles.metaLabel}>Merchant</Text>
|
||||||
|
<Text style={styles.metaValue}>
|
||||||
|
{product?.merchant?.name || 'N/A'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.sectionDivider} />
|
||||||
|
|
||||||
|
{renderRatingsSection()}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{totalItems > 0 && (
|
{/* Sticky Bottom Bar */}
|
||||||
<CartStrip
|
<View style={styles.bottomBarWrap}>
|
||||||
styles={styles}
|
{isInCart ? (
|
||||||
totalItems={totalItems}
|
// ── Already in cart: show inline qty stepper + Go to Cart ──
|
||||||
totalPrice={totalPrice}
|
<>
|
||||||
|
<View style={styles.qtySelector}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.qtyBtn}
|
||||||
|
onPress={handleDecrement}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
disabled={localQty <= 1}
|
||||||
|
>
|
||||||
|
<Text style={styles.qtyBtnText}>-</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.qtyValue}>{localQty}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.qtyBtn}
|
||||||
|
onPress={handleIncrement}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.qtyBtnText}>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.addBtn}
|
||||||
|
activeOpacity={0.8}
|
||||||
onPress={() => navigation.navigate('CartScreen')}
|
onPress={() => navigation.navigate('CartScreen')}
|
||||||
/>
|
>
|
||||||
|
<Text style={styles.addBtnText}>Go to Cart</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// ── Not in cart: qty picker + Add to Cart ──
|
||||||
|
<>
|
||||||
|
<View style={styles.qtySelector}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.qtyBtn}
|
||||||
|
onPress={() => setLocalQty(q => Math.max(0, q - 1))}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
disabled={localQty <= 0}
|
||||||
|
>
|
||||||
|
<Text style={styles.qtyBtnText}>-</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.qtyValue}>{localQty}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.qtyBtn}
|
||||||
|
onPress={handleAddToCart}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.qtyBtnText}>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.addBtn}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
onPress={handleAddToCart}
|
||||||
|
disabled={
|
||||||
|
!product ||
|
||||||
|
(product.isTrackStock && product.stockQuantity === 0)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text style={styles.addBtnText}>
|
||||||
|
{!product
|
||||||
|
? 'Loading...'
|
||||||
|
: product.isTrackStock && product.stockQuantity === 0
|
||||||
|
? 'Out of Stock'
|
||||||
|
: 'Add to Cart'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
33
app/features/screens/providerDetailsScreen/reducer.ts
Normal file
33
app/features/screens/providerDetailsScreen/reducer.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { createReducer } from '@reduxjs/toolkit';
|
||||||
|
import { Product } from '@interfaces';
|
||||||
|
import { getProductDetailsThunk } from './thunk';
|
||||||
|
|
||||||
|
export interface ProviderDetailsState {
|
||||||
|
product: Product | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ProviderDetailsState = {
|
||||||
|
product: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerDetailsReducer = createReducer(initialState, builder => {
|
||||||
|
builder
|
||||||
|
.addCase(getProductDetailsThunk.pending, state => {
|
||||||
|
state.isLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(getProductDetailsThunk.fulfilled, (state, action) => {
|
||||||
|
state.product = action.payload;
|
||||||
|
state.isLoading = false;
|
||||||
|
})
|
||||||
|
.addCase(getProductDetailsThunk.rejected, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = action.payload || 'Failed to fetch product details';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default providerDetailsReducer;
|
||||||
18
app/features/screens/providerDetailsScreen/thunk.ts
Normal file
18
app/features/screens/providerDetailsScreen/thunk.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import { getProductDetailsApi } from '@api';
|
||||||
|
import { Product } from '@interfaces';
|
||||||
|
|
||||||
|
export const getProductDetailsThunk = createAsyncThunk<
|
||||||
|
Product,
|
||||||
|
string,
|
||||||
|
{ rejectValue: string }
|
||||||
|
>('providerDetails/getProductDetails', async (productId, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await getProductDetailsApi(productId);
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(
|
||||||
|
error.response?.data?.message || 'Failed to fetch product details',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
41
app/features/screens/searchScreen/reducer.ts
Normal file
41
app/features/screens/searchScreen/reducer.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { createReducer } from '@reduxjs/toolkit';
|
||||||
|
import { Products } from '@interfaces';
|
||||||
|
import { getProductsByCategoryThunk } from './thunk';
|
||||||
|
|
||||||
|
export interface SearchState {
|
||||||
|
products: Products[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
selectedCategoryId: string | undefined;
|
||||||
|
selectedCategoryName: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: SearchState = {
|
||||||
|
products: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
selectedCategoryId: undefined,
|
||||||
|
selectedCategoryName: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchReducer = createReducer(initialState, builder => {
|
||||||
|
builder
|
||||||
|
.addCase(getProductsByCategoryThunk.pending, (state, action) => {
|
||||||
|
state.isLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
// Store the categoryId that was requested so the UI can track it
|
||||||
|
state.selectedCategoryId = action.meta.arg;
|
||||||
|
})
|
||||||
|
.addCase(getProductsByCategoryThunk.fulfilled, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.products =
|
||||||
|
action.payload?.products ||
|
||||||
|
(Array.isArray(action.payload) ? action.payload : []);
|
||||||
|
})
|
||||||
|
.addCase(getProductsByCategoryThunk.rejected, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = action.payload || 'Failed to fetch products';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default searchReducer;
|
||||||
@ -1,27 +1,194 @@
|
|||||||
import { StyleSheet } from 'react-native';
|
import { StyleSheet, Dimensions, Platform } from 'react-native';
|
||||||
import { typography } from '@theme';
|
import { typography } from '@theme';
|
||||||
|
|
||||||
export const getStyles = (colors: any) => StyleSheet.create({
|
const { width } = Dimensions.get('window');
|
||||||
|
const CARD_WIDTH = (width - 48) / 2; // 2-col grid with 16px side padding + 16px gap
|
||||||
|
|
||||||
|
export const getStyles = (colors: any) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
// ---------- Root ----------
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Header ----------
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 14,
|
||||||
|
paddingBottom: 12,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border ?? '#ECECEC',
|
||||||
|
},
|
||||||
|
backBtn: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: colors.surface ?? '#F5F6F8',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
backBtnText: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: typography.fontSize.lg,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
headerCount: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Search Bar wrapper ----------
|
||||||
|
searchWrap: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Category chips ----------
|
||||||
|
chipsContainer: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border ?? '#ECECEC',
|
||||||
|
},
|
||||||
|
chipsList: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
marginRight: 10,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
},
|
||||||
|
chipActive: {
|
||||||
|
backgroundColor: colors.primaryMuted ?? '#E9F7EF',
|
||||||
|
borderColor: colors.primary ?? '#05824C',
|
||||||
|
},
|
||||||
|
chipInactive: {
|
||||||
|
backgroundColor: colors.surface ?? '#F5F6F8',
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
chipEmoji: {
|
||||||
|
fontSize: 14,
|
||||||
|
marginRight: 5,
|
||||||
|
},
|
||||||
|
chipLabel: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
},
|
||||||
|
chipLabelActive: {
|
||||||
|
color: colors.primary ?? '#05824C',
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
},
|
||||||
|
chipLabelInactive: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Section header ----------
|
||||||
|
sectionHeader: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
sectionSubtitle: {
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Product grid ----------
|
||||||
|
gridContent: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
|
paddingBottom: 32,
|
||||||
},
|
},
|
||||||
list: {
|
row: {
|
||||||
paddingBottom: 24,
|
justifyContent: 'space-between',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---------- Skeleton cards ----------
|
||||||
|
skeletonCard: {
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
height: CARD_WIDTH + 80,
|
||||||
|
backgroundColor: colors.surface ?? '#F0F0F0',
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
skeletonImageBlock: {
|
||||||
|
width: '100%',
|
||||||
|
height: CARD_WIDTH,
|
||||||
|
backgroundColor: colors.border ?? '#E8E8E8',
|
||||||
|
},
|
||||||
|
skeletonTextBlock: {
|
||||||
|
marginTop: 8,
|
||||||
|
marginHorizontal: 10,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: colors.border ?? '#E8E8E8',
|
||||||
|
},
|
||||||
|
skeletonTextBlockShort: {
|
||||||
|
marginTop: 6,
|
||||||
|
marginHorizontal: 10,
|
||||||
|
width: '55%',
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
backgroundColor: colors.border ?? '#E8E8E8',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Empty state ----------
|
||||||
empty: {
|
empty: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingTop: 60,
|
paddingTop: 80,
|
||||||
},
|
|
||||||
emptyIcon: {
|
|
||||||
fontSize: 48,
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
emptyText: {
|
|
||||||
fontSize: typography.fontSize.md,
|
|
||||||
color: colors.textSecondary,
|
|
||||||
textAlign: 'center',
|
|
||||||
paddingHorizontal: 40,
|
paddingHorizontal: 40,
|
||||||
},
|
},
|
||||||
|
emptyIcon: {
|
||||||
|
fontSize: 52,
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
fontSize: typography.fontSize.lg,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Error state ----------
|
||||||
|
errorWrap: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: 80,
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
},
|
||||||
|
errorIcon: {
|
||||||
|
fontSize: 40,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.error ?? '#E53935',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,59 +1,191 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
FlatList,
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
Animated,
|
||||||
|
ScrollView,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation, CompositeNavigationProp } from '@react-navigation/native';
|
import {
|
||||||
|
useNavigation,
|
||||||
|
useRoute,
|
||||||
|
RouteProp,
|
||||||
|
CompositeNavigationProp,
|
||||||
|
} from '@react-navigation/native';
|
||||||
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
import { getStyles } from './searchScreen.styles';
|
import { getStyles } from './searchScreen.styles';
|
||||||
import { SearchBar, ProviderCard } from '@components';
|
import { SearchBar, ProductCard } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { searchProvidersApi } from '../../../api/deliveryApi';
|
|
||||||
import { Provider } from '../../../interfaces';
|
|
||||||
import { AppStackParamList } from '../../../navigation/appStack';
|
import { AppStackParamList } from '../../../navigation/appStack';
|
||||||
import { MainTabParamList } from '../../../navigation/mainTabNavigator';
|
import { MainTabParamList } from '../../../navigation/mainTabNavigator';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@store';
|
||||||
|
import { getProductsByCategoryThunk } from './thunk';
|
||||||
|
import { Products } from '@interfaces';
|
||||||
|
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type NavProp = CompositeNavigationProp<
|
type NavProp = CompositeNavigationProp<
|
||||||
BottomTabNavigationProp<MainTabParamList, 'SearchScreen'>,
|
BottomTabNavigationProp<MainTabParamList, 'SearchScreen'>,
|
||||||
StackNavigationProp<AppStackParamList>
|
StackNavigationProp<AppStackParamList>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
type SearchRouteProp = RouteProp<MainTabParamList, 'SearchScreen'>;
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const getCategoryEmoji = (name: string): string => {
|
||||||
|
const n = name.toLowerCase();
|
||||||
|
if (n.includes('food')) return '🍔';
|
||||||
|
if (n.includes('grocer')) return '🛒';
|
||||||
|
if (n.includes('pharma') || n.includes('medic')) return '💊';
|
||||||
|
if (n.includes('meat')) return '🥩';
|
||||||
|
if (n.includes('flower')) return '💐';
|
||||||
|
if (n.includes('electronic')) return '📱';
|
||||||
|
if (n.includes('cloth') || n.includes('fashion')) return '👗';
|
||||||
|
if (n.includes('bakery') || n.includes('cake')) return '🎂';
|
||||||
|
return '📦';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const SearchScreen: React.FC = () => {
|
export const SearchScreen: React.FC = () => {
|
||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
const styles = getStyles(colors);
|
const styles = getStyles(colors);
|
||||||
const navigation = useNavigation<NavProp>();
|
const navigation = useNavigation<NavProp>();
|
||||||
|
const route = useRoute<SearchRouteProp>();
|
||||||
|
|
||||||
const [query, setQuery] = useState('');
|
const dispatch = useAppDispatch();
|
||||||
const [results, setResults] = useState<Provider[]>([]);
|
|
||||||
|
// Redux state
|
||||||
|
const { products, isLoading, error } = useAppSelector(
|
||||||
|
state => state.search,
|
||||||
|
);
|
||||||
|
const { categories } = useAppSelector(state => state.home);
|
||||||
|
|
||||||
|
// Route params (set when navigating from HomeScreen)
|
||||||
|
const routeCategoryId = route.params?.categoryId;
|
||||||
|
const routeCategoryName = route.params?.categoryName;
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const [activeCategoryId, setActiveCategoryId] = useState<string | undefined>(
|
||||||
|
routeCategoryId,
|
||||||
|
);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Skeleton shimmer animation
|
||||||
|
const shimmer = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.trim().length > 0) {
|
if (isLoading) {
|
||||||
searchProvidersApi(query).then(setResults);
|
Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(shimmer, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 900,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(shimmer, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 900,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
).start();
|
||||||
} else {
|
} else {
|
||||||
setResults([]);
|
shimmer.stopAnimation();
|
||||||
}
|
}
|
||||||
}, [query]);
|
}, [isLoading, shimmer]);
|
||||||
|
|
||||||
|
// Sync when route params change (e.g. user taps a different chip on HomeScreen)
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveCategoryId(routeCategoryId);
|
||||||
|
setSearchQuery('');
|
||||||
|
}, [routeCategoryId]);
|
||||||
|
|
||||||
|
// Fetch products whenever active category changes
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(getProductsByCategoryThunk(activeCategoryId));
|
||||||
|
}, [activeCategoryId, dispatch]);
|
||||||
|
|
||||||
|
// Client-side text filter on top of the fetched product set
|
||||||
|
const filteredProducts: Products[] = products.filter(p =>
|
||||||
|
searchQuery.trim().length === 0
|
||||||
|
? true
|
||||||
|
: p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
(p.brand ?? '').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Category chips: prepend "All"
|
||||||
|
const allChip = {
|
||||||
|
id: 'all' as const,
|
||||||
|
name: 'All',
|
||||||
|
slug: 'all',
|
||||||
|
imageUrl: null,
|
||||||
|
isActive: true,
|
||||||
|
parentId: null,
|
||||||
|
};
|
||||||
|
const chipCategories = [allChip, ...categories];
|
||||||
|
|
||||||
|
const headerCategoryName =
|
||||||
|
activeCategoryId === undefined || activeCategoryId === 'all'
|
||||||
|
? 'All Products'
|
||||||
|
: routeCategoryName ?? 'Products';
|
||||||
|
|
||||||
|
// ─── Sub-renders ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const shimmerOpacity = shimmer.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0.4, 1],
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderSkeletons = () => (
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: 8 }}>
|
||||||
|
{[0, 1, 2, 3].map(i => (
|
||||||
|
<Animated.View key={i} style={[styles.skeletonCard, { opacity: shimmerOpacity }]}>
|
||||||
|
<View style={styles.skeletonImageBlock} />
|
||||||
|
<View style={styles.skeletonTextBlock} />
|
||||||
|
<View style={styles.skeletonTextBlockShort} />
|
||||||
|
</Animated.View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderEmpty = useCallback(() => {
|
||||||
|
if (isLoading) return null;
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.empty}>
|
||||||
<SearchBar
|
<Text style={styles.emptyIcon}>🔍</Text>
|
||||||
value={query}
|
<Text style={styles.emptyTitle}>No products found</Text>
|
||||||
onChangeText={setQuery}
|
<Text style={styles.emptyText}>
|
||||||
placeholder="Search providers or items..."
|
{searchQuery.trim().length > 0
|
||||||
/>
|
? `No results for "${searchQuery}" in ${headerCategoryName}`
|
||||||
<FlatList
|
: `No products available in ${headerCategoryName} right now`}
|
||||||
data={results}
|
</Text>
|
||||||
keyExtractor={(item) => item.id}
|
</View>
|
||||||
renderItem={({ item }) => (
|
);
|
||||||
<ProviderCard
|
}, [isLoading, searchQuery, headerCategoryName, styles]);
|
||||||
|
|
||||||
|
const renderError = () => (
|
||||||
|
<View style={styles.errorWrap}>
|
||||||
|
<Text style={styles.errorIcon}>⚠️</Text>
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderProductItem = useCallback(
|
||||||
|
({ item }: { item: Products }) => (
|
||||||
|
<ProductCard
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
imageUrl={item.imageUrl}
|
imageUrl={item.imageUrl}
|
||||||
name={item.name}
|
name={item.name}
|
||||||
rating={item.rating}
|
price={item.price}
|
||||||
deliveryTime={item.deliveryTime}
|
compareAtPrice={item.compareAtPrice}
|
||||||
tag={item.tag}
|
brand={item.brand || item.merchant?.name}
|
||||||
discountText={item.discountText}
|
currency={item.currency === 'INR' ? '₹' : item.currency}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
navigation.navigate('ProviderDetailsScreen', {
|
navigation.navigate('ProviderDetailsScreen', {
|
||||||
providerId: item.id,
|
providerId: item.id,
|
||||||
@ -61,21 +193,104 @@ export const SearchScreen: React.FC = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
),
|
||||||
|
[navigation],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backBtn}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="arrow-back" size={24} color="black" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||||
|
{headerCategoryName}
|
||||||
|
</Text>
|
||||||
|
{!isLoading && (
|
||||||
|
<Text style={styles.headerCount}>
|
||||||
|
{filteredProducts.length} item{filteredProducts.length !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
ListEmptyComponent={
|
|
||||||
query.trim().length > 0 ? (
|
|
||||||
<View style={styles.empty}>
|
|
||||||
<Text style={styles.emptyText}>No results found</Text>
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
|
||||||
<View style={styles.empty}>
|
{/* Search Bar */}
|
||||||
<Text style={styles.emptyIcon}>🔍</Text>
|
<View style={styles.searchWrap}>
|
||||||
<Text style={styles.emptyText}>Search for food, groceries, medicines...</Text>
|
<SearchBar
|
||||||
</View>
|
value={searchQuery}
|
||||||
)
|
onChangeText={setSearchQuery}
|
||||||
}
|
placeholder={`Search in ${headerCategoryName}...`}
|
||||||
contentContainerStyle={styles.list}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Category chips */}
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<View style={styles.chipsContainer}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.chipsList}
|
||||||
|
>
|
||||||
|
{chipCategories.map(cat => {
|
||||||
|
const isActive =
|
||||||
|
cat.id === 'all'
|
||||||
|
? activeCategoryId === undefined || activeCategoryId === 'all'
|
||||||
|
: activeCategoryId === cat.id;
|
||||||
|
const emoji = cat.id === 'all' ? '🏠' : getCategoryEmoji(cat.name);
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={cat.id}
|
||||||
|
style={[styles.chip, isActive ? styles.chipActive : styles.chipInactive]}
|
||||||
|
activeOpacity={0.75}
|
||||||
|
onPress={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setActiveCategoryId(cat.id === 'all' ? undefined : cat.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.chipEmoji}>{emoji}</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.chipLabel,
|
||||||
|
isActive ? styles.chipLabelActive : styles.chipLabelInactive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && !isLoading && renderError()}
|
||||||
|
|
||||||
|
{/* Loading skeleton */}
|
||||||
|
{isLoading && renderSkeletons()}
|
||||||
|
|
||||||
|
{/* Product grid */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<FlatList
|
||||||
|
data={filteredProducts}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
numColumns={2}
|
||||||
|
columnWrapperStyle={styles.row}
|
||||||
|
renderItem={renderProductItem}
|
||||||
|
ListEmptyComponent={renderEmpty}
|
||||||
|
contentContainerStyle={styles.gridContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
initialNumToRender={8}
|
||||||
|
maxToRenderPerBatch={10}
|
||||||
|
windowSize={5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
20
app/features/screens/searchScreen/thunk.ts
Normal file
20
app/features/screens/searchScreen/thunk.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import { getProductsApi, GetProductsResponse } from '@api';
|
||||||
|
|
||||||
|
export const getProductsByCategoryThunk = createAsyncThunk<
|
||||||
|
GetProductsResponse,
|
||||||
|
string | undefined,
|
||||||
|
{ rejectValue: string }
|
||||||
|
>(
|
||||||
|
'search/getProductsByCategory',
|
||||||
|
async (categoryId, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await getProductsApi(categoryId);
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
return rejectWithValue(
|
||||||
|
error?.response?.data?.message || 'Failed to fetch products',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user