289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
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>
|
||
);
|
||
};
|