diff --git a/app/api/index.ts b/app/api/index.ts index fdf96c5..54cf825 100644 --- a/app/api/index.ts +++ b/app/api/index.ts @@ -7,3 +7,4 @@ export * from './paymentMethodsApi'; export * from './customerDetailsApi'; export * from './orderApi'; export * from './offerApi'; +export * from './reviewApi'; diff --git a/app/api/reviewApi.ts b/app/api/reviewApi.ts index e69de29..652c871 100644 --- a/app/api/reviewApi.ts +++ b/app/api/reviewApi.ts @@ -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; +}; diff --git a/app/components/index.ts b/app/components/index.ts index 63fbf59..b3c29b2 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -15,4 +15,4 @@ export * from './orderHistoryCard'; export * from './paymentOption'; export * from './OrderStatusTimeline'; export * from './TrackingMap'; - +export * from './productRatingModal'; diff --git a/app/components/productRatingModal/index.ts b/app/components/productRatingModal/index.ts new file mode 100644 index 0000000..a41cc55 --- /dev/null +++ b/app/components/productRatingModal/index.ts @@ -0,0 +1 @@ +export * from './productRatingModal'; diff --git a/app/components/productRatingModal/productRatingModal.styles.ts b/app/components/productRatingModal/productRatingModal.styles.ts new file mode 100644 index 0000000..4c10e32 --- /dev/null +++ b/app/components/productRatingModal/productRatingModal.styles.ts @@ -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, + }, + }); diff --git a/app/components/productRatingModal/productRatingModal.tsx b/app/components/productRatingModal/productRatingModal.tsx new file mode 100644 index 0000000..f3b8554 --- /dev/null +++ b/app/components/productRatingModal/productRatingModal.tsx @@ -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 = { + 1: 'Terrible 😞', + 2: 'Bad 😕', + 3: 'Okay 😐', + 4: 'Good 😊', + 5: 'Excellent 🤩', +}; + +const MAX_COMMENT = 300; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export const ProductRatingModal: React.FC = ({ + 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([]); // 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 ( + + + {/* Backdrop — tap to dismiss */} + + + + + {/* Drag handle */} + + + {/* Product hero */} + + 🛍️ + + {productName} + + {quantity !== undefined && ( + Qty: {quantity} + )} + + + {/* Already-submitted banner */} + {submitted ? ( + <> + + + + Thanks for your rating! + + + + {/* Show submitted stars (read-only) */} + + + + + + Done + + + ) : ( + + {/* Star rating */} + + Your Rating + + + {rating > 0 + ? RATING_LABELS[rating] + : 'Tap a star to rate'} + + + + + + {/* Comment (optional) */} + + + Review{' '} + (optional) + + setComment(t.slice(0, MAX_COMMENT))} + multiline + onFocus={() => setCommentFocused(true)} + onBlur={() => setCommentFocused(false)} + returnKeyType="done" + blurOnSubmit + /> + + {comment.length}/{MAX_COMMENT} + + + + {/* Image upload strip (optional) */} + + + Photos{' '} + (optional) + + + {/* Add photo button */} + + 📷 + Add + + + {/* Thumbnail previews */} + {imageUris.map((uri, i) => ( + + + + + + + ))} + + + + {/* Submit */} + + + Submit Review + + + + )} + + + + + + + ); +}; diff --git a/app/features/screens/orderDetailsScreen/index.ts b/app/features/screens/orderDetailsScreen/index.ts index b54c20b..7644a4e 100644 --- a/app/features/screens/orderDetailsScreen/index.ts +++ b/app/features/screens/orderDetailsScreen/index.ts @@ -1 +1,3 @@ export * from './orderDetailsScreen'; +export * from './thunk'; +export * from './reducer'; diff --git a/app/features/screens/orderDetailsScreen/orderDetailsScreen.styles.ts b/app/features/screens/orderDetailsScreen/orderDetailsScreen.styles.ts index c43d2c2..59a5d04 100644 --- a/app/features/screens/orderDetailsScreen/orderDetailsScreen.styles.ts +++ b/app/features/screens/orderDetailsScreen/orderDetailsScreen.styles.ts @@ -191,4 +191,65 @@ export const getStyles = (colors: any) => 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, + }, }); diff --git a/app/features/screens/orderDetailsScreen/orderDetailsScreen.tsx b/app/features/screens/orderDetailsScreen/orderDetailsScreen.tsx index 1a2a985..9b5b637 100644 --- a/app/features/screens/orderDetailsScreen/orderDetailsScreen.tsx +++ b/app/features/screens/orderDetailsScreen/orderDetailsScreen.tsx @@ -1,15 +1,23 @@ -import React, { useEffect } from 'react'; -import { View, Text, ScrollView, ActivityIndicator } from 'react-native'; +import React, { useEffect, useState, useCallback } from 'react'; +import { + View, + Text, + ScrollView, + ActivityIndicator, + TouchableOpacity, +} 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 } from '@components'; +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 { AppStackParamList } from '../../../navigation/appStack'; +import { submitReview } from './thunk'; +import { giveRatingPayload, OrderItem } from '@interfaces'; +import { AppStackParamList } from '@navigation'; type OrderDetailsRouteProp = RouteProp; type OrderDetailsNavProp = StackNavigationProp; @@ -26,12 +34,53 @@ export const OrderDetailsScreen: React.FC = () => { const { orderDetails, orderDetailsLoading, orderDetailsError } = useAppSelector((state: RootState) => state.paymentMethods); + // ── Rating modal state ──────────────────────────────────────────────────── + const [ratingModalItem, setRatingModalItem] = useState( + null, + ); + /** Tracks which productIds have already been rated this session */ + const [ratedProductIds, setRatedProductIds] = useState>( + new Set(), + ); + + const isDelivered = orderDetails?.status === 'DELIVERED'; + 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 ( @@ -105,17 +154,61 @@ export const OrderDetailsScreen: React.FC = () => { {/* Order Items Section */} Order Items - {orderDetails.orderItems?.map((item, index) => ( - - - - {item.quantity}x - - {item.name} - - ₹{item.totalAmount} + + {/* Rate all hint — shown only when delivered */} + {isDelivered && ( + + + + Tap any item below to rate it + - ))} + )} + + {orderDetails.orderItems?.map((item, index) => { + const isRated = ratedProductIds.has(item.productId); + return ( + { + if (isDelivered) { + setRatingModalItem(item); + } + }} + disabled={!isDelivered} + > + + + + {item.quantity}x + + + + {item.name} + {/* Rating status pill — only shown when DELIVERED */} + {isDelivered && ( + + {isRated ? ( + + ✓ Rated + + ) : ( + + + ⭐ Rate this + + + )} + + )} + + + ₹{item.totalAmount} + + ); + })} {/* Bill Summary Section */} @@ -184,6 +277,17 @@ export const OrderDetailsScreen: React.FC = () => { )} + + {/* Product Rating Modal */} + ); }; diff --git a/app/features/screens/orderDetailsScreen/reducer.ts b/app/features/screens/orderDetailsScreen/reducer.ts new file mode 100644 index 0000000..5515c19 --- /dev/null +++ b/app/features/screens/orderDetailsScreen/reducer.ts @@ -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; + }); +}); diff --git a/app/features/screens/orderDetailsScreen/thunk.ts b/app/features/screens/orderDetailsScreen/thunk.ts new file mode 100644 index 0000000..8d62cd2 --- /dev/null +++ b/app/features/screens/orderDetailsScreen/thunk.ts @@ -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); + } + }, +); diff --git a/app/features/screens/providerDetailsScreen/providerDetailsScreen.tsx b/app/features/screens/providerDetailsScreen/providerDetailsScreen.tsx index e54727e..f374032 100644 --- a/app/features/screens/providerDetailsScreen/providerDetailsScreen.tsx +++ b/app/features/screens/providerDetailsScreen/providerDetailsScreen.tsx @@ -36,13 +36,13 @@ const { width } = Dimensions.get('window'); // TODO: move these into your shared Product type once the backend // response includes them. -interface ProductReview { - id: string; - userName?: string; - rating: number; - comment?: string; - createdAt?: string; -} +// interface ProductReview { +// id: string; +// userName?: string; +// rating: number; +// comment?: string; +// createdAt?: string; +// } type RatingBreakdown = Partial>; @@ -256,9 +256,8 @@ export const ProviderDetailsScreen: React.FC = () => { } const avgRating = product?.avgRating ? parseFloat(product.avgRating) : 0; - const reviews: ProductReview[] = (product as any)?.reviews ?? []; - const totalRatings: number = - (product as any)?.ratingsCount ?? reviews.length ?? 0; + const reviews = product?.recentRatings ?? []; + const totalRatings = reviews.length; const breakdown: RatingBreakdown | undefined = (product as any) ?.ratingBreakdown; const hasRatings = totalRatings > 0; @@ -331,12 +330,12 @@ export const ProviderDetailsScreen: React.FC = () => { - {(review.userName || 'U').charAt(0).toUpperCase()} + {(review?.user?.name || 'U').charAt(0).toUpperCase()} - {review.userName || 'Anonymous'} + {review?.user?.name || 'Anonymous'} {renderStars(review.rating, 12)} @@ -349,8 +348,8 @@ export const ProviderDetailsScreen: React.FC = () => { : ''} - {!!review.comment && ( - {review.comment} + {!!review.review && ( + {review.review} )} ))} diff --git a/app/interfaces/index.ts b/app/interfaces/index.ts index ab3c6ca..0df0dbe 100644 --- a/app/interfaces/index.ts +++ b/app/interfaces/index.ts @@ -97,3 +97,4 @@ export * from './paymentMethods'; export * from './customerProfile'; export * from './order'; export * from './offers'; +export * from './productRating'; diff --git a/app/interfaces/product.ts b/app/interfaces/product.ts index ccdb526..a5c1e52 100644 --- a/app/interfaces/product.ts +++ b/app/interfaces/product.ts @@ -128,6 +128,11 @@ export interface ProductRating { rating: number; review: string; createdAt: string; + user: { + id: string; + name: string; + profileImage: string; + }; } export enum ProductType { diff --git a/app/interfaces/productRating.ts b/app/interfaces/productRating.ts new file mode 100644 index 0000000..765d6dd --- /dev/null +++ b/app/interfaces/productRating.ts @@ -0,0 +1,6 @@ +export interface giveRatingPayload { + rating: number; + comment?: string; + images?: string[]; + orderId: string; +} diff --git a/app/navigation/index.ts b/app/navigation/index.ts new file mode 100644 index 0000000..1495cde --- /dev/null +++ b/app/navigation/index.ts @@ -0,0 +1,5 @@ +export * from './appStack'; +export * from './mainTabNavigator'; +export * from './rootNavigator'; +export * from './authStack'; +export * from './onboardingStack'; diff --git a/app/store/rootReducer.ts b/app/store/rootReducer.ts index ba33e4f..021ece0 100644 --- a/app/store/rootReducer.ts +++ b/app/store/rootReducer.ts @@ -11,7 +11,7 @@ import providerDetailsReducer from '@features/screens/providerDetailsScreen/redu import paymentMethodsReducer from '@features/screens/checkoutPaymentScreen/reducer'; import customerProfileReducer from './commonreducers/customerProfile/reducer'; import { offerReducer } from './commonreducers/offer'; -import { accountReducer } from '@features/screens'; +import { accountReducer, orderDetailsReducer } from '@features/screens'; import searchReducer from '@features/screens/searchScreen/reducer'; const rootReducer = combineReducers({ @@ -28,6 +28,7 @@ const rootReducer = combineReducers({ offer: offerReducer, account: accountReducer, search: searchReducer, + orderDetails: orderDetailsReducer, }); export type RootState = ReturnType;