freshyo/apps/admin-ui/app/(drawer)/product-detail/[id].tsx
2026-01-24 00:13:15 +05:30

547 lines
No EOL
24 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { View, Text, ScrollView, TouchableOpacity, Alert, TextInput, Dimensions, ActivityIndicator, Platform } from 'react-native';
import { Image } from 'expo-image';
import { useRouter, useLocalSearchParams, Stack } from 'expo-router';
import { tw, AppContainer, MyText, useMarkDataFetchers, BottomDialog, ImageUploader, ImageCarousel } from 'common-ui';
import { MaterialIcons, FontAwesome5, Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons';
import { trpc } from '@/src/trpc-client';
import usePickImage from 'common-ui/src/components/use-pick-image';
import { Formik } from 'formik';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import Animated, { FadeInDown, FadeInUp, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
const { width: screenWidth } = Dimensions.get("window");
const carouselHeight = screenWidth * 0.85;
interface ReviewResponseFormProps {
reviewId: number;
onClose: () => void;
}
const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClose }) => {
const [adminResponse, setAdminResponse] = useState('');
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
const [uploadUrls, setUploadUrls] = useState<string[]>([]);
const respondToReview = trpc.admin.product.respondToReview.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 (adminResponse: string) => {
try {
const mimeTypes = selectedImages.map(s => s.mimeType);
const { uploadUrls: generatedUrls } = await generateUploadUrls.mutateAsync({
contextString: 'review',
mimeTypes,
});
const keys = generatedUrls.map(url => {
const u = new URL(url);
const rawKey = u.pathname.replace(/^\/+/, "");
const decodedKey = decodeURIComponent(rawKey);
const parts = decodedKey.split('/');
parts.shift();
return parts.join('/');
});
setUploadUrls(generatedUrls);
for (let i = 0; i < generatedUrls.length; i++) {
const uploadUrl = generatedUrls[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}`);
}
await respondToReview.mutateAsync({
reviewId,
adminResponse,
adminResponseImages: keys,
uploadUrls: generatedUrls,
});
Alert.alert('Success', 'Response submitted');
onClose();
setAdminResponse('');
setSelectedImages([]);
setDisplayImages([]);
setUploadUrls([]);
} catch (error:any) {
Alert.alert('Error', error.message || 'Failed to submit response.');
}
};
return (
<Formik
initialValues={{ adminResponse: '', images: [] }}
onSubmit={(values) => handleSubmit(values.adminResponse)}
>
{({ handleChange, handleSubmit: formikSubmit, values }) => (
<View>
<TextInput
style={tw`border border-gray-200 bg-gray-50 rounded-2xl p-4 mb-4 h-32 text-gray-900 text-base shadow-sm`}
placeholder="Write your response here..."
placeholderTextColor="#9CA3AF"
value={values.adminResponse}
onChangeText={handleChange('adminResponse')}
multiline
textAlignVertical="top"
/>
<View style={tw`mb-6`}>
<MyText style={tw`text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider`}>Attach Images</MyText>
<ImageUploader
images={displayImages}
existingImageUrls={[]}
onAddImage={handleImagePick}
onRemoveImage={handleRemoveImage}
/>
</View>
<TouchableOpacity
onPress={() => formikSubmit()}
activeOpacity={0.8}
disabled={respondToReview.isPending}
>
<LinearGradient
colors={['#2563EB', '#1D4ED8']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={tw`py-4 rounded-2xl items-center shadow-lg`}
>
{respondToReview.isPending ? (
<ActivityIndicator color="white" />
) : (
<MyText style={tw`text-white font-bold text-lg`}>Submit Response</MyText>
)}
</LinearGradient>
</TouchableOpacity>
</View>
)}
</Formik>
);
};
export default function ProductDetail() {
const { id } = useLocalSearchParams();
const router = useRouter();
const productId = parseInt(id as string);
const { data: productData, isLoading, error, refetch } = trpc.admin.product.getProductById.useQuery({ id: productId });
const { data: reviewsData } = trpc.admin.product.getProductReviews.useQuery({ productId });
const [responseDialogOpen, setResponseDialogOpen] = useState(false);
const [selectedReview, setSelectedReview] = useState<any>(null);
useMarkDataFetchers(() => {
refetch();
});
const toggleOutOfStock = trpc.admin.product.toggleOutOfStock.useMutation();
const product = productData?.product;
const handleEdit = () => {
router.push(`/edit-product?id=${productId}` as any);
};
if (isLoading) {
return (
<View style={tw`flex-1 justify-center items-center bg-white`}>
<ActivityIndicator size="large" color="#3B82F6" />
<MyText style={tw`text-gray-500 mt-4 font-medium`}>Loading...</MyText>
</View>
);
}
if (error || !product) {
return (
<View style={tw`flex-1 justify-center items-center bg-white`}>
<MaterialIcons name="error-outline" size={64} color="#EF4444" />
<MyText style={tw`text-gray-900 text-xl font-bold mt-4`}>Product Not Found</MyText>
<TouchableOpacity onPress={() => router.back()} style={tw`mt-6 px-8 py-3 bg-gray-100 rounded-full`}>
<Text style={tw`text-gray-800 font-semibold`}>Go Back</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={tw`flex-1 bg-gray-50`}>
<Stack.Screen options={{ headerShown: false }} />
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-32`} showsVerticalScrollIndicator={false}>
{/* Hero Section */}
<View style={tw`relative`}>
{product.images && product.images.length > 0 ? (
<ImageCarousel
urls={product.images}
imageWidth={screenWidth}
imageHeight={carouselHeight}
showPaginationDots={true}
/>
) : (
<View style={{ width: screenWidth, height: carouselHeight, backgroundColor: '#E5E7EB', alignItems: 'center', justifyContent: 'center' }} />
)}
{/* Gradient Overlay */}
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.05)', 'rgba(0,0,0,0.3)']}
style={tw`absolute bottom-0 left-0 right-0 h-24`}
/>
{/* Floating Header Buttons */}
<View style={tw`absolute top-${Platform.OS === 'ios' ? '12' : '8'} left-4 right-4 flex-row justify-between items-center z-10`}>
<TouchableOpacity onPress={() => router.back()} activeOpacity={0.8}>
<BlurView intensity={80} tint="dark" style={tw`w-10 h-10 rounded-full items-center justify-center overflow-hidden`}>
<Ionicons name="arrow-back" size={24} color="white" />
</BlurView>
</TouchableOpacity>
<TouchableOpacity onPress={handleEdit} activeOpacity={0.8}>
<BlurView intensity={80} tint="dark" style={tw`px-4 py-2 rounded-full flex-row items-center overflow-hidden`}>
<Feather name="edit-2" size={16} color="white" />
<Text style={tw`text-white font-bold ml-2`}>Edit</Text>
</BlurView>
</TouchableOpacity>
</View>
</View>
{/* Content Container - Overlapping the image slightly */}
<View style={tw`-mt-8 rounded-t-[32px] bg-gray-50 overflow-hidden`}>
{/* Main Info Card */}
<Animated.View entering={FadeInUp.delay(100).duration(500)} style={tw`bg-white px-6 pt-8 pb-6 rounded-b-[32px] shadow-sm mb-4`}>
<View style={tw`flex-row justify-between items-start mb-2`}>
<MyText style={tw`text-3xl font-extrabold text-gray-900 flex-1 mr-4 leading-tight`}>{product.name}</MyText>
<TouchableOpacity
onPress={() => {
toggleOutOfStock.mutate({ id: productId }, {
onSuccess: () => Alert.alert('Success', 'Stock status updated'),
onError: (err) => Alert.alert('Error', err.message)
});
}}
activeOpacity={0.9}
>
<LinearGradient
colors={product.isOutOfStock ? ['#EF4444', '#DC2626'] : ['#10B981', '#059669']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={tw`px-4 py-1.5 rounded-full shadow-sm`}
>
<Text style={tw`text-white text-xs font-bold uppercase tracking-wide`}>
{product.isOutOfStock ? 'Out of Stock' : 'In Stock'}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
<View style={tw`flex-row items-end mt-2`}>
<Text style={tw`text-4xl font-black text-gray-900`}>{product.price}</Text>
<Text style={tw`text-gray-500 text-xl font-medium mb-1.5 ml-2`}>/ {product.unit?.shortNotation}</Text>
{product.marketPrice && (
<View style={tw`ml-4 mb-2 px-2 py-0.5 bg-red-50 rounded`}>
<Text style={tw`text-red-400 text-base line-through font-medium`}>{product.marketPrice}</Text>
</View>
)}
</View>
{/* Increment Step Info */}
<View style={tw`mt-3 flex-row items-center`}>
<View style={tw`bg-blue-50 px-3 py-1.5 rounded-full border border-blue-100`}>
<Text style={tw`text-blue-700 text-sm font-bold`}>
Increment: {product.incrementStep || 1}
</Text>
</View>
</View>
{/* Quick Stats Row */}
<View style={tw`flex-row mt-6 pt-6 border-t border-gray-100`}>
<View style={tw`flex-1 items-center border-r border-gray-100`}>
<View style={tw`flex-row items-center`}>
<MaterialIcons name="star" size={20} color="#F59E0B" />
<Text style={tw`text-lg font-bold text-gray-900 ml-1`}>
{reviewsData?.reviews.reduce((acc, r) => acc + r.ratings, 0)
? (reviewsData.reviews.reduce((acc, r) => acc + r.ratings, 0) / reviewsData.reviews.length).toFixed(1)
: '-'}
</Text>
</View>
<Text style={tw`text-xs text-gray-400 font-medium mt-1 uppercase`}>Rating</Text>
</View>
<View style={tw`flex-1 items-center border-r border-gray-100`}>
<Text style={tw`text-lg font-bold text-gray-900`}>{reviewsData?.reviews.length || 0}</Text>
<Text style={tw`text-xs text-gray-400 font-medium mt-1 uppercase`}>Reviews</Text>
</View>
{/* <View style={tw`flex-1 items-center`}>
<TouchableOpacity
onPress={() => {
toggleOutOfStock.mutate({ id: productId }, {
onSuccess: () => Alert.alert('Success', 'Stock status updated'),
onError: (err) => Alert.alert('Error', err.message)
});
}}
activeOpacity={0.9}
>
<LinearGradient
colors={product.isOutOfStock ? ['#EF4444', '#DC2626'] : ['#10B981', '#059669']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={tw`px-3 py-1 rounded-full shadow-sm`}
>
<Text style={tw`text-white text-xs font-bold uppercase tracking-wide`}>
{product.isOutOfStock ? 'Out of Stock' : 'In Stock'}
</Text>
</LinearGradient>
</TouchableOpacity>
<Text style={tw`text-xs text-gray-400 font-medium mt-1 uppercase`}>Stock</Text>
</View> */}
</View>
</Animated.View>
{/* Description */}
<Animated.View entering={FadeInDown.delay(200).duration(500)} style={tw`px-4 mb-4`}>
<View style={tw`bg-white p-6 rounded-3xl shadow-sm`}>
<View style={tw`flex-row items-center mb-4`}>
<View style={tw`w-10 h-10 bg-blue-50 rounded-full items-center justify-center mr-3`}>
<MaterialCommunityIcons name="text-box-outline" size={22} color="#2563EB" />
</View>
<Text style={tw`text-lg font-bold text-gray-900`}>Description</Text>
</View>
{product.shortDescription && (
<Text style={tw`text-gray-900 font-medium text-base mb-3 leading-relaxed`}>{product.shortDescription}</Text>
)}
<Text style={tw`text-gray-600 leading-7 text-base`}>
{product.longDescription || "No detailed description available for this product."}
</Text>
</View>
</Animated.View>
{/* Availability */}
<Animated.View entering={FadeInDown.delay(250).duration(500)} style={tw`px-4 mb-4`}>
<View style={tw`bg-white p-6 rounded-3xl shadow-sm`}>
<View style={tw`flex-row items-center mb-4`}>
<View style={tw`w-10 h-10 bg-blue-50 rounded-full items-center justify-center mr-3`}>
<MaterialIcons name="inventory" size={22} color="#2563EB" />
</View>
<Text style={tw`text-lg font-bold text-gray-900`}>Availability</Text>
</View>
<Text style={tw`text-gray-600 mb-4`}>
This product is currently {product.isOutOfStock ? 'out of stock' : 'in stock'}.
</Text>
<TouchableOpacity
onPress={() => {
toggleOutOfStock.mutate({ id: productId }, {
onSuccess: () => {
Alert.alert('Success', 'Stock status updated');
refetch();
},
onError: (err) => Alert.alert('Error', err.message)
});
}}
activeOpacity={0.8}
style={tw`bg-gray-100 px-4 py-2 rounded-full border border-gray-200 self-start`}
>
<Text style={tw`text-gray-700 font-bold text-sm`}>
Mark as {product.isOutOfStock ? 'In Stock' : 'Out of Stock'}
</Text>
</TouchableOpacity>
</View>
</Animated.View>
{/* Special Deals */}
{product.deals && product.deals.length > 0 && (
<Animated.View entering={FadeInDown.delay(300).duration(500)} style={tw`px-4 mb-4`}>
<LinearGradient
colors={['#FFFBEB', '#FEF3C7']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={tw`p-6 rounded-3xl shadow-sm border border-amber-100`}
>
<View style={tw`flex-row items-center mb-4`}>
<View style={tw`w-10 h-10 bg-amber-100 rounded-full items-center justify-center mr-3`}>
<MaterialIcons name="local-offer" size={22} color="#D97706" />
</View>
<Text style={tw`text-lg font-bold text-amber-900`}>Special Deals</Text>
</View>
{product.deals.map((deal, index) => (
<View key={index} style={tw`flex-row justify-between items-center bg-white/60 p-4 rounded-2xl mb-2 border border-amber-200/50`}>
<View>
<Text style={tw`text-amber-900 font-bold text-lg`}>Buy {deal.quantity}</Text>
<Text style={tw`text-amber-700 text-xs font-medium`}>Valid until {new Date(deal.validTill).toLocaleDateString()}</Text>
</View>
<View style={tw`items-end`}>
<Text style={tw`text-2xl font-black text-amber-600`}>{deal.price}</Text>
<Text style={tw`text-amber-700 text-xs font-medium`}>Total Price</Text>
</View>
</View>
))}
</LinearGradient>
</Animated.View>
)}
{/* Reviews Section */}
<Animated.View entering={FadeInDown.delay(400).duration(500)} style={tw`px-4 mb-8`}>
<View style={tw`bg-white p-6 rounded-3xl shadow-sm`}>
<View style={tw`flex-row justify-between items-center mb-6`}>
<View style={tw`flex-row items-center`}>
<View style={tw`w-10 h-10 bg-purple-50 rounded-full items-center justify-center mr-3`}>
<MaterialIcons name="rate-review" size={22} color="#7C3AED" />
</View>
<Text style={tw`text-lg font-bold text-gray-900`}>Reviews</Text>
</View>
<View style={tw`bg-gray-100 px-3 py-1 rounded-full`}>
<Text style={tw`text-xs font-bold text-gray-600`}>{reviewsData?.reviews.length || 0} Total</Text>
</View>
</View>
{reviewsData && reviewsData.reviews.length > 0 ? (
reviewsData.reviews.map((review, idx) => (
<View key={review.id} style={tw`mb-6 last:mb-0`}>
<View style={tw`flex-row items-start`}>
<View style={tw`flex-1 bg-gray-50 p-4 rounded-2xl`}>
<View style={tw`flex-row justify-between items-start mb-2`}>
<Text style={tw`font-bold text-gray-900 text-base`}>{review.userName}</Text>
<View style={tw`flex-row`}>
{[1, 2, 3, 4, 5].map((star) => (
<MaterialIcons
key={star}
name={star <= review.ratings ? 'star' : 'star-border'}
size={14}
color="#F59E0B"
/>
))}
</View>
</View>
<Text style={tw`text-gray-700 leading-relaxed mb-3`}>{review.reviewBody}</Text>
{review.signedImageUrls && review.signedImageUrls.length > 0 && (
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={tw`mb-3`}>
{review.signedImageUrls.map((url, index) => (
<Image key={index} source={{ uri: url }} style={tw`w-16 h-16 rounded-lg mr-2 bg-gray-200`} />
))}
</ScrollView>
)}
{/* Admin Response Section */}
{review.adminResponse ? (
<View style={tw`mt-3 pt-3 border-t border-gray-200`}>
<View style={tw`flex-row items-center mb-1`}>
<MaterialIcons name="verified-user" size={14} color="#2563EB" />
<Text style={tw`text-blue-700 font-bold text-xs ml-1`}>Admin Response</Text>
</View>
<Text style={tw`text-gray-600 text-sm leading-relaxed`}>{review.adminResponse}</Text>
{review.signedAdminImageUrls && review.signedAdminImageUrls.length > 0 && (
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={tw`mt-2`}>
{review.signedAdminImageUrls.map((url, index) => (
<Image key={index} source={{ uri: url }} style={tw`w-12 h-12 rounded-lg mr-2 bg-white`} />
))}
</ScrollView>
)}
</View>
) : (
<TouchableOpacity
onPress={() => {
setSelectedReview(review);
setResponseDialogOpen(true);
}}
style={tw`mt-2 self-end px-4 py-2 bg-white rounded-full border border-gray-200 shadow-sm flex-row items-center`}
>
<MaterialIcons name="reply" size={16} color="#059669" />
<Text style={tw`text-gray-700 font-bold text-xs ml-1`}>Reply</Text>
</TouchableOpacity>
)}
</View>
</View>
</View>
))
) : (
<View style={tw`py-10 items-center justify-center opacity-50`}>
<MaterialIcons name="chat-bubble-outline" size={48} color="#9CA3AF" />
<Text style={tw`text-gray-400 mt-2 font-medium`}>No reviews yet</Text>
</View>
)}
</View>
</Animated.View>
</View>
</ScrollView>
{/* Response Dialog */}
<BottomDialog open={responseDialogOpen} onClose={() => setResponseDialogOpen(false)}>
<View style={tw`p-6 max-h-[700px]`}>
<View style={tw`flex-row justify-between items-center mb-6`}>
<Text style={tw`text-2xl font-bold text-gray-900`}>Reply to Review</Text>
<TouchableOpacity onPress={() => setResponseDialogOpen(false)} style={tw`p-2 bg-gray-100 rounded-full`}>
<MaterialIcons name="close" size={20} color="#6B7280" />
</TouchableOpacity>
</View>
{selectedReview && (
<View>
<View style={tw`bg-gray-50 p-4 rounded-2xl mb-6 border border-gray-100`}>
<View style={tw`flex-row items-center mb-2`}>
<MaterialIcons name="format-quote" size={20} color="#9CA3AF" />
<Text style={tw`text-xs font-bold text-gray-500 uppercase ml-1`}>Replying to {selectedReview.userName}</Text>
</View>
<Text style={tw`text-gray-700 italic text-base leading-relaxed pl-2 border-l-2 border-gray-300`} numberOfLines={3}>
{selectedReview.reviewBody}
</Text>
</View>
<ReviewResponseForm
reviewId={selectedReview.id}
onClose={() => {
setResponseDialogOpen(false);
setSelectedReview(null);
}}
/>
</View>
)}
</View>
</BottomDialog>
</View>
);
}