feat: implement order details screen and product review submission functionality
This commit is contained in:
parent
b6af3c8d24
commit
bed28f2310
@ -7,3 +7,4 @@ export * from './paymentMethodsApi';
|
||||
export * from './customerDetailsApi';
|
||||
export * from './orderApi';
|
||||
export * from './offerApi';
|
||||
export * from './reviewApi';
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -15,4 +15,4 @@ export * from './orderHistoryCard';
|
||||
export * from './paymentOption';
|
||||
export * from './OrderStatusTimeline';
|
||||
export * from './TrackingMap';
|
||||
|
||||
export * from './productRatingModal';
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -1 +1,3 @@
|
||||
export * from './orderDetailsScreen';
|
||||
export * from './thunk';
|
||||
export * from './reducer';
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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<AppStackParamList, 'OrderDetailsScreen'>;
|
||||
type OrderDetailsNavProp = StackNavigationProp<AppStackParamList>;
|
||||
@ -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<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';
|
||||
|
||||
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}>
|
||||
@ -105,17 +154,61 @@ export const OrderDetailsScreen: React.FC = () => {
|
||||
{/* Order Items Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Order Items</Text>
|
||||
{orderDetails.orderItems?.map((item, index) => (
|
||||
<View key={item.id || index} style={styles.itemRow}>
|
||||
<View style={styles.itemInfo}>
|
||||
<View style={styles.itemQuantityBadge}>
|
||||
<Text style={styles.itemQuantityText}>{item.quantity}x</Text>
|
||||
</View>
|
||||
<Text style={styles.itemName}>{item.name}</Text>
|
||||
</View>
|
||||
<Text style={styles.itemPrice}>₹{item.totalAmount}</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 */}
|
||||
@ -184,6 +277,17 @@ export const OrderDetailsScreen: React.FC = () => {
|
||||
</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}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -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<Record<1 | 2 | 3 | 4 | 5, number>>;
|
||||
|
||||
@ -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 = () => {
|
||||
<View style={styles.reviewCardHeader}>
|
||||
<View style={styles.reviewerAvatar}>
|
||||
<Text style={styles.reviewerAvatarText}>
|
||||
{(review.userName || 'U').charAt(0).toUpperCase()}
|
||||
{(review?.user?.name || 'U').charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.reviewerName}>
|
||||
{review.userName || 'Anonymous'}
|
||||
{review?.user?.name || 'Anonymous'}
|
||||
</Text>
|
||||
{renderStars(review.rating, 12)}
|
||||
</View>
|
||||
@ -349,8 +348,8 @@ export const ProviderDetailsScreen: React.FC = () => {
|
||||
: ''}
|
||||
</Text>
|
||||
</View>
|
||||
{!!review.comment && (
|
||||
<Text style={styles.reviewComment}>{review.comment}</Text>
|
||||
{!!review.review && (
|
||||
<Text style={styles.reviewComment}>{review.review}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
|
||||
@ -97,3 +97,4 @@ export * from './paymentMethods';
|
||||
export * from './customerProfile';
|
||||
export * from './order';
|
||||
export * from './offers';
|
||||
export * from './productRating';
|
||||
|
||||
@ -128,6 +128,11 @@ export interface ProductRating {
|
||||
rating: number;
|
||||
review: string;
|
||||
createdAt: string;
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
profileImage: string;
|
||||
};
|
||||
}
|
||||
|
||||
export enum ProductType {
|
||||
|
||||
6
app/interfaces/productRating.ts
Normal file
6
app/interfaces/productRating.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface giveRatingPayload {
|
||||
rating: number;
|
||||
comment?: string;
|
||||
images?: string[];
|
||||
orderId: string;
|
||||
}
|
||||
5
app/navigation/index.ts
Normal file
5
app/navigation/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './appStack';
|
||||
export * from './mainTabNavigator';
|
||||
export * from './rootNavigator';
|
||||
export * from './authStack';
|
||||
export * from './onboardingStack';
|
||||
@ -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<typeof rootReducer>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user