767 lines
No EOL
33 KiB
TypeScript
767 lines
No EOL
33 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { View, ScrollView, Alert, Dimensions, FlatList, RefreshControl } from 'react-native';
|
|
import { useRouter } from 'expo-router';
|
|
import { ImageCarousel, tw, BottomDialog, useManualRefresh, useMarkDataFetchers, LoadingDialog, ImageUploader, MyText, MyTextInput, MyTouchableOpacity, Quantifier } from 'common-ui';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import usePickImage from 'common-ui/src/components/use-pick-image';
|
|
import { theme } from 'common-ui/src/theme';
|
|
import dayjs from 'dayjs';
|
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
import FontAwesome5 from '@expo/vector-icons/FontAwesome5';
|
|
import { trpc, trpcClient } from '@/src/trpc-client';
|
|
import { useAddToCart, useGetCart, useUpdateCartItem, useRemoveFromCart } from '@/hooks/cart-query-hooks';
|
|
import { useProductSlotIdentifier } from '@/hooks/useProductSlotIdentifier';
|
|
import { useFlashNavigationStore } from '@/components/stores/flashNavigationStore';
|
|
import FloatingCartBar from './floating-cart-bar';
|
|
|
|
const { width: screenWidth } = Dimensions.get("window");
|
|
const carouselWidth = screenWidth;
|
|
const carouselHeight = carouselWidth * 0.8;
|
|
|
|
const extractKeyFromUrl = (url: string): string => {
|
|
const u = new URL(url);
|
|
const rawKey = u.pathname.replace(/^\/+/, "");
|
|
return decodeURIComponent(rawKey);
|
|
};
|
|
|
|
interface ProductDetailProps {
|
|
productId: string;
|
|
isFlashDelivery?: boolean;
|
|
}
|
|
|
|
const formatQuantity = (quantity: number, unit: string): { value: string; display: string } => {
|
|
if (unit?.toLowerCase() === 'kg' && quantity < 1) {
|
|
return { value: `${Math.round(quantity * 1000)} g`, display: `${Math.round(quantity * 1000)}g` };
|
|
}
|
|
return { value: `${quantity} ${unit}(s)`, display: `${quantity}${unit}` };
|
|
};
|
|
|
|
const ProductDetail: React.FC<ProductDetailProps> = ({ productId, isFlashDelivery = false }) => {
|
|
const router = useRouter();
|
|
const [showAllSlots, setShowAllSlots] = useState(false);
|
|
const [isLoadingDialogOpen, setIsLoadingDialogOpen] = useState(false);
|
|
const [reviews, setReviews] = useState<any[]>([]);
|
|
const [reviewsLoading, setReviewsLoading] = useState(false);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [reviewsOffset, setReviewsOffset] = useState(0);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const { data: productDetail, isLoading, error, refetch } = trpc.user.product.getProductDetails.useQuery({ id: productId });
|
|
const cartType: "regular" | "flash" = isFlashDelivery ? "flash" : "regular";
|
|
const { addToCart = () => {} } = useAddToCart({ showSuccessAlert: false, showErrorAlert: false, refetchCart: true }, cartType) || {};
|
|
const { addToCart:addTOFlashCart = () => {} } = useAddToCart({ showSuccessAlert: false, showErrorAlert: false, refetchCart: true }, 'flash') || {};
|
|
const cartData = useGetCart({}, cartType);
|
|
const updateCartItem = useUpdateCartItem({ showSuccessAlert: false, showErrorAlert: false, refetchCart: true }, cartType);
|
|
const removeFromCart = useRemoveFromCart({ showSuccessAlert: false, showErrorAlert: false, refetchCart: true }, cartType);
|
|
const { getQuickestSlot } = useProductSlotIdentifier();
|
|
const { setShouldNavigateToCart } = useFlashNavigationStore();
|
|
|
|
// Find current quantity from cart data
|
|
const cartItem = productDetail ? cartData?.data?.items?.find((item: any) => item.productId === productDetail.id) : null;
|
|
const quantity = cartItem?.quantity || 0;
|
|
|
|
const handleQuantityChange = (newQuantity: number) => {
|
|
if (!productDetail) return;
|
|
|
|
if (newQuantity === 0 && cartItem) {
|
|
removeFromCart.mutate({ itemId: cartItem.id });
|
|
} else if (newQuantity === 1 && !cartItem) {
|
|
handleAddToCart(productDetail.id);
|
|
} else if (cartItem) {
|
|
updateCartItem.mutate({ itemId: cartItem.id, quantity: newQuantity });
|
|
}
|
|
};
|
|
|
|
|
|
useManualRefresh(() => {
|
|
refetch();
|
|
});
|
|
|
|
useMarkDataFetchers(() => {
|
|
refetch();
|
|
});
|
|
|
|
const handleAddToCart = (productId: number) => {
|
|
if (isFlashDelivery) {
|
|
if (!productDetail?.isFlashAvailable) {
|
|
Alert.alert("Error", "This product is not available for flash delivery");
|
|
return;
|
|
}
|
|
setIsLoadingDialogOpen(true);
|
|
addToCart(productId, 1, 0, () => setIsLoadingDialogOpen(false));
|
|
} else {
|
|
const slotId = getQuickestSlot(productId);
|
|
if (!slotId) {
|
|
Alert.alert("Error", "No available delivery slot for this product");
|
|
return;
|
|
}
|
|
setIsLoadingDialogOpen(true);
|
|
addToCart(productId, 1, slotId, () => setIsLoadingDialogOpen(false));
|
|
}
|
|
};
|
|
|
|
const handleBuyNow = (productId: number) => {
|
|
if (isFlashDelivery) {
|
|
if (!productDetail?.isFlashAvailable) {
|
|
Alert.alert("Error", "This product is not available for flash delivery");
|
|
return;
|
|
}
|
|
setIsLoadingDialogOpen(true);
|
|
addToCart(productId, 1, 0, () => setIsLoadingDialogOpen(false));
|
|
router.push(`/(drawer)/(tabs)/flash-delivery/(cart)/cart?select=${productId}`);
|
|
} else {
|
|
addTOFlashCart(productId, 1, 0);
|
|
setShouldNavigateToCart(true);
|
|
router.push('/(drawer)/(tabs)/flash-delivery');
|
|
}
|
|
};
|
|
|
|
const handleSlotAddToCart = (productId: number, selectedSlotId: number) => {
|
|
const cartItem = cartData.data?.items?.find((item: any) => item.productId === productId);
|
|
setIsLoadingDialogOpen(true);
|
|
if (cartItem) {
|
|
removeFromCart.mutate(
|
|
{ itemId: cartItem.id },
|
|
{
|
|
onSuccess: () => {
|
|
addToCart(productId, cartItem.quantity + 1, selectedSlotId, () => setIsLoadingDialogOpen(false));
|
|
},
|
|
onError: () => setIsLoadingDialogOpen(false),
|
|
}
|
|
);
|
|
} else {
|
|
addToCart(productId, 1, selectedSlotId, () => setIsLoadingDialogOpen(false));
|
|
}
|
|
};
|
|
|
|
const discountPercentage = productDetail?.marketPrice
|
|
? Math.round(((Number(productDetail.marketPrice) - Number(productDetail.price)) / Number(productDetail.marketPrice)) * 100)
|
|
: 0;
|
|
|
|
const loadReviews = async (reset = false) => {
|
|
if (reviewsLoading || (!hasMore && !reset)) return;
|
|
setReviewsLoading(true);
|
|
try {
|
|
const { reviews: newReviews, hasMore: newHasMore } = await trpcClient.user.product.getProductReviews.query({
|
|
productId: Number(productId),
|
|
limit: 10,
|
|
offset: reset ? 0 : reviewsOffset,
|
|
});
|
|
setReviews(reset ? newReviews : [...reviews, ...newReviews]);
|
|
setHasMore(newHasMore);
|
|
setReviewsOffset(reset ? 10 : reviewsOffset + 10);
|
|
} catch (error) {
|
|
console.error('Error loading reviews:', error);
|
|
} finally {
|
|
setReviewsLoading(false);
|
|
}
|
|
};
|
|
|
|
const onRefresh = async () => {
|
|
setRefreshing(true);
|
|
await refetch(); // Refetch product details
|
|
await loadReviews(true); // Reset and reload reviews
|
|
setRefreshing(false);
|
|
};
|
|
|
|
const handleReviewSubmitted = () => {
|
|
loadReviews(true);
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
if (productDetail?.id) {
|
|
loadReviews(true);
|
|
}
|
|
}, [productDetail?.id]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<View style={tw`flex-1 justify-center items-center bg-gray-50`}>
|
|
<MyText style={tw`text-gray-500 font-medium`}>Loading product details...</MyText>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (error || !productDetail) {
|
|
return (
|
|
<View style={tw`flex-1 justify-center items-center bg-gray-50`}>
|
|
<MaterialIcons name="error-outline" size={48} color="#EF4444" />
|
|
<MyText style={tw`text-gray-900 text-lg font-bold mt-4`}>Oops!</MyText>
|
|
<MyText style={tw`text-gray-500 mt-2`}>Product not found or error loading</MyText>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
|
|
return (
|
|
<View style={tw`flex-1 bg-gray-50`}>
|
|
{/* <CustomHeader /> */}
|
|
|
|
<ScrollView
|
|
style={tw`flex-1`}
|
|
contentContainerStyle={tw`pb-8`}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={onRefresh}
|
|
colors={[theme.colors.brand500]}
|
|
tintColor={theme.colors.brand500}
|
|
/>
|
|
}
|
|
>
|
|
{/* Image Carousel */}
|
|
<View style={tw`bg-white shadow-sm mb-4`}>
|
|
<ImageCarousel
|
|
urls={productDetail.images}
|
|
imageWidth={carouselWidth}
|
|
imageHeight={carouselHeight}
|
|
showPaginationDots={true}
|
|
/>
|
|
</View>
|
|
|
|
{/* Product Info */}
|
|
<View style={tw`px-4 mb-4`}>
|
|
<View style={tw`bg-white p-5 rounded-2xl shadow-sm border border-gray-100`}>
|
|
<View style={tw`flex-row justify-between items-start mb-2`}>
|
|
<MyText style={tw`text-2xl font-bold text-gray-900 flex-1 mr-2`}>{productDetail.name}</MyText>
|
|
<View style={tw`flex-row gap-2`}>
|
|
{productDetail.isFlashAvailable && (
|
|
<View style={tw`bg-pink-100 px-3 py-1 rounded-full flex-row items-center`}>
|
|
<MaterialIcons name="bolt" size={12} color="#EC4899" style={tw`mr-1`} />
|
|
<MyText style={tw`text-pink-700 text-xs font-bold`}>Flash Delivery</MyText>
|
|
</View>
|
|
)}
|
|
{productDetail.isOutOfStock && (
|
|
<View style={tw`bg-red-100 px-3 py-1 rounded-full`}>
|
|
<MyText style={tw`text-red-700 text-xs font-bold`}>Out of Stock</MyText>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
<MyText style={tw`text-base text-gray-500 mb-4 leading-6`}>{productDetail.shortDescription}</MyText>
|
|
|
|
<View>
|
|
{/* Main price display - always show regular price prominently */}
|
|
<View style={tw`flex-row items-end`}>
|
|
<MyText style={tw`text-3xl font-bold text-gray-900`}>
|
|
₹{productDetail.price}
|
|
</MyText>
|
|
<MyText style={tw`text-gray-500 text-lg mb-1 ml-1`}>/ {formatQuantity(productDetail.productQuantity || 1, productDetail.unitNotation).display}</MyText>
|
|
|
|
{/* Show market price discount if available */}
|
|
{productDetail.marketPrice && (
|
|
<View style={tw`ml-3 mb-1 flex-row items-center`}>
|
|
<MyText style={tw`text-gray-400 text-base line-through mr-2`}>₹{productDetail.marketPrice}</MyText>
|
|
<View style={tw`bg-green-100 px-2 py-0.5 rounded`}>
|
|
<MyText style={tw`text-green-700 text-xs font-bold`}>{discountPercentage}% OFF</MyText>
|
|
</View>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Flash price on separate line - smaller and less prominent */}
|
|
{productDetail.isFlashAvailable && productDetail.flashPrice && productDetail.flashPrice !== productDetail.price && (
|
|
<View style={tw`mt-1`}>
|
|
<MyText style={tw`text-pink-600 text-lg font-bold`}>
|
|
Flash Delivery: ₹{productDetail.flashPrice} / {formatQuantity(productDetail.productQuantity || 1, productDetail.unitNotation).display}
|
|
</MyText>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
|
|
|
|
{/* Action Buttons */}
|
|
<View style={tw`flex-row gap-3 mt-6`}>
|
|
{quantity > 0 ? (
|
|
// Show quantifier when item is in cart
|
|
<View style={tw`flex-1`}>
|
|
<Quantifier
|
|
value={quantity}
|
|
setValue={handleQuantityChange}
|
|
step={1} // Default step for product detail quantifier
|
|
unit={productDetail.unitNotation}
|
|
/>
|
|
</View>
|
|
) : (
|
|
// Show "Add to Cart" button when not in cart
|
|
<MyTouchableOpacity
|
|
style={[tw`flex-1 py-3.5 rounded-xl items-center border`, {
|
|
borderColor: (productDetail.isOutOfStock || (isFlashDelivery && !productDetail.isFlashAvailable)) ? '#9ca3af' : theme.colors.brand500,
|
|
backgroundColor: 'white'
|
|
}]}
|
|
onPress={() => !(productDetail.isOutOfStock || (isFlashDelivery && !productDetail.isFlashAvailable)) && handleAddToCart(productDetail.id)}
|
|
disabled={productDetail.isOutOfStock || (isFlashDelivery && !productDetail.isFlashAvailable)}
|
|
>
|
|
<MyText style={[tw`font-bold text-base`, { color: (productDetail.isOutOfStock || (isFlashDelivery && !productDetail.isFlashAvailable)) ? '#9ca3af' : theme.colors.brand500 }]}>
|
|
{(productDetail.isOutOfStock || (isFlashDelivery && !productDetail.isFlashAvailable)) ? 'Unavailable' : 'Add to Cart'}
|
|
</MyText>
|
|
</MyTouchableOpacity>
|
|
)}
|
|
|
|
{isFlashDelivery ? (
|
|
<MyTouchableOpacity
|
|
style={[tw`flex-1 py-3.5 rounded-xl items-center shadow-md`, {
|
|
backgroundColor: (productDetail.isOutOfStock || !productDetail.isFlashAvailable) ? '#9ca3af' : theme.colors.brand500
|
|
}]}
|
|
onPress={() => !(productDetail.isOutOfStock || !productDetail.isFlashAvailable) && handleBuyNow(productDetail.id)}
|
|
disabled={productDetail.isOutOfStock || !productDetail.isFlashAvailable}
|
|
>
|
|
<MyText style={tw`text-white text-base font-bold`}>
|
|
{productDetail.isOutOfStock ? 'Out of Stock' :
|
|
(!productDetail.isFlashAvailable ? 'Not Flash Eligible' : 'Buy Now')}
|
|
</MyText>
|
|
</MyTouchableOpacity>
|
|
) : productDetail.isFlashAvailable ? (
|
|
<MyTouchableOpacity
|
|
style={[tw`flex-1 py-3.5 rounded-xl items-center shadow-md`, {
|
|
backgroundColor: productDetail.deliverySlots.length === 0 ? '#9ca3af' : theme.colors.brand500
|
|
}]}
|
|
onPress={() => productDetail.deliverySlots.length > 0 && handleBuyNow(productDetail.id)}
|
|
disabled={productDetail.deliverySlots.length === 0}
|
|
>
|
|
<MyText style={tw`text-white text-base font-bold`}>
|
|
{productDetail.deliverySlots.length === 0 ? 'No Slots' : 'Buy Now'}
|
|
</MyText>
|
|
</MyTouchableOpacity>
|
|
) : (
|
|
<View style={tw`flex-1`} />
|
|
)}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Delivery Slots */}
|
|
<View style={tw`px-4 mb-4`}>
|
|
<View style={tw`bg-white p-5 rounded-2xl shadow-sm border border-gray-100`}>
|
|
<View style={tw`flex-row items-center mb-4`}>
|
|
<View style={tw`w-8 h-8 bg-blue-50 rounded-full items-center justify-center mr-3`}>
|
|
<MaterialIcons name="schedule" size={18} color="#3B82F6" />
|
|
</View>
|
|
<MyText style={tw`text-lg font-bold text-gray-900`}>Available Slots</MyText>
|
|
</View>
|
|
|
|
{productDetail.deliverySlots.length === 0 ? (
|
|
<MyText style={tw`text-gray-400 italic`}>No delivery slots available currently</MyText>
|
|
) : (
|
|
<>
|
|
{productDetail.deliverySlots.slice(0, 2).map((slot, index) => (
|
|
<MyTouchableOpacity
|
|
key={index}
|
|
style={tw`flex-row items-start mb-4 bg-gray-50 p-3 rounded-xl border border-gray-100`}
|
|
onPress={() => handleSlotAddToCart(productDetail.id, slot.id)}
|
|
disabled={productDetail.isOutOfStock}
|
|
activeOpacity={0.7}
|
|
>
|
|
<MaterialIcons name="local-shipping" size={20} color="#3B82F6" style={tw`mt-0.5`} />
|
|
<View style={tw`ml-3 flex-1`}>
|
|
<MyText style={tw`text-gray-900 font-bold text-sm`}>
|
|
{dayjs(slot.deliveryTime).format('ddd, DD MMM • h:mm A')}
|
|
</MyText>
|
|
<MyText style={tw`text-xs text-gray-500 mt-1`}>
|
|
Orders Close: {dayjs(slot.freezeTime).format('h:mm A')}
|
|
</MyText>
|
|
</View>
|
|
<MaterialIcons name="add" size={20} color="#3B82F6" style={tw`mt-0.5`} />
|
|
</MyTouchableOpacity>
|
|
))}
|
|
{productDetail.deliverySlots.length > 2 && (
|
|
<MyTouchableOpacity
|
|
onPress={() => setShowAllSlots(true)}
|
|
style={tw`items-center py-2`}
|
|
>
|
|
<MyText style={tw`text-brand500 font-bold text-sm`}>View All {productDetail.deliverySlots.length} Slots</MyText>
|
|
</MyTouchableOpacity>
|
|
)}
|
|
</>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
|
|
|
|
{/* Description */}
|
|
<View style={tw`px-4 mb-4`}>
|
|
<View style={tw`bg-white p-5 rounded-2xl shadow-sm border border-gray-100`}>
|
|
<MyText style={tw`text-lg font-bold text-gray-900 mb-3`}>About the Product</MyText>
|
|
{!productDetail.longDescription ? (
|
|
<MyText style={tw`text-gray-400 italic`}>
|
|
No detailed description available.
|
|
</MyText>
|
|
) : (
|
|
<MyText style={tw`text-gray-600 leading-6`}>{productDetail.longDescription}</MyText>
|
|
)}
|
|
|
|
{productDetail.store && (
|
|
<View style={tw`mt-6 pt-4 border-t border-gray-100 flex-row items-center`}>
|
|
<View style={tw`w-10 h-10 bg-gray-100 rounded-full items-center justify-center mr-3`}>
|
|
<FontAwesome5 name="store" size={16} color="#4B5563" />
|
|
</View>
|
|
<View>
|
|
<MyText style={tw`text-xs text-gray-500 uppercase font-bold`}>Sourced From</MyText>
|
|
<MyText style={tw`text-gray-900 font-bold`}>{productDetail.store.name}</MyText>
|
|
</View>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Package Deals */}
|
|
{productDetail.specialDeals && productDetail.specialDeals.length > 0 && (
|
|
<View style={tw`px-4 mb-4`}>
|
|
<View style={tw`bg-white p-5 rounded-2xl shadow-sm border border-gray-100`}>
|
|
<View style={tw`flex-row items-center mb-4`}>
|
|
<View style={tw`w-8 h-8 bg-amber-50 rounded-full items-center justify-center mr-3`}>
|
|
<MaterialIcons name="stars" size={18} color="#F59E0B" />
|
|
</View>
|
|
<MyText style={tw`text-lg font-bold text-gray-900`}>Bulk Savings</MyText>
|
|
</View>
|
|
|
|
{productDetail.specialDeals.map((deal: { quantity: string; price: string; validTill: string }, index: number) => (
|
|
<View key={index} style={tw`flex-row justify-between items-center p-3 bg-amber-50 rounded-xl border border-amber-100 mb-2`}>
|
|
<MyText style={tw`text-amber-900 font-medium`}>Buy {deal.quantity} {formatQuantity(parseFloat(deal.quantity), productDetail.unitNotation).display}</MyText>
|
|
<MyText style={tw`text-amber-900 font-bold text-lg`}>₹{deal.price}</MyText>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Reviews Section */}
|
|
<View style={tw`px-4 mb-8`}>
|
|
|
|
{/* Review Form - Moved above or keep below? Usually users want to read reviews first, but if few reviews, writing is good. The original had form then reviews. I will keep format but make it nicer. */}
|
|
<View style={tw`mb-6`}>
|
|
<ReviewForm productId={productDetail.id} onReviewSubmitted={handleReviewSubmitted} />
|
|
</View>
|
|
|
|
<View style={tw`bg-white rounded-3xl shadow-sm border border-gray-100 overflow-hidden`}>
|
|
<View style={tw`p-5 border-b border-gray-100 bg-gray-50 flex-row justify-between items-center`}>
|
|
<View>
|
|
<MyText style={tw`text-xl font-bold text-gray-900`}>Customer Reviews</MyText>
|
|
<MyText style={tw`text-gray-500 text-xs mt-1`}>What others are saying</MyText>
|
|
</View>
|
|
<MaterialIcons name="forum" size={24} color={theme.colors.brand500} style={tw`opacity-80`} />
|
|
</View>
|
|
|
|
<View style={tw`p-2`}>
|
|
<FlatList
|
|
data={reviews}
|
|
keyExtractor={(item) => item.id.toString()}
|
|
renderItem={({ item }) => (
|
|
<View style={tw`mb-3 mx-2 p-4 bg-white rounded-2xl border border-gray-100 shadow-sm`}>
|
|
<View style={tw`flex-row items-start mb-3`}>
|
|
{/* User Avatar Placeholder */}
|
|
<View style={tw`w-10 h-10 rounded-full bg-blue-100 items-center justify-center mr-3`}>
|
|
<MyText style={tw`text-blue-600 font-bold text-lg`}>
|
|
{item.userName ? item.userName.charAt(0).toUpperCase() : 'U'}
|
|
</MyText>
|
|
</View>
|
|
|
|
<View style={tw`flex-1`}>
|
|
<View style={tw`flex-row justify-between items-center`}>
|
|
<MyText style={tw`font-bold text-gray-900 text-base`}>{item.userName}</MyText>
|
|
<MyText style={tw`text-xs text-gray-400`}>{dayjs(item.reviewTime).format('MMM DD, YYYY')}</MyText>
|
|
</View>
|
|
<View style={tw`flex-row mt-0.5`}>
|
|
{[1, 2, 3, 4, 5].map((star) => (
|
|
<MaterialIcons
|
|
key={star}
|
|
name={star <= item.ratings ? 'star' : 'star-border'}
|
|
size={14}
|
|
color="#F59E0B"
|
|
/>
|
|
))}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
<MyText style={tw`text-gray-700 leading-6 mb-3 text-sm`}>{item.reviewBody}</MyText>
|
|
|
|
{item.signedImageUrls && item.signedImageUrls.length > 0 && (
|
|
<View style={tw`my-2`}>
|
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
{/* Using ImageCarousel for simplicity if it handles list, but it seems to be a slideshow.
|
|
Original code used ImageCarousel. Let's make it more "gallery" like if possible or stick to carousel if it's robust.
|
|
Actually, a row of thumbnails is better for reviews.
|
|
But to avoid breaking changes if ImageCarousel is needed for signed URLs logic (headers etc), I will trust ImageCarousel or just map standard Images if standard urls.
|
|
The original code used ImageCarousel. I'll stick to it to ensure images load correctly.
|
|
*/}
|
|
<View style={{ height: 100, width: 200 }}>
|
|
<ImageCarousel
|
|
urls={item.signedImageUrls}
|
|
imageWidth={200}
|
|
imageHeight={100}
|
|
showPaginationDots={true}
|
|
/>
|
|
</View>
|
|
</ScrollView>
|
|
</View>
|
|
)}
|
|
|
|
{item.adminResponse && (
|
|
<View style={tw`mt-3 p-4 bg-gray-50 rounded-xl border border-gray-100`}>
|
|
<View style={tw`flex-row items-center mb-2`}>
|
|
<View style={tw`w-5 h-5 rounded-full bg-green-100 items-center justify-center mr-2`}>
|
|
<MaterialIcons name="store" size={12} color="green" />
|
|
</View>
|
|
<MyText style={tw`text-gray-900 font-bold text-sm`}>Seller Response</MyText>
|
|
</View>
|
|
<MyText style={tw`text-gray-600 text-sm leading-5`}>{item.adminResponse}</MyText>
|
|
{item.signedAdminImageUrls && item.signedAdminImageUrls.length > 0 && (
|
|
<View style={{ marginTop: 8, height: 100, width: 200 }}>
|
|
<ImageCarousel
|
|
urls={item.signedAdminImageUrls}
|
|
imageWidth={200}
|
|
imageHeight={100}
|
|
showPaginationDots={false}
|
|
/>
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
onEndReached={() => loadReviews()}
|
|
onEndReachedThreshold={0.5}
|
|
ListEmptyComponent={
|
|
<View style={tw`items-center justify-center py-10`}>
|
|
<View style={tw`w-16 h-16 bg-gray-50 rounded-full items-center justify-center mb-3`}>
|
|
<MaterialIcons name="rate-review" size={32} color={theme.colors.gray2} style={tw`opacity-50 text-gray-300`} />
|
|
</View>
|
|
<MyText style={tw`text-gray-400 font-medium`}>No reviews yet.</MyText>
|
|
<MyText style={tw`text-gray-300 text-xs mt-1`}>Be the first to share your thoughts!</MyText>
|
|
</View>
|
|
}
|
|
ListFooterComponent={reviewsLoading ? <View style={tw`py-4`}><MyText style={tw`text-center text-gray-400 text-xs`}>Loading more reviews...</MyText></View> : null}
|
|
scrollEnabled={false}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</ScrollView>
|
|
|
|
{/* All Slots Dialog */}
|
|
<BottomDialog open={showAllSlots} onClose={() => setShowAllSlots(false)}>
|
|
<View style={tw`p-6 max-h-[500px]`}>
|
|
<View style={tw`flex-row items-center mb-6`}>
|
|
<View style={tw`w-10 h-10 bg-blue-50 rounded-full items-center justify-center mr-3`}>
|
|
<MaterialIcons name="schedule" size={20} color="#3B82F6" />
|
|
</View>
|
|
<MyText style={tw`text-xl font-bold text-gray-900`}>All Delivery Slots</MyText>
|
|
</View>
|
|
|
|
<ScrollView showsVerticalScrollIndicator={false}>
|
|
{productDetail.deliverySlots.map((slot, index) => (
|
|
<MyTouchableOpacity
|
|
key={index}
|
|
style={tw`flex-row items-start mb-4 bg-gray-50 p-4 rounded-xl border border-gray-100`}
|
|
onPress={() => handleSlotAddToCart(productDetail.id, slot.id)}
|
|
disabled={productDetail.isOutOfStock}
|
|
activeOpacity={0.7}
|
|
>
|
|
<MaterialIcons name="local-shipping" size={20} color="#3B82F6" style={tw`mt-0.5`} />
|
|
<View style={tw`ml-3 flex-1`}>
|
|
<MyText style={tw`text-gray-900 font-bold text-base`}>
|
|
{dayjs(slot.deliveryTime).format('ddd, DD MMM • h:mm A')}
|
|
</MyText>
|
|
<MyText style={tw`text-sm text-gray-500 mt-1`}>
|
|
Orders Close: {dayjs(slot.freezeTime).format('h:mm A')}
|
|
</MyText>
|
|
</View>
|
|
<MaterialIcons name="add" size={24} color="#3B82F6" style={tw`mt-0.5`} />
|
|
</MyTouchableOpacity>
|
|
))}
|
|
</ScrollView>
|
|
|
|
<MyTouchableOpacity
|
|
style={tw`mt-4 bg-gray-900 py-3.5 rounded-xl items-center`}
|
|
onPress={() => setShowAllSlots(false)}
|
|
>
|
|
<MyText style={tw`text-white font-bold`}>Close</MyText>
|
|
</MyTouchableOpacity>
|
|
</View>
|
|
</BottomDialog>
|
|
|
|
<LoadingDialog open={isLoadingDialogOpen} message="Processing..." />
|
|
<View style={tw`h-8`}></View>
|
|
<View style={tw`absolute bottom-2 left-4 right-4`}>
|
|
<FloatingCartBar isFlashDelivery={isFlashDelivery} />
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
interface ReviewFormProps {
|
|
productId: number;
|
|
onReviewSubmitted?: () => void;
|
|
}
|
|
|
|
const ReviewForm = ({ productId, onReviewSubmitted }: ReviewFormProps) => {
|
|
const [reviewBody, setReviewBody] = useState('');
|
|
const [ratings, setRatings] = useState(0);
|
|
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
|
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
|
|
|
|
|
const createReview = trpc.user.product.createReview.useMutation();
|
|
const generateUploadUrls = trpc.user.fileUpload.generateUploadUrls.useMutation();
|
|
|
|
const handleImagePick = usePickImage({
|
|
setFile: async (assets: any) => {
|
|
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
|
setSelectedImages([]);
|
|
setDisplayImages([]);
|
|
return;
|
|
}
|
|
|
|
const files = Array.isArray(assets) ? assets : [assets];
|
|
const blobPromises = files.map(async (asset) => {
|
|
const response = await fetch(asset.uri);
|
|
const blob = await response.blob();
|
|
return { blob, mimeType: asset.mimeType || 'image/jpeg' };
|
|
});
|
|
|
|
const blobArray = await Promise.all(blobPromises);
|
|
setSelectedImages(blobArray);
|
|
setDisplayImages(files.map(asset => ({ uri: asset.uri })));
|
|
},
|
|
multiple: true,
|
|
});
|
|
|
|
const handleRemoveImage = (uri: string) => {
|
|
const index = displayImages.findIndex(img => img.uri === uri);
|
|
if (index !== -1) {
|
|
const newDisplay = displayImages.filter((_, i) => i !== index);
|
|
const newFiles = selectedImages.filter((_, i) => i !== index);
|
|
|
|
setDisplayImages(newDisplay);
|
|
setSelectedImages(newFiles);
|
|
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!reviewBody.trim() || ratings === 0) {
|
|
Alert.alert('Error', 'Please provide a review and rating.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Generate upload URLs
|
|
const mimeTypes = selectedImages.map(s => s.mimeType);
|
|
const { uploadUrls: generatedUrls } = await generateUploadUrls.mutateAsync({
|
|
contextString: 'review',
|
|
mimeTypes,
|
|
});
|
|
const keys = generatedUrls.map(extractKeyFromUrl);
|
|
|
|
// Upload images
|
|
for (let i = 0; i < generatedUrls.length; i++) {
|
|
const uploadUrl = generatedUrls[i];
|
|
const key = keys[i];
|
|
const { blob, mimeType } = selectedImages[i];
|
|
|
|
|
|
const uploadResponse = await fetch(uploadUrl, {
|
|
method: 'PUT',
|
|
body: blob,
|
|
headers: {
|
|
'Content-Type': mimeType,
|
|
},
|
|
});
|
|
|
|
if (!uploadResponse.ok) {
|
|
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
|
}
|
|
}
|
|
|
|
// Submit review with image URLs
|
|
await createReview.mutateAsync({
|
|
productId,
|
|
reviewBody,
|
|
ratings,
|
|
imageUrls: keys,
|
|
uploadUrls: generatedUrls,
|
|
});
|
|
|
|
Alert.alert('Success', 'Review submitted!');
|
|
onReviewSubmitted?.();
|
|
// Reset form
|
|
setReviewBody('');
|
|
setRatings(0);
|
|
setSelectedImages([]);
|
|
setDisplayImages([]);
|
|
} catch (error:any) {
|
|
|
|
Alert.alert('Error', error.message || 'Failed to submit review.');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<View style={tw`bg-white p-6 rounded-3xl shadow-sm border border-gray-100 mx-1`}>
|
|
<MyText style={tw`text-xl font-bold text-gray-900 mb-2 text-center`}>Rate this Product</MyText>
|
|
<MyText style={tw`text-sm text-gray-500 mb-6 text-center`}>Share your experience with others</MyText>
|
|
|
|
{/* Rating */}
|
|
<View style={tw`mb-6 items-center`}>
|
|
<View style={tw`flex-row justify-center gap-2`}>
|
|
{[1, 2, 3, 4, 5].map((star) => (
|
|
<MyTouchableOpacity key={star} onPress={() => setRatings(star)} activeOpacity={0.7}>
|
|
<MaterialIcons
|
|
name={star <= ratings ? 'star' : 'star-border'}
|
|
size={36}
|
|
color={star <= ratings ? '#F59E0B' : '#E5E7EB'}
|
|
/>
|
|
</MyTouchableOpacity>
|
|
))}
|
|
</View>
|
|
<MyText style={tw`text-xs text-gray-400 mt-2 font-medium`}>
|
|
{ratings === 0 ? 'Tap to Rate' : ratings === 1 ? 'Poor' : ratings === 2 ? 'Fair' : ratings === 3 ? 'Good' : ratings === 4 ? 'Very Good' : 'Excellent'}
|
|
</MyText>
|
|
</View>
|
|
|
|
{/* Review Text */}
|
|
<View style={tw`bg-gray-50 rounded-2xl p-3 mb-4 border border-gray-200`}>
|
|
<MyTextInput
|
|
style={[tw`text-gray-900 text-base`, { minHeight: 100, textAlignVertical: 'top' }]}
|
|
placeholder="What did you like or dislike?"
|
|
placeholderTextColor="#9CA3AF"
|
|
value={reviewBody}
|
|
onChangeText={setReviewBody}
|
|
multiline
|
|
/>
|
|
</View>
|
|
|
|
{/* Images */}
|
|
<View style={tw`mb-6`}>
|
|
<ImageUploader
|
|
images={displayImages}
|
|
existingImageUrls={[]}
|
|
onAddImage={handleImagePick}
|
|
onRemoveImage={handleRemoveImage}
|
|
/>
|
|
</View>
|
|
|
|
{/* Submit */}
|
|
<MyTouchableOpacity
|
|
onPress={handleSubmit}
|
|
disabled={createReview.isPending}
|
|
activeOpacity={0.9}
|
|
>
|
|
<LinearGradient
|
|
colors={createReview.isPending ? ['#d1d5db', '#d1d5db'] : [theme.colors.brand500 || '#2E90FA', theme.colors.red1 || '#D84343']}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 0 }}
|
|
style={tw`py-4 rounded-xl items-center shadow-md`}
|
|
>
|
|
<MyText style={tw`text-white font-bold text-lg`}>
|
|
{createReview.isPending ? 'Submitting...' : 'Submit Review'}
|
|
</MyText>
|
|
</LinearGradient>
|
|
</MyTouchableOpacity>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default ProductDetail; |