519 lines
16 KiB
TypeScript
519 lines
16 KiB
TypeScript
import React, { useEffect, useState, useRef } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
TouchableOpacity,
|
|
ScrollView,
|
|
Image,
|
|
Dimensions,
|
|
NativeSyntheticEvent,
|
|
NativeScrollEvent,
|
|
} from 'react-native';
|
|
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
|
import { StackNavigationProp } from '@react-navigation/stack';
|
|
import { getStyles } from './providerDetailsScreen.styles';
|
|
import { useAppTheme } from '@theme';
|
|
import {
|
|
addToCartThunk,
|
|
updateCartItemThunk,
|
|
} from '../../../store/commonreducers/cart';
|
|
import { AppStackParamList } from '../../../navigation/appStack';
|
|
import { useAppDispatch, useAppSelector } from '@store';
|
|
import { getProductDetailsThunk } from './thunk';
|
|
import { getDiscountPercentage, formatPrice } from '@components';
|
|
|
|
type ProviderDetailsNavProp = StackNavigationProp<
|
|
AppStackParamList,
|
|
'ProviderDetailsScreen'
|
|
>;
|
|
type ProviderDetailsRouteProp = RouteProp<
|
|
AppStackParamList,
|
|
'ProviderDetailsScreen'
|
|
>;
|
|
|
|
const { width } = Dimensions.get('window');
|
|
|
|
const BASE_URL = 'https://2e1b-202-8-116-13.ngrok-free.app';
|
|
const getFullUrl = (url?: string) => {
|
|
if (!url) return '';
|
|
return url.startsWith('/') ? `${BASE_URL}${url}` : url;
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
|
|
type RatingBreakdown = Partial<Record<1 | 2 | 3 | 4 | 5, number>>;
|
|
|
|
export const ProviderDetailsScreen: React.FC = () => {
|
|
const { colors } = useAppTheme();
|
|
const styles = getStyles(colors);
|
|
const dispatch = useAppDispatch();
|
|
const navigation = useNavigation<ProviderDetailsNavProp>();
|
|
const route = useRoute<ProviderDetailsRouteProp>();
|
|
|
|
// Notice we still use providerId param name to avoid breaking routing everywhere
|
|
const { providerId } = route.params;
|
|
|
|
const cartItems = useAppSelector(state => state.cart.items);
|
|
const { product, isLoading } = useAppSelector(state => state.providerDetails);
|
|
|
|
// Find this product in the cart (if already added)
|
|
const cartItem = product
|
|
? cartItems.find(i => i.productId === product.id)
|
|
: undefined;
|
|
const isInCart = !!cartItem;
|
|
|
|
const [activeImageIndex, setActiveImageIndex] = useState(0);
|
|
const [localQty, setLocalQty] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (providerId) {
|
|
dispatch(getProductDetailsThunk(providerId));
|
|
}
|
|
}, [providerId, dispatch]);
|
|
|
|
// Sync localQty with cart quantity when cart loads or product changes
|
|
useEffect(() => {
|
|
if (cartItem) {
|
|
setLocalQty(cartItem.quantity);
|
|
} else {
|
|
setLocalQty(0);
|
|
}
|
|
}, [cartItem?.quantity, cartItem?.productId]);
|
|
|
|
const handleScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
const slide = Math.round(e.nativeEvent.contentOffset.x / width);
|
|
setActiveImageIndex(slide);
|
|
};
|
|
|
|
const handleAddToCart = () => {
|
|
if (!product) return;
|
|
dispatch(
|
|
addToCartThunk({
|
|
productId: product.id,
|
|
quantity: Math.max(1, localQty), // always add at least 1
|
|
}),
|
|
);
|
|
};
|
|
|
|
const handleIncrement = () => {
|
|
if (!product) return;
|
|
const newQty = localQty + 1;
|
|
setLocalQty(newQty);
|
|
dispatch(updateCartItemThunk({ productId: product.id, quantity: newQty }));
|
|
};
|
|
|
|
const handleDecrement = () => {
|
|
if (!product || localQty <= 1) return;
|
|
const newQty = localQty - 1;
|
|
setLocalQty(newQty);
|
|
dispatch(updateCartItemThunk({ productId: product.id, quantity: newQty }));
|
|
};
|
|
|
|
const handleRateProduct = () => {
|
|
navigation.navigate('WriteReviewScreen', { productId: product?.id ?? '' });
|
|
};
|
|
|
|
const renderStars = (value: number, size = 14) => {
|
|
const rounded = Math.round(value);
|
|
const starColor = (colors as any).warning ?? '#F5A623';
|
|
return (
|
|
<View style={styles.starsRow}>
|
|
{[1, 2, 3, 4, 5].map(i => (
|
|
<Text
|
|
key={i}
|
|
style={[styles.starIcon, { fontSize: size, color: starColor }]}
|
|
>
|
|
{i <= rounded ? '★' : '☆'}
|
|
</Text>
|
|
))}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const renderImages = () => {
|
|
if (isLoading) {
|
|
return (
|
|
<View style={styles.carouselWrap}>
|
|
<View
|
|
style={[
|
|
styles.fallbackIconWrap,
|
|
{ backgroundColor: (colors as any).surface ?? colors.cardBg },
|
|
]}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const images = product?.media?.length
|
|
? product.media
|
|
: [
|
|
{
|
|
id: 'fallback',
|
|
url: product?.imageUrl || '',
|
|
mediaType: 'IMAGE',
|
|
sortOrder: 0,
|
|
productId: '',
|
|
createdAt: '',
|
|
},
|
|
];
|
|
|
|
return (
|
|
<View style={styles.carouselWrap}>
|
|
<ScrollView
|
|
horizontal
|
|
pagingEnabled
|
|
showsHorizontalScrollIndicator={false}
|
|
onMomentumScrollEnd={handleScroll}
|
|
>
|
|
{images.map(img => (
|
|
<Image
|
|
key={img.id}
|
|
source={{ uri: getFullUrl(img.url) }}
|
|
style={styles.carouselImage}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
{images.length > 1 && (
|
|
<View style={styles.paginationDots}>
|
|
{images.map((_, i) => (
|
|
<View
|
|
key={i}
|
|
style={[styles.dot, i === activeImageIndex && styles.dotActive]}
|
|
/>
|
|
))}
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const renderProductInfo = () => {
|
|
if (isLoading || !product) {
|
|
return (
|
|
<View style={styles.infoBox}>
|
|
<View style={styles.skeletonTitle} />
|
|
<View style={styles.skeletonPrice} />
|
|
<View style={styles.skeletonText} />
|
|
<View style={styles.skeletonText} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const discount = getDiscountPercentage(
|
|
product.price,
|
|
product.compareAtPrice,
|
|
);
|
|
const currency = product.currency === 'INR' ? '₹' : product.currency;
|
|
|
|
return (
|
|
<View style={styles.infoBox}>
|
|
<View style={styles.brandRow}>
|
|
<Text style={styles.brandText}>
|
|
{product.brand || product.merchant?.name || 'BRAND'}
|
|
</Text>
|
|
<View style={styles.ratingBadge}>
|
|
<Text style={{ fontSize: 11 }}>⭐</Text>
|
|
<Text style={styles.ratingBadgeText}>
|
|
{parseFloat(product.avgRating).toFixed(1)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<Text style={styles.titleText}>{product.name}</Text>
|
|
|
|
<View style={styles.priceRow}>
|
|
<Text style={styles.priceText}>
|
|
{formatPrice(product.price, currency)}
|
|
</Text>
|
|
{product.compareAtPrice &&
|
|
parseFloat(product.compareAtPrice) > parseFloat(product.price) && (
|
|
<Text style={styles.comparePriceText}>
|
|
{formatPrice(product.compareAtPrice, currency)}
|
|
</Text>
|
|
)}
|
|
{discount && (
|
|
<View style={styles.discountBadgeWrap}>
|
|
<Text style={styles.discountBadgeText}>{discount}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<Text style={styles.taxText}>Inclusive of all taxes</Text>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const renderRatingsSection = () => {
|
|
if (isLoading) {
|
|
return (
|
|
<View style={styles.ratingsSection}>
|
|
<View style={styles.skeletonBlock} />
|
|
<View style={[styles.skeletonBlock, { height: 80 }]} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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 breakdown: RatingBreakdown | undefined = (product as any)
|
|
?.ratingBreakdown;
|
|
const hasRatings = totalRatings > 0;
|
|
|
|
return (
|
|
<View style={styles.ratingsSection}>
|
|
<View style={styles.ratingsHeaderRow}>
|
|
<Text style={styles.sectionTitle}>Ratings & Reviews</Text>
|
|
{reviews.length > 3 && (
|
|
<TouchableOpacity activeOpacity={0.7}>
|
|
<Text style={styles.seeAllText}>See all</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
|
|
{hasRatings ? (
|
|
<View style={styles.ratingsSummaryCard}>
|
|
<View
|
|
style={[
|
|
styles.ratingsSummaryLeft,
|
|
breakdown && styles.ratingsSummaryLeftWithDivider,
|
|
]}
|
|
>
|
|
<Text style={styles.avgRatingNumber}>{avgRating.toFixed(1)}</Text>
|
|
{renderStars(avgRating, 16)}
|
|
<Text style={styles.totalRatingsText}>
|
|
{totalRatings} {totalRatings === 1 ? 'rating' : 'ratings'}
|
|
</Text>
|
|
</View>
|
|
|
|
{breakdown && (
|
|
<View style={styles.ratingsBarsWrap}>
|
|
{[5, 4, 3, 2, 1].map(star => {
|
|
const count = breakdown[star as 1 | 2 | 3 | 4 | 5] ?? 0;
|
|
const pct =
|
|
totalRatings > 0 ? (count / totalRatings) * 100 : 0;
|
|
return (
|
|
<View key={star} style={styles.barRow}>
|
|
<Text style={styles.barLabel}>{star}★</Text>
|
|
<View style={styles.barTrack}>
|
|
<View style={[styles.barFill, { width: `${pct}%` }]} />
|
|
</View>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
)}
|
|
</View>
|
|
) : (
|
|
<View style={styles.emptyRatingsCard}>
|
|
<Text style={styles.emptyRatingsIcon}>⭐</Text>
|
|
<Text style={styles.emptyRatingsTitle}>No ratings yet</Text>
|
|
<Text style={styles.emptyRatingsSubtitle}>
|
|
Be the first to share what you think of this product.
|
|
</Text>
|
|
<TouchableOpacity
|
|
style={styles.rateProductBtn}
|
|
activeOpacity={0.8}
|
|
onPress={handleRateProduct}
|
|
>
|
|
<Text style={styles.rateProductBtnText}>Rate this product</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
|
|
{reviews.length > 0 && (
|
|
<View style={styles.reviewsList}>
|
|
{reviews.slice(0, 3).map(review => (
|
|
<View key={review.id} style={styles.reviewCard}>
|
|
<View style={styles.reviewCardHeader}>
|
|
<View style={styles.reviewerAvatar}>
|
|
<Text style={styles.reviewerAvatarText}>
|
|
{(review.userName || 'U').charAt(0).toUpperCase()}
|
|
</Text>
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={styles.reviewerName}>
|
|
{review.userName || 'Anonymous'}
|
|
</Text>
|
|
{renderStars(review.rating, 12)}
|
|
</View>
|
|
<Text style={styles.reviewDate}>
|
|
{review.createdAt
|
|
? new Date(review.createdAt).toLocaleDateString('en-IN', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
})
|
|
: ''}
|
|
</Text>
|
|
</View>
|
|
{!!review.comment && (
|
|
<Text style={styles.reviewComment}>{review.comment}</Text>
|
|
)}
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<ScrollView
|
|
style={styles.content}
|
|
contentContainerStyle={styles.contentBody}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<View style={{ position: 'relative' }}>
|
|
{renderImages()}
|
|
|
|
<View style={styles.topIconRow}>
|
|
<TouchableOpacity
|
|
style={styles.iconButton}
|
|
activeOpacity={0.8}
|
|
onPress={() => navigation.goBack()}
|
|
>
|
|
<Text style={styles.iconButtonText}>←</Text>
|
|
</TouchableOpacity>
|
|
<View style={styles.iconButtonGroup}>
|
|
<TouchableOpacity
|
|
style={[styles.iconButton, { marginRight: 12 }]}
|
|
activeOpacity={0.8}
|
|
>
|
|
<Text style={styles.iconButtonText}>🔗</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={styles.iconButton} activeOpacity={0.8}>
|
|
<Text style={{ fontSize: 17 }}>🤍</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{renderProductInfo()}
|
|
|
|
<View style={styles.sectionDivider} />
|
|
|
|
<View style={styles.detailsSection}>
|
|
<Text style={styles.sectionTitle}>Product Details</Text>
|
|
<Text style={styles.descriptionText}>
|
|
{product?.description ||
|
|
'No description available for this product.'}
|
|
</Text>
|
|
|
|
<View style={styles.metaGrid}>
|
|
<View style={styles.metaItem}>
|
|
<Text style={styles.metaLabel}>Category</Text>
|
|
<Text style={styles.metaValue}>
|
|
{product?.category?.name || 'N/A'}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.metaItem}>
|
|
<Text style={styles.metaLabel}>SKU</Text>
|
|
<Text style={styles.metaValue}>{product?.sku || 'N/A'}</Text>
|
|
</View>
|
|
<View style={styles.metaItem}>
|
|
<Text style={styles.metaLabel}>Stock</Text>
|
|
<Text style={styles.metaValue}>
|
|
{product?.stockQuantity ? 'In Stock' : 'Out of Stock'}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.metaItem}>
|
|
<Text style={styles.metaLabel}>Merchant</Text>
|
|
<Text style={styles.metaValue}>
|
|
{product?.merchant?.name || 'N/A'}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.sectionDivider} />
|
|
|
|
{renderRatingsSection()}
|
|
</ScrollView>
|
|
|
|
{/* Sticky Bottom Bar */}
|
|
<View style={styles.bottomBarWrap}>
|
|
{isInCart ? (
|
|
// ── Already in cart: show inline qty stepper + Go to Cart ──
|
|
<>
|
|
<View style={styles.qtySelector}>
|
|
<TouchableOpacity
|
|
style={styles.qtyBtn}
|
|
onPress={handleDecrement}
|
|
activeOpacity={0.7}
|
|
disabled={localQty <= 1}
|
|
>
|
|
<Text style={styles.qtyBtnText}>-</Text>
|
|
</TouchableOpacity>
|
|
<Text style={styles.qtyValue}>{localQty}</Text>
|
|
<TouchableOpacity
|
|
style={styles.qtyBtn}
|
|
onPress={handleIncrement}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text style={styles.qtyBtnText}>+</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
style={styles.addBtn}
|
|
activeOpacity={0.8}
|
|
onPress={() => navigation.navigate('CartScreen')}
|
|
>
|
|
<Text style={styles.addBtnText}>Go to Cart</Text>
|
|
</TouchableOpacity>
|
|
</>
|
|
) : (
|
|
// ── Not in cart: qty picker + Add to Cart ──
|
|
<>
|
|
<View style={styles.qtySelector}>
|
|
<TouchableOpacity
|
|
style={styles.qtyBtn}
|
|
onPress={() => setLocalQty(q => Math.max(0, q - 1))}
|
|
activeOpacity={0.7}
|
|
disabled={localQty <= 0}
|
|
>
|
|
<Text style={styles.qtyBtnText}>-</Text>
|
|
</TouchableOpacity>
|
|
<Text style={styles.qtyValue}>{localQty}</Text>
|
|
<TouchableOpacity
|
|
style={styles.qtyBtn}
|
|
onPress={() => setLocalQty(q => q + 1)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text style={styles.qtyBtnText}>+</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
style={styles.addBtn}
|
|
activeOpacity={0.8}
|
|
onPress={handleAddToCart}
|
|
disabled={
|
|
!product ||
|
|
(product.isTrackStock && product.stockQuantity === 0)
|
|
}
|
|
>
|
|
<Text style={styles.addBtnText}>
|
|
{!product
|
|
? 'Loading...'
|
|
: product.isTrackStock && product.stockQuantity === 0
|
|
? 'Out of Stock'
|
|
: 'Add to Cart'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</>
|
|
)}
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|