Files
info-target-mobile/components/ui/ProductList.tsx
Samandar Turgunboyev ab363ca3b9 bug fixed
2026-03-02 13:22:55 +05:00

404 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useTheme } from '@/components/ThemeContext';
import { products_api } from '@/screens/home/lib/api';
import { ProductResponse } from '@/screens/home/lib/types';
import { user_api } from '@/screens/profile/lib/api';
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { ResizeMode, Video } from 'expo-av';
import { Info, Package, PlayCircle } from 'lucide-react-native';
import React, { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Dimensions,
Image,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const IMAGE_WIDTH = SCREEN_WIDTH - 40;
const PAGE_SIZE = 10;
type Props = { query: string };
export default function ProductList({ query }: Props) {
const { t } = useTranslation();
const { isDark } = useTheme();
const trimmedQuery = query.trim();
const [selectedProduct, setSelectedProduct] = useState<ProductResponse | null>(null);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['products-list', trimmedQuery],
queryFn: async ({ pageParam = 1 }) => {
const response = await products_api.getProducts({
page: pageParam,
page_size: PAGE_SIZE,
search: trimmedQuery,
});
return response.data.data;
},
getNextPageParam: (lastPage) =>
lastPage.current_page < lastPage.total_pages ? lastPage.current_page + 1 : undefined,
initialPageParam: 1,
});
const allProducts = data?.pages.flatMap((p) => p.results) ?? [];
const {
data: detail,
isLoading: loadingDetail,
isError: detailError,
} = useQuery({
queryKey: ['my_ads_id', selectedProduct],
queryFn: () => user_api.detail_service(Number(selectedProduct?.id)),
select: (res) => res.data.data,
enabled: !!selectedProduct,
});
const handlePresentModalPress = useCallback((product: ProductResponse) => {
setSelectedProduct(product);
setCurrentImageIndex(0);
bottomSheetModalRef.current?.present();
}, []);
const renderBackdrop = useCallback(
(props: any) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
),
[]
);
const isVideoFile = (url?: string) => url?.toLowerCase().match(/\.(mp4|mov|avi|mkv|webm)$/i);
const renderItem = ({ item }: { item: ProductResponse }) => {
const mainFile = item.files?.[0]?.file;
const isVideo = isVideoFile(mainFile);
return (
<TouchableOpacity
style={[styles.card, isDark ? styles.darkCard : styles.lightCard]}
activeOpacity={0.85}
onPress={() => handlePresentModalPress(item)}
>
<View style={[styles.imageContainer, isDark ? styles.darkImageBg : styles.lightImageBg]}>
{mainFile ? (
<>
<Image source={{ uri: mainFile }} style={styles.image} resizeMode="cover" />
{isVideo && (
<View style={styles.videoIconOverlay}>
<PlayCircle size={36} color="white" fill="rgba(0,0,0,0.35)" />
</View>
)}
</>
) : (
<View style={styles.placeholder}>
<Package size={40} color={isDark ? '#64748b' : '#cbd5e1'} />
</View>
)}
</View>
<View style={styles.info}>
<Text
style={[styles.title, isDark ? styles.darkText : styles.lightText]}
numberOfLines={2}
>
{item.title}
</Text>
<View style={styles.companyRow}>
<Text style={[styles.companyText, isDark ? styles.darkSubText : styles.lightSubText]}>
{item.company}
</Text>
</View>
</View>
</TouchableOpacity>
);
};
const renderCarouselItem = ({ item }: { item: { id: number; file: string } }) => {
const isVideo = isVideoFile(item.file);
return (
<View style={styles.carouselImageContainer}>
{isVideo ? (
<Video
source={{ uri: item.file }}
style={styles.carouselImage}
resizeMode={ResizeMode.COVER}
useNativeControls
/>
) : (
<Image source={{ uri: item.file }} style={styles.carouselImage} resizeMode="cover" />
)}
</View>
);
};
if (isLoading && !data)
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#3b82f6" />
</View>
);
if (isError)
return (
<View style={styles.center}>
<Text style={{ color: '#f87171' }}>{t('Xatolik yuz berdi')}</Text>
</View>
);
return (
<>
<FlatList
data={allProducts}
nestedScrollEnabled={true}
keyExtractor={(item) => item.id.toString()}
renderItem={renderItem}
numColumns={2}
columnWrapperStyle={{ justifyContent: 'space-between', marginBottom: 20 }}
contentContainerStyle={{ paddingBottom: 60 }}
onEndReached={() => hasNextPage && !isFetchingNextPage && fetchNextPage()}
onEndReachedThreshold={0.4}
ListFooterComponent={
isFetchingNextPage ? (
<View style={styles.footerLoader}>
<ActivityIndicator size="small" color="#3b82f6" />
</View>
) : null
}
ListEmptyComponent={
data ? (
<View style={styles.center}>
<Text style={{ color: '#94a3b8' }}>{t('Natija topilmadi')}</Text>
</View>
) : null
}
/>
<BottomSheetModal
ref={bottomSheetModalRef}
index={0}
snapPoints={['70%', '95%']}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{ backgroundColor: '#94a3b8', width: 50 }}
backgroundStyle={{ backgroundColor: isDark ? '#0f172a' : '#ffffff' }}
enablePanDownToClose
>
<BottomSheetScrollView
style={styles.sheetContent}
contentContainerStyle={styles.sheetContentContainer}
>
{/* Loading holati */}
{loadingDetail && (
<View style={styles.center}>
<ActivityIndicator size="large" color="#3b82f6" />
</View>
)}
{/* Error holati */}
{detailError && (
<View style={styles.center}>
<Text style={{ color: '#ef4444', fontWeight: '600' }}>
{t('Xatolik yuz berdi')}
</Text>
</View>
)}
{/* Detail mavjud bolsa */}
{detail && (
<>
<View style={styles.carouselWrapper}>
<FlatList
nestedScrollEnabled={true}
data={detail.files || []}
renderItem={renderCarouselItem}
keyExtractor={(item) => item.id.toString()}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={(e) => {
const index = Math.round(e.nativeEvent.contentOffset.x / IMAGE_WIDTH);
setCurrentImageIndex(index);
}}
/>
{detail.files.length > 1 && (
<View style={styles.pagination}>
{detail.files.map((_, i) => (
<View
key={i}
style={[
styles.paginationDot,
currentImageIndex === i && styles.paginationDotActive,
]}
/>
))}
</View>
)}
</View>
<View style={styles.sheetHeader}>
<Text style={[styles.sheetTitle, isDark ? styles.darkText : styles.lightText]}>
{detail.title}
</Text>
<View style={styles.sheetCompanyBadge}>
<Text style={styles.sheetCompanyText}>{detail.company}</Text>
</View>
</View>
<View style={[styles.divider, isDark ? styles.darkDivider : styles.lightDivider]} />
<View style={styles.section}>
<View style={styles.sectionTitleRow}>
<Info size={18} color={isDark ? '#f1f5f9' : '#0f172a'} />
<Text style={[styles.sectionLabel, isDark ? styles.darkText : styles.lightText]}>
{t("Batafsil ma'lumot")}
</Text>
</View>
<Text style={[styles.sheetDescription, isDark ? styles.darkText : styles.lightText]}>
{detail.description || "Ma'lumot mavjud emas."}
</Text>
</View>
</>
)}
</BottomSheetScrollView>
</BottomSheetModal>
</>
);
}
const styles = StyleSheet.create({
listContainer: { gap: 0, paddingBottom: 20 },
card: {
borderRadius: 16,
overflow: 'hidden',
width: (SCREEN_WIDTH - 40) / 2,
marginLeft: 2,
shadowColor: '#000',
shadowOpacity: 0.15,
shadowOffset: { width: 0, height: 4 },
shadowRadius: 50,
elevation: 4,
},
darkCard: {
backgroundColor: '#1e293b',
},
lightCard: {
backgroundColor: '#ffffff',
},
imageContainer: {
width: '100%',
height: 160,
},
darkImageBg: {
backgroundColor: '#0f172a',
},
lightImageBg: {
backgroundColor: '#f1f5f9',
},
image: { width: '100%', height: '100%' },
videoIconOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.25)',
},
placeholder: { flex: 1, alignItems: 'center', justifyContent: 'center' },
info: { padding: 12 },
title: {
fontSize: 16,
fontWeight: '700',
marginBottom: 4,
},
darkText: {
color: '#f1f5f9',
},
lightText: {
color: '#0f172a',
},
companyRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
companyText: {
fontSize: 13,
},
darkSubText: {
color: '#64748b',
},
lightSubText: {
color: '#94a3b8',
},
// Bottom Sheet
sheetContent: { flex: 1 },
sheetContentContainer: { paddingHorizontal: 20, paddingBottom: 40 },
carouselWrapper: {
width: IMAGE_WIDTH,
height: 280,
marginBottom: 20,
borderRadius: 16,
overflow: 'hidden',
alignSelf: 'center',
},
carouselImageContainer: { width: IMAGE_WIDTH, height: 280, backgroundColor: '#e2e8f0' },
carouselImage: { width: '100%', height: '100%' },
pagination: {
position: 'absolute',
bottom: 12,
flexDirection: 'row',
width: '100%',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
},
paginationDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: 'rgba(255,255,255,0.4)' },
paginationDotActive: { backgroundColor: '#3b82f6', width: 20 },
sheetHeader: { marginBottom: 16 },
sheetTitle: {
fontSize: 20,
fontWeight: '800',
marginBottom: 8,
},
sheetCompanyBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: '#3b82f6',
alignSelf: 'flex-start',
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 12,
},
sheetCompanyText: { fontSize: 13, color: '#ffffff', fontWeight: '700' },
divider: {
height: 1,
marginBottom: 20,
},
darkDivider: {
backgroundColor: '#334155',
},
lightDivider: {
backgroundColor: '#e2e8f0',
},
section: { marginBottom: 20 },
sectionTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 },
sectionLabel: {
fontSize: 16,
fontWeight: '700',
},
sheetDescription: {
fontSize: 15,
lineHeight: 22,
},
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
footerLoader: { paddingVertical: 20, alignItems: 'center' },
});