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 './customerDetailsApi';
|
||||||
export * from './orderApi';
|
export * from './orderApi';
|
||||||
export * from './offerApi';
|
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 './paymentOption';
|
||||||
export * from './OrderStatusTimeline';
|
export * from './OrderStatusTimeline';
|
||||||
export * from './TrackingMap';
|
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 './orderDetailsScreen';
|
||||||
|
export * from './thunk';
|
||||||
|
export * from './reducer';
|
||||||
|
|||||||
@ -191,4 +191,65 @@ export const getStyles = (colors: any) =>
|
|||||||
color: colors.text,
|
color: colors.text,
|
||||||
fontWeight: typography.fontWeight.medium,
|
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 React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { View, Text, ScrollView, ActivityIndicator } from 'react-native';
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
ActivityIndicator,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from 'react-native';
|
||||||
import { useRoute, useNavigation } from '@react-navigation/native';
|
import { useRoute, useNavigation } from '@react-navigation/native';
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
import { RouteProp } from '@react-navigation/native';
|
import { RouteProp } from '@react-navigation/native';
|
||||||
import { Header, OrderStatusTimeline } from '@components';
|
import { Header, OrderStatusTimeline, ProductRatingModal } from '@components';
|
||||||
import { useAppTheme } from '@theme';
|
import { useAppTheme } from '@theme';
|
||||||
import { getStyles } from './orderDetailsScreen.styles';
|
import { getStyles } from './orderDetailsScreen.styles';
|
||||||
import { RootState, useAppDispatch, useAppSelector } from '@store';
|
import { RootState, useAppDispatch, useAppSelector } from '@store';
|
||||||
import { getOrderByIdThunk } from '../checkoutPaymentScreen';
|
import { getOrderByIdThunk } from '../checkoutPaymentScreen';
|
||||||
import { formatDate, formatTime } from '@utils/helper';
|
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 OrderDetailsRouteProp = RouteProp<AppStackParamList, 'OrderDetailsScreen'>;
|
||||||
type OrderDetailsNavProp = StackNavigationProp<AppStackParamList>;
|
type OrderDetailsNavProp = StackNavigationProp<AppStackParamList>;
|
||||||
@ -26,12 +34,53 @@ export const OrderDetailsScreen: React.FC = () => {
|
|||||||
const { orderDetails, orderDetailsLoading, orderDetailsError } =
|
const { orderDetails, orderDetailsLoading, orderDetailsError } =
|
||||||
useAppSelector((state: RootState) => state.paymentMethods);
|
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(() => {
|
useEffect(() => {
|
||||||
if (orderId) {
|
if (orderId) {
|
||||||
dispatch(getOrderByIdThunk(orderId));
|
dispatch(getOrderByIdThunk(orderId));
|
||||||
}
|
}
|
||||||
}, [dispatch, 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) {
|
if (orderDetailsLoading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@ -105,17 +154,61 @@ export const OrderDetailsScreen: React.FC = () => {
|
|||||||
{/* Order Items Section */}
|
{/* Order Items Section */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Order Items</Text>
|
<Text style={styles.sectionTitle}>Order Items</Text>
|
||||||
{orderDetails.orderItems?.map((item, index) => (
|
|
||||||
<View key={item.id || index} style={styles.itemRow}>
|
{/* 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.itemInfo}>
|
||||||
<View style={styles.itemQuantityBadge}>
|
<View style={styles.itemQuantityBadge}>
|
||||||
<Text style={styles.itemQuantityText}>{item.quantity}x</Text>
|
<Text style={styles.itemQuantityText}>
|
||||||
|
{item.quantity}x
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.itemName}>{item.name}</Text>
|
<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>
|
</View>
|
||||||
<Text style={styles.itemPrice}>₹{item.totalAmount}</Text>
|
<Text style={styles.itemPrice}>₹{item.totalAmount}</Text>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Bill Summary Section */}
|
{/* Bill Summary Section */}
|
||||||
@ -184,6 +277,17 @@ export const OrderDetailsScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</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>
|
</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
|
// TODO: move these into your shared Product type once the backend
|
||||||
// response includes them.
|
// response includes them.
|
||||||
interface ProductReview {
|
// interface ProductReview {
|
||||||
id: string;
|
// id: string;
|
||||||
userName?: string;
|
// userName?: string;
|
||||||
rating: number;
|
// rating: number;
|
||||||
comment?: string;
|
// comment?: string;
|
||||||
createdAt?: string;
|
// createdAt?: string;
|
||||||
}
|
// }
|
||||||
|
|
||||||
type RatingBreakdown = Partial<Record<1 | 2 | 3 | 4 | 5, number>>;
|
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 avgRating = product?.avgRating ? parseFloat(product.avgRating) : 0;
|
||||||
const reviews: ProductReview[] = (product as any)?.reviews ?? [];
|
const reviews = product?.recentRatings ?? [];
|
||||||
const totalRatings: number =
|
const totalRatings = reviews.length;
|
||||||
(product as any)?.ratingsCount ?? reviews.length ?? 0;
|
|
||||||
const breakdown: RatingBreakdown | undefined = (product as any)
|
const breakdown: RatingBreakdown | undefined = (product as any)
|
||||||
?.ratingBreakdown;
|
?.ratingBreakdown;
|
||||||
const hasRatings = totalRatings > 0;
|
const hasRatings = totalRatings > 0;
|
||||||
@ -331,12 +330,12 @@ export const ProviderDetailsScreen: React.FC = () => {
|
|||||||
<View style={styles.reviewCardHeader}>
|
<View style={styles.reviewCardHeader}>
|
||||||
<View style={styles.reviewerAvatar}>
|
<View style={styles.reviewerAvatar}>
|
||||||
<Text style={styles.reviewerAvatarText}>
|
<Text style={styles.reviewerAvatarText}>
|
||||||
{(review.userName || 'U').charAt(0).toUpperCase()}
|
{(review?.user?.name || 'U').charAt(0).toUpperCase()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.reviewerName}>
|
<Text style={styles.reviewerName}>
|
||||||
{review.userName || 'Anonymous'}
|
{review?.user?.name || 'Anonymous'}
|
||||||
</Text>
|
</Text>
|
||||||
{renderStars(review.rating, 12)}
|
{renderStars(review.rating, 12)}
|
||||||
</View>
|
</View>
|
||||||
@ -349,8 +348,8 @@ export const ProviderDetailsScreen: React.FC = () => {
|
|||||||
: ''}
|
: ''}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{!!review.comment && (
|
{!!review.review && (
|
||||||
<Text style={styles.reviewComment}>{review.comment}</Text>
|
<Text style={styles.reviewComment}>{review.review}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -97,3 +97,4 @@ export * from './paymentMethods';
|
|||||||
export * from './customerProfile';
|
export * from './customerProfile';
|
||||||
export * from './order';
|
export * from './order';
|
||||||
export * from './offers';
|
export * from './offers';
|
||||||
|
export * from './productRating';
|
||||||
|
|||||||
@ -128,6 +128,11 @@ export interface ProductRating {
|
|||||||
rating: number;
|
rating: number;
|
||||||
review: string;
|
review: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
profileImage: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ProductType {
|
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 paymentMethodsReducer from '@features/screens/checkoutPaymentScreen/reducer';
|
||||||
import customerProfileReducer from './commonreducers/customerProfile/reducer';
|
import customerProfileReducer from './commonreducers/customerProfile/reducer';
|
||||||
import { offerReducer } from './commonreducers/offer';
|
import { offerReducer } from './commonreducers/offer';
|
||||||
import { accountReducer } from '@features/screens';
|
import { accountReducer, orderDetailsReducer } from '@features/screens';
|
||||||
import searchReducer from '@features/screens/searchScreen/reducer';
|
import searchReducer from '@features/screens/searchScreen/reducer';
|
||||||
|
|
||||||
const rootReducer = combineReducers({
|
const rootReducer = combineReducers({
|
||||||
@ -28,6 +28,7 @@ const rootReducer = combineReducers({
|
|||||||
offer: offerReducer,
|
offer: offerReducer,
|
||||||
account: accountReducer,
|
account: accountReducer,
|
||||||
search: searchReducer,
|
search: searchReducer,
|
||||||
|
orderDetails: orderDetailsReducer,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RootState = ReturnType<typeof rootReducer>;
|
export type RootState = ReturnType<typeof rootReducer>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user