579 lines
23 KiB
TypeScript
579 lines
23 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
View,
|
||
Text,
|
||
ScrollView,
|
||
TouchableOpacity,
|
||
Image,
|
||
TextInput,
|
||
LayoutAnimation,
|
||
Platform,
|
||
UIManager,
|
||
Animated,
|
||
StatusBar,
|
||
} from 'react-native';
|
||
import { useNavigation } from '@react-navigation/native';
|
||
import { StackNavigationProp } from '@react-navigation/stack';
|
||
import { getStyles } from './cartScreen.styles';
|
||
import { Header, QuantitySelector, PrimaryButton } from '@components';
|
||
import { useAppTheme } from '@theme';
|
||
import {
|
||
selectCartTotal,
|
||
getCartThunk,
|
||
updateCartItemThunk,
|
||
} from '../../../store/commonreducers/cart';
|
||
import { AppStackParamList } from '../../../navigation/appStack';
|
||
import { useAppDispatch, useAppSelector } from '@store';
|
||
import { getFullUrl } from '@utils';
|
||
|
||
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||
}
|
||
|
||
type CartScreenNavProp = StackNavigationProp<AppStackParamList, 'CartScreen'>;
|
||
|
||
const TIP_OPTIONS = [20, 30, 50, 100];
|
||
|
||
// ─── Skeleton shimmer ──────────────────────────────────────────────────────────
|
||
const SkeletonBlock: React.FC<{ width?: number | string; height?: number; borderRadius?: number; colors: any }> = ({
|
||
width = '100%',
|
||
height = 16,
|
||
borderRadius = 8,
|
||
colors,
|
||
}) => {
|
||
const shimmer = useRef(new Animated.Value(0)).current;
|
||
|
||
useEffect(() => {
|
||
const loop = Animated.loop(
|
||
Animated.sequence([
|
||
Animated.timing(shimmer, { toValue: 1, duration: 900, useNativeDriver: true }),
|
||
Animated.timing(shimmer, { toValue: 0, duration: 900, useNativeDriver: true }),
|
||
]),
|
||
);
|
||
loop.start();
|
||
return () => loop.stop();
|
||
}, [shimmer]);
|
||
|
||
const opacity = shimmer.interpolate({ inputRange: [0, 1], outputRange: [0.3, 0.7] });
|
||
|
||
return (
|
||
<Animated.View
|
||
style={{
|
||
width: width as any,
|
||
height,
|
||
borderRadius,
|
||
backgroundColor: colors.border,
|
||
opacity,
|
||
marginBottom: 8,
|
||
}}
|
||
/>
|
||
);
|
||
};
|
||
|
||
// ─── Savings pill ──────────────────────────────────────────────────────────────
|
||
const SavingsPill: React.FC<{ label: string; styles: any }> = ({ label, styles }) => (
|
||
<View style={styles.savingsPill}>
|
||
<Text style={styles.savingsPillEmoji}>🎉</Text>
|
||
<Text style={styles.savingsPillText}>{label}</Text>
|
||
</View>
|
||
);
|
||
|
||
// ─── Section Header ────────────────────────────────────────────────────────────
|
||
const SectionTitle: React.FC<{ label: string; styles: any }> = ({ label, styles }) => (
|
||
<Text style={styles.sectionTitle}>{label}</Text>
|
||
);
|
||
|
||
// ─── Main Component ────────────────────────────────────────────────────────────
|
||
export const CartScreen: React.FC = () => {
|
||
const { colors, isDarkMode } = useAppTheme();
|
||
const styles = getStyles(colors);
|
||
const dispatch = useAppDispatch();
|
||
const navigation = useNavigation<CartScreenNavProp>();
|
||
const { items, isLoading } = useAppSelector(state => state.cart);
|
||
const totals = useAppSelector(selectCartTotal);
|
||
|
||
const [instructions, setInstructions] = useState('');
|
||
const [showInstructionsInput, setShowInstructionsInput] = useState(false);
|
||
const [selectedTip, setSelectedTip] = useState<number | null>(null);
|
||
const [couponInput, setCouponInput] = useState('');
|
||
const [billExpanded, setBillExpanded] = useState(false);
|
||
|
||
// Animated values
|
||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||
const slideAnim = useRef(new Animated.Value(30)).current;
|
||
const bottomPanelAnim = useRef(new Animated.Value(100)).current;
|
||
|
||
useEffect(() => {
|
||
dispatch(getCartThunk());
|
||
}, [dispatch]);
|
||
|
||
useEffect(() => {
|
||
if (!isLoading) {
|
||
Animated.parallel([
|
||
Animated.timing(fadeAnim, { toValue: 1, duration: 400, useNativeDriver: true }),
|
||
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, tension: 80, friction: 10 }),
|
||
Animated.spring(bottomPanelAnim, { toValue: 0, useNativeDriver: true, tension: 80, friction: 12 }),
|
||
]).start();
|
||
}
|
||
}, [isLoading, fadeAnim, slideAnim, bottomPanelAnim]);
|
||
|
||
const itemCount = useMemo(
|
||
() => items.reduce((sum, item) => sum + item.quantity, 0),
|
||
[items],
|
||
);
|
||
|
||
const tipAmount = selectedTip ?? 0;
|
||
const grandTotal = (totals.total ?? 0) + tipAmount;
|
||
|
||
const toggleBillExpanded = () => {
|
||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||
setBillExpanded(prev => !prev);
|
||
};
|
||
|
||
// ─── Empty state ──────────────────────────────────────────────────────────────
|
||
if (!isLoading && items.length === 0) {
|
||
return (
|
||
<View style={styles.container}>
|
||
<StatusBar
|
||
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
|
||
backgroundColor={colors.background}
|
||
/>
|
||
<Header title="My Cart" onBack={() => navigation.goBack()} />
|
||
<View style={styles.emptyWrap}>
|
||
<View style={styles.emptyIllustration}>
|
||
<Text style={styles.emptyEmoji}>🛒</Text>
|
||
</View>
|
||
<Text style={styles.emptyTitle}>Your cart is empty</Text>
|
||
<Text style={styles.emptySubtitle}>
|
||
Looks like you haven't added anything yet.{'\n'}Browse and find something you'll love!
|
||
</Text>
|
||
<TouchableOpacity
|
||
style={styles.emptyButton}
|
||
activeOpacity={0.85}
|
||
onPress={() => navigation.goBack()}
|
||
>
|
||
<Text style={styles.emptyButtonText}>Browse Menu</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ─── Loading skeleton ─────────────────────────────────────────────────────────
|
||
if (isLoading) {
|
||
return (
|
||
<View style={styles.container}>
|
||
<StatusBar
|
||
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
|
||
backgroundColor={colors.background}
|
||
/>
|
||
<Header title="My Cart" onBack={() => navigation.goBack()} />
|
||
<ScrollView contentContainerStyle={styles.list} showsVerticalScrollIndicator={false}>
|
||
<View style={[styles.skeletonCard, { marginTop: 12 }]}>
|
||
{[1, 2, 3].map(i => (
|
||
<View key={i} style={styles.skeletonItemRow}>
|
||
<SkeletonBlock width={56} height={56} borderRadius={12} colors={colors} />
|
||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||
<SkeletonBlock width="80%" height={14} colors={colors} />
|
||
<SkeletonBlock width="40%" height={12} colors={colors} />
|
||
</View>
|
||
<SkeletonBlock width={80} height={32} borderRadius={8} colors={colors} />
|
||
</View>
|
||
))}
|
||
</View>
|
||
<View style={styles.skeletonCard}>
|
||
<SkeletonBlock height={60} colors={colors} />
|
||
</View>
|
||
<View style={styles.skeletonCard}>
|
||
<SkeletonBlock height={120} colors={colors} />
|
||
</View>
|
||
</ScrollView>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ─── Main Cart View ───────────────────────────────────────────────────────────
|
||
return (
|
||
<View style={styles.container}>
|
||
<StatusBar
|
||
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
|
||
backgroundColor={colors.background}
|
||
/>
|
||
<Header title="My Cart" onBack={() => navigation.goBack()} />
|
||
|
||
<Animated.View
|
||
style={[{ flex: 1 }, { opacity: fadeAnim, transform: [{ translateY: slideAnim }] }]}
|
||
>
|
||
<ScrollView
|
||
contentContainerStyle={styles.list}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
{/* ── ETA Banner ─────────────────────────────────────────── */}
|
||
<View style={styles.etaBanner}>
|
||
<View style={styles.etaLeft}>
|
||
<View style={styles.etaIconWrap}>
|
||
<Text style={styles.etaIcon}>🛵</Text>
|
||
</View>
|
||
<View>
|
||
<Text style={styles.etaText}>Delivery in 20–25 mins</Text>
|
||
<Text style={styles.etaSubtext}>Order arrives fresh & hot 🔥</Text>
|
||
</View>
|
||
</View>
|
||
<View style={styles.etaBadge}>
|
||
<Text style={styles.etaBadgeText}>FASTEST</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* ── Items Card ─────────────────────────────────────────── */}
|
||
<View style={styles.itemsCard}>
|
||
<View style={styles.itemsCardHeader}>
|
||
<Text style={styles.itemsCardHeaderText}>
|
||
{itemCount} {itemCount === 1 ? 'item' : 'items'} in your cart
|
||
</Text>
|
||
</View>
|
||
|
||
{items.map((item, index) => (
|
||
<View
|
||
key={item.id}
|
||
style={[
|
||
styles.cartItem,
|
||
index < items.length - 1 && styles.cartItemDivider,
|
||
]}
|
||
>
|
||
{/* Veg / Non-veg indicator */}
|
||
{/* {typeof item.product === 'boolean' && (
|
||
<View
|
||
style={[
|
||
styles.vegIndicator,
|
||
{ borderColor: item.product ? '#2E7D32' : '#C62828' },
|
||
]}
|
||
>
|
||
<View
|
||
style={[
|
||
styles.vegDot,
|
||
{ backgroundColor: item.product ? '#2E7D32' : '#C62828' },
|
||
]}
|
||
/>
|
||
</View>
|
||
)} */}
|
||
|
||
{/* Thumbnail */}
|
||
<View style={styles.itemThumbWrap}>
|
||
<Image
|
||
style={styles.itemThumb}
|
||
source={{ uri: getFullUrl(item.product.imageUrl) }}
|
||
resizeMode="cover"
|
||
/>
|
||
</View>
|
||
|
||
{/* Info */}
|
||
<View style={styles.itemInfo}>
|
||
<Text style={styles.itemTitle} numberOfLines={2}>
|
||
{item.product.name}
|
||
</Text>
|
||
<View style={styles.itemPriceRow}>
|
||
<Text style={styles.itemPrice}>₹{item.product.price}</Text>
|
||
{/* {!!item.product.mrp && item.product.mrp > item.product.price && (
|
||
<>
|
||
<Text style={styles.itemMrp}>₹{item.product.mrp}</Text>
|
||
<View style={styles.discountBadge}>
|
||
<Text style={styles.discountBadgeText}>
|
||
{Math.round(((item.product.mrp - item.product.price) / item.product.mrp) * 100)}% OFF
|
||
</Text>
|
||
</View>
|
||
</>
|
||
)} */}
|
||
</View>
|
||
</View>
|
||
|
||
{/* Quantity */}
|
||
<QuantitySelector
|
||
value={item.quantity}
|
||
onIncrement={() =>
|
||
dispatch(
|
||
updateCartItemThunk({
|
||
productId: item.productId,
|
||
quantity: item.quantity + 1,
|
||
}),
|
||
)
|
||
}
|
||
onDecrement={() => {
|
||
if (item.quantity >= 1) {
|
||
dispatch(
|
||
updateCartItemThunk({
|
||
productId: item.productId,
|
||
quantity: item.quantity - 1,
|
||
}),
|
||
);
|
||
}
|
||
}}
|
||
/>
|
||
</View>
|
||
))}
|
||
|
||
{/* Add more items */}
|
||
<TouchableOpacity
|
||
style={styles.addMoreRow}
|
||
activeOpacity={0.7}
|
||
onPress={() => navigation.goBack()}
|
||
>
|
||
<Text style={styles.addMoreIcon}>+</Text>
|
||
<Text style={styles.addMoreText}>Add more items</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* ── Savings if any ─────────────────────────────────────── */}
|
||
{totals.discount > 0 && (
|
||
<SavingsPill
|
||
label={`You're saving ₹${totals.discount} on this order 🎉`}
|
||
styles={styles}
|
||
/>
|
||
)}
|
||
|
||
{/* ── Coupons & Offers ───────────────────────────────────── */}
|
||
<View style={styles.section}>
|
||
<SectionTitle label="Coupons & Offers" styles={styles} />
|
||
|
||
{totals.discount > 0 ? (
|
||
<View style={styles.couponCard}>
|
||
<View style={styles.couponAppliedRow}>
|
||
<View style={styles.couponAppliedLeft}>
|
||
<View style={styles.couponCheckBadge}>
|
||
<Text style={styles.couponCheckIcon}>🏷️</Text>
|
||
</View>
|
||
<View>
|
||
<Text style={styles.couponAppliedCode}>Coupon Applied!</Text>
|
||
<Text style={styles.couponAppliedSub}>
|
||
You saved ₹{totals.discount} on this order
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
<TouchableOpacity style={styles.couponRemoveBtn}>
|
||
<Text style={styles.couponRemoveText}>Remove</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
) : (
|
||
<View style={styles.couponCard}>
|
||
<View style={styles.couponInputRow}>
|
||
<Text style={styles.couponIcon}>🎁</Text>
|
||
<TextInput
|
||
style={styles.couponInput}
|
||
placeholder="Enter coupon code"
|
||
placeholderTextColor={colors.placeholder}
|
||
value={couponInput}
|
||
onChangeText={setCouponInput}
|
||
autoCapitalize="characters"
|
||
/>
|
||
<TouchableOpacity
|
||
style={[
|
||
styles.applyButton,
|
||
!couponInput && styles.applyButtonDisabled,
|
||
]}
|
||
disabled={!couponInput}
|
||
activeOpacity={0.85}
|
||
>
|
||
<Text style={styles.applyButtonText}>Apply</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
<TouchableOpacity style={styles.viewCouponsRow} activeOpacity={0.7} onPress={() => navigation.navigate('MainTabs', { screen: 'OffersScreen' })}>
|
||
<View style={styles.viewCouponsLeft}>
|
||
<Text style={styles.viewCouponsTag}>🔖</Text>
|
||
<Text style={styles.viewCouponsText}>View all available offers</Text>
|
||
</View>
|
||
<Text style={styles.viewCouponsChevron}>›</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* ── Order Instructions ─────────────────────────────────── */}
|
||
{/* <View style={styles.section}>
|
||
<SectionTitle label="Order Instructions" styles={styles} />
|
||
<View style={styles.instructionsCard}>
|
||
{showInstructionsInput ? (
|
||
<TextInput
|
||
style={styles.instructionsInput}
|
||
placeholder="e.g. No onions, leave at door, don't ring bell…"
|
||
placeholderTextColor={colors.placeholder}
|
||
value={instructions}
|
||
onChangeText={setInstructions}
|
||
multiline
|
||
autoFocus
|
||
onBlur={() => setShowInstructionsInput(false)}
|
||
/>
|
||
) : (
|
||
<TouchableOpacity
|
||
style={styles.instructionsRow}
|
||
activeOpacity={0.7}
|
||
onPress={() => setShowInstructionsInput(true)}
|
||
>
|
||
<Text style={styles.instructionsIcon}>📝</Text>
|
||
<Text
|
||
style={
|
||
instructions
|
||
? styles.instructionsFilledText
|
||
: styles.instructionsPlaceholder
|
||
}
|
||
numberOfLines={1}
|
||
>
|
||
{instructions || 'Add a note for the rider or restaurant'}
|
||
</Text>
|
||
<Text style={styles.instructionsChevron}>›</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
</View> */}
|
||
|
||
{/* ── Tip Your Delivery Partner ──────────────────────────── */}
|
||
{/* <View style={styles.section}>
|
||
<SectionTitle label="Tip Your Delivery Partner 💛" styles={styles} />
|
||
<View style={styles.tipCard}>
|
||
<Text style={styles.tipSubtitle}>
|
||
100% of the tip goes directly to your delivery partner. They
|
||
work hard so your food arrives hot!
|
||
</Text>
|
||
<View style={styles.tipOptionsRow}>
|
||
{TIP_OPTIONS.map(amount => (
|
||
<TouchableOpacity
|
||
key={amount}
|
||
style={[
|
||
styles.tipOption,
|
||
selectedTip === amount && styles.tipOptionSelected,
|
||
]}
|
||
activeOpacity={0.8}
|
||
onPress={() =>
|
||
setSelectedTip(prev => (prev === amount ? null : amount))
|
||
}
|
||
>
|
||
{selectedTip === amount && (
|
||
<Text style={styles.tipSelectedCheck}>✓ </Text>
|
||
)}
|
||
<Text
|
||
style={[
|
||
styles.tipOptionText,
|
||
selectedTip === amount && styles.tipOptionTextSelected,
|
||
]}
|
||
>
|
||
₹{amount}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
{selectedTip !== null && (
|
||
<TouchableOpacity
|
||
style={styles.tipRemove}
|
||
onPress={() => setSelectedTip(null)}
|
||
>
|
||
<Text style={styles.tipRemoveText}>✕ Remove tip</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
</View> */}
|
||
|
||
{/* ── Bill Details ───────────────────────────────────────── */}
|
||
<View style={styles.section}>
|
||
<SectionTitle label="Bill Details" styles={styles} />
|
||
<View style={styles.billCard}>
|
||
<View style={styles.feeRow}>
|
||
<Text style={styles.feeLabel}>Item Total</Text>
|
||
<Text style={styles.feeValue}>₹{totals.subtotal}</Text>
|
||
</View>
|
||
|
||
<View style={styles.feeRow}>
|
||
<TouchableOpacity
|
||
style={styles.feeLabelRow}
|
||
onPress={toggleBillExpanded}
|
||
activeOpacity={0.7}
|
||
>
|
||
<Text style={styles.feeLabel}>Delivery Fee</Text>
|
||
<Text style={styles.infoIcon}> ⓘ</Text>
|
||
</TouchableOpacity>
|
||
<Text style={styles.feeValue}>₹{totals.deliveryFee}</Text>
|
||
</View>
|
||
|
||
{billExpanded && (
|
||
<View style={styles.gstNote}>
|
||
<Text style={styles.gstNoteText}>
|
||
Delivery fee helps cover your delivery partner's costs. GST as
|
||
applicable is included in the item total.
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
<View style={styles.feeRow}>
|
||
<Text style={styles.feeLabel}>Platform Fee</Text>
|
||
<Text style={styles.feeValue}>₹{totals.platformFee}</Text>
|
||
</View>
|
||
|
||
{tipAmount > 0 && (
|
||
<View style={styles.feeRow}>
|
||
<Text style={styles.feeLabel}>Delivery Tip 💛</Text>
|
||
<Text style={styles.feeValue}>₹{tipAmount}</Text>
|
||
</View>
|
||
)}
|
||
|
||
{totals.discount > 0 && (
|
||
<View style={styles.feeRow}>
|
||
<Text style={[styles.feeLabel, styles.discountLabel]}>
|
||
Coupon Discount
|
||
</Text>
|
||
<Text style={[styles.feeValue, styles.discountValue]}>
|
||
− ₹{totals.discount}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
<View style={styles.billDivider} />
|
||
|
||
<View style={styles.totalRow}>
|
||
<View>
|
||
<Text style={styles.totalLabel}>To Pay</Text>
|
||
{totals.discount > 0 && (
|
||
<Text style={styles.totalSaved}>
|
||
Saved ₹{totals.discount}
|
||
</Text>
|
||
)}
|
||
</View>
|
||
<Text style={styles.totalValue}>₹{grandTotal}</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
{/* ── Cancellation Policy ────────────────────────────────── */}
|
||
<View style={styles.policyCard}>
|
||
<Text style={styles.policyIcon}>🛈</Text>
|
||
<Text style={styles.policyText}>
|
||
Orders cannot be cancelled once packed for delivery. In case of
|
||
unexpected delays, a refund will be provided, if applicable.
|
||
</Text>
|
||
</View>
|
||
</ScrollView>
|
||
</Animated.View>
|
||
|
||
{/* ── Bottom Panel ───────────────────────────────────────────── */}
|
||
<Animated.View
|
||
style={[styles.bottomPanel, { transform: [{ translateY: bottomPanelAnim }] }]}
|
||
>
|
||
<View style={styles.bottomTotalWrap}>
|
||
<Text style={styles.bottomTotalLabel}>
|
||
{itemCount} {itemCount === 1 ? 'item' : 'items'}
|
||
</Text>
|
||
<View style={styles.bottomTotalRow}>
|
||
<Text style={styles.bottomTotal}>₹{grandTotal}</Text>
|
||
{totals.discount > 0 && (
|
||
<View style={styles.bottomSavedBadge}>
|
||
<Text style={styles.bottomSavedText}>saved ₹{totals.discount}</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
<PrimaryButton
|
||
title="Proceed to Checkout →"
|
||
onPress={() => navigation.navigate('CheckoutAddressScreen')}
|
||
style={styles.checkoutButton}
|
||
/>
|
||
</Animated.View>
|
||
</View>
|
||
);
|
||
}; |