fitst commit

This commit is contained in:
Samandar Turgunboyev
2026-01-28 18:26:50 +05:00
parent 166a55b1e9
commit 124798419b
196 changed files with 26627 additions and 421 deletions

View File

@@ -0,0 +1,48 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createContext, useContext, useEffect, useState } from 'react';
type AuthContextType = {
isAuthenticated: boolean;
isLoading: boolean;
login: (token: string) => Promise<void>;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const checkToken = async () => {
const token = await AsyncStorage.getItem('access_token');
setIsAuthenticated(!!token);
setIsLoading(false);
};
checkToken();
}, []);
const login = async (token: string) => {
await AsyncStorage.setItem('access_token', token);
setIsAuthenticated(true);
};
const logout = async () => {
await AsyncStorage.removeItem('access_token');
await AsyncStorage.removeItem('refresh_token');
setIsAuthenticated(false);
};
return (
<AuthContext.Provider value={{ isAuthenticated, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
};

View File

@@ -0,0 +1,25 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode, useState } from 'react';
const QueryProvider = ({ children }: { children: ReactNode }) => {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 1000 * 60 * 5,
refetchOnReconnect: false,
refetchOnMount: false,
refetchInterval: 1000 * 60 * 5,
},
},
})
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
export default QueryProvider;

View File

@@ -0,0 +1,59 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { Appearance } from 'react-native';
type ThemeType = 'light' | 'dark';
interface ThemeContextProps {
theme: ThemeType;
isDark: boolean;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextProps | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const systemTheme = Appearance.getColorScheme();
const [theme, setTheme] = useState<ThemeType>(systemTheme === 'dark' ? 'dark' : 'light');
// 🔹 Load saved theme
useEffect(() => {
(async () => {
const savedTheme = await AsyncStorage.getItem('APP_THEME');
if (savedTheme === 'dark' || savedTheme === 'light') {
setTheme(savedTheme);
}
})();
}, []);
// 🔹 Save theme
useEffect(() => {
AsyncStorage.setItem('APP_THEME', theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
return (
<ThemeContext.Provider
value={{
theme,
isDark: theme === 'dark',
toggleTheme,
}}
>
{children}
</ThemeContext.Provider>
);
}
// Custom hook
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error('useTheme must be used inside ThemeProvider');
}
return ctx;
}

View File

@@ -0,0 +1,151 @@
import { languages } from '@/constants/languages';
import { getLang, saveLang } from '@/hooks/storage.native';
import { useLanguage } from '@/i18n/useLanguage';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import { ArrowLeft, Check, ChevronDown, Globe } from 'lucide-react-native';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
export default function AuthHeader({ back = true }: { back?: boolean }) {
const router = useRouter();
const { language, changeLanguage, getLanguageName } = useLanguage();
const { i18n } = useTranslation();
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
useEffect(() => {
const loadLanguage = async () => {
const lang = await getLang();
if (lang === 'uz' || lang === 'ru' || lang === 'en') {
changeLanguage(lang);
}
};
loadLanguage();
}, [language]);
const selectLanguage = async (lang: string) => {
changeLanguage(lang as any);
queryClient.invalidateQueries();
await i18n.changeLanguage(lang);
await saveLang(lang);
setOpen(false);
};
return (
<View style={{ ...styles.header, justifyContent: back ? 'space-between' : 'flex-end' }}>
{/* Back */}
{back && (
<TouchableOpacity style={styles.back} onPress={() => router.back()} activeOpacity={0.7}>
<ArrowLeft size={22} color="#fff" />
</TouchableOpacity>
)}
{/* Language */}
<View>
<TouchableOpacity style={styles.langBtn} onPress={() => setOpen(!open)} activeOpacity={0.7}>
<Globe size={18} color="#94a3b8" />
<Text style={styles.langText}>{getLanguageName()}</Text>
<ChevronDown size={16} color="#94a3b8" />
</TouchableOpacity>
{open && (
<View style={styles.dropdown}>
{languages.map((l) => {
const active = language === l.code;
return (
<TouchableOpacity
key={l.code}
style={[styles.option, active && styles.optionActive]}
onPress={() => selectLanguage(l.code)}
>
<Text style={[styles.optionText, active && styles.optionTextActive]}>
{l.name}
</Text>
{active && (
<View style={styles.check}>
<Check size={14} color="#fff" />
</View>
)}
</TouchableOpacity>
);
})}
</View>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 12,
zIndex: 50,
},
back: {
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: 'rgba(255,255,255,0.1)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.15)',
},
langBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'rgba(255,255,255,0.1)',
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.15)',
},
langText: {
color: '#94a3b8',
fontWeight: '600',
fontSize: 14,
},
dropdown: {
position: 'absolute',
top: 52,
right: 0,
backgroundColor: '#fff',
borderRadius: 16,
padding: 8,
minWidth: 180,
elevation: 20,
},
option: {
flexDirection: 'row',
justifyContent: 'space-between',
padding: 12,
borderRadius: 12,
},
optionActive: {
backgroundColor: '#eff6ff',
},
optionText: {
color: '#475569',
fontWeight: '600',
},
optionTextActive: {
color: '#3b82f6',
},
check: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: '#3b82f6',
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@@ -0,0 +1,255 @@
import { useTheme } from '@/components/ThemeContext';
import { products_api } from '@/screens/home/lib/api';
import { useMutation, useQuery } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { ChevronLeft, ChevronRight } from 'lucide-react-native';
import React, { Dispatch, SetStateAction, useState } from 'react';
import {
ActivityIndicator,
FlatList,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
interface Category {
id: number;
name: string;
code: string;
external_id: string | null;
level: number;
is_leaf: boolean;
icon_name: string | null;
}
interface CategoryResponse {
data: {
data: Category[];
};
}
interface Props {
selectedCategories: Category | null;
setSelectedCategories: Dispatch<SetStateAction<Category | null>>;
}
interface HistoryItem {
parentId: number | null;
categories: Category[];
}
export default function CategorySelect({ selectedCategories, setSelectedCategories }: Props) {
const { isDark } = useTheme();
const [currentCategories, setCurrentCategories] = useState<Category[]>([]);
const [currentParentId, setCurrentParentId] = useState<number | null>(null);
const [history, setHistory] = useState<HistoryItem[]>([]);
// Root categories
const { isLoading: rootLoading, error: rootError } = useQuery<CategoryResponse>({
queryKey: ['categories'],
queryFn: async () => products_api.getCategorys(),
select(data) {
setCurrentCategories(data.data.data);
setCurrentParentId(null);
setHistory([]);
return data;
},
});
// Child categories
const { mutate, isPending: mutatePending } = useMutation({
mutationFn: (id: number) => products_api.getCategorys({ parent: id }),
onSuccess: (response: CategoryResponse, id) => {
const childCategories = response.data.data;
setHistory((prev) => [...prev, { parentId: currentParentId, categories: currentCategories }]);
setCurrentCategories(childCategories);
setCurrentParentId(id);
},
onError: (err: AxiosError) => {
console.error('Child category loading error:', err);
},
});
const toggleCategory = (category: Category) => {
if (category.is_leaf) {
setSelectedCategories(category);
} else {
mutate(category.id);
}
};
const goBack = () => {
if (history.length > 0) {
const previous = history[history.length - 1];
setCurrentCategories(previous.categories);
setCurrentParentId(previous.parentId);
setHistory((prev) => prev.slice(0, -1));
}
};
const isLoading = rootLoading || mutatePending;
const error = rootError;
const renderCategory = ({ item: category }: { item: Category }) => {
const isSelected = selectedCategories?.id === category.id;
return (
<TouchableOpacity
style={[
styles.chip,
isDark ? styles.darkChip : styles.lightChip,
isSelected && styles.chipSelected,
]}
onPress={() => toggleCategory(category)}
>
<Text
style={[
styles.chipText,
isDark ? styles.darkChipText : styles.lightChipText,
isSelected && styles.chipTextSelected,
]}
>
{category.name}
</Text>
{!category.is_leaf && (
<ChevronRight size={20} color={isSelected ? '#ffffff' : isDark ? '#64748b' : '#94a3b8'} />
)}
</TouchableOpacity>
);
};
if (isLoading && currentCategories.length === 0) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="small" color="#3b82f6" />
</View>
);
}
if (error && currentCategories.length === 0) {
return (
<View style={styles.centerContainer}>
<Text style={{ color: '#f87171' }}>Ma'lumot yuklashda xatolik yuz berdi</Text>
</View>
);
}
if (currentCategories.length === 0) {
return (
<View style={styles.centerContainer}>
<Text style={isDark ? styles.darkText : styles.lightText}>Kategoriyalar topilmadi</Text>
</View>
);
}
return (
<View style={[styles.container, isDark ? styles.darkBg : styles.lightBg]}>
<View style={styles.header}>
{history.length > 0 && (
<TouchableOpacity
onPress={goBack}
style={[styles.backButton, isDark ? styles.darkBackButton : styles.lightBackButton]}
>
<ChevronLeft color={'#3b82f6'} size={20} />
</TouchableOpacity>
)}
</View>
<FlatList
data={currentCategories}
renderItem={renderCategory}
keyExtractor={(item) => item.id.toString()}
scrollEnabled={false}
showsVerticalScrollIndicator={true}
ItemSeparatorComponent={() => <View style={{ height: 10 }} />}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
marginBottom: 20,
},
darkBg: {
backgroundColor: '#0f172a',
},
lightBg: {
backgroundColor: '#f8fafc',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginBottom: 12,
},
backButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
},
darkBackButton: {
backgroundColor: '#1e293b',
},
lightBackButton: {
backgroundColor: '#ffffff',
},
chip: {
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 20,
borderWidth: 1,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
darkChip: {
backgroundColor: '#1e293b',
borderColor: '#334155',
},
lightChip: {
backgroundColor: '#ffffff',
borderColor: '#e2e8f0',
},
chipSelected: {
backgroundColor: '#3b82f6',
borderColor: '#3b82f6',
shadowColor: '#3b82f6',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 3,
},
chipText: {
fontSize: 14,
fontWeight: '500',
flex: 1,
},
darkChipText: {
color: '#cbd5e1',
},
lightChipText: {
color: '#64748b',
},
chipTextSelected: {
color: '#ffffff',
fontWeight: '600',
},
darkText: {
color: '#f1f5f9',
},
lightText: {
color: '#0f172a',
},
});

164
components/ui/Combobox.tsx Normal file
View File

@@ -0,0 +1,164 @@
import { Check, ChevronDown } from 'lucide-react-native';
import React, { useState } from 'react';
import { FlatList, Modal, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
interface ComboboxProps {
value: string;
onChange: (value: string, label: string) => void;
items: { label: string; value: string }[];
placeholder?: string;
disabled?: boolean;
}
export default function Combobox({ value, onChange, items, placeholder, disabled }: ComboboxProps) {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const selectedItem = items.find((item) => item.value === value);
const displayText = selectedItem ? selectedItem.label : placeholder || 'Select...';
const filteredItems = items.filter((item) =>
item.label.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleSelect = (item: { label: string; value: string }) => {
onChange(item.value, item.label);
setIsOpen(false);
setSearchQuery('');
};
return (
<>
<TouchableOpacity
style={[styles.trigger, disabled && styles.triggerDisabled]}
onPress={() => !disabled && setIsOpen(true)}
disabled={disabled}
>
<Text style={[styles.triggerText, disabled && styles.triggerTextDisabled]}>
{displayText}
</Text>
<ChevronDown size={16} color={disabled ? '#9ca3af' : '#6b7280'} />
</TouchableOpacity>
<Modal
visible={isOpen}
transparent
animationType="slide"
onRequestClose={() => setIsOpen(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Select Option</Text>
<TouchableOpacity onPress={() => setIsOpen(false)}>
<Text style={styles.closeButton}>Done</Text>
</TouchableOpacity>
</View>
<TextInput
style={styles.searchInput}
placeholder="Search..."
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
/>
<FlatList
data={filteredItems}
keyExtractor={(item) => item.value}
renderItem={({ item }) => (
<TouchableOpacity style={styles.option} onPress={() => handleSelect(item)}>
<Text style={styles.optionText}>{item.label}</Text>
{value === item.value && <Check size={18} color="#2563eb" />}
</TouchableOpacity>
)}
style={styles.optionsList}
/>
</View>
</View>
</Modal>
</>
);
}
const styles = StyleSheet.create({
trigger: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
},
triggerDisabled: {
backgroundColor: '#f9fafb',
borderColor: '#e5e7eb',
},
triggerText: {
fontSize: 14,
color: '#374151',
flex: 1,
},
triggerTextDisabled: {
color: '#9ca3af',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: '#fff',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
maxHeight: '80%',
paddingBottom: 20,
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
modalTitle: {
fontSize: 18,
fontWeight: '600',
color: '#111827',
},
closeButton: {
fontSize: 16,
color: '#2563eb',
fontWeight: '600',
},
searchInput: {
margin: 16,
padding: 12,
backgroundColor: '#f9fafb',
borderRadius: 8,
fontSize: 14,
borderWidth: 1,
borderColor: '#e5e7eb',
},
optionsList: {
flex: 1,
},
option: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: '#f3f4f6',
},
optionText: {
fontSize: 15,
color: '#374151',
flex: 1,
},
});

View File

@@ -0,0 +1,315 @@
import { BASE_URL } from '@/api/URLs';
import { useTheme } from '@/components/ThemeContext';
import { products_api } from '@/screens/home/lib/api';
import { CompanyResponse } from '@/screens/home/lib/types';
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet';
import { useInfiniteQuery } from '@tanstack/react-query';
import { Building2, ChevronRight, MapPin, Package } from 'lucide-react-native';
import React, { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Dimensions,
FlatList,
Image,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
export default function CompanyList({ query }: { query: string }) {
const { isDark } = useTheme();
const { t } = useTranslation();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [selectedCompany, setSelectedCompany] = useState<CompanyResponse | null>(null);
const PAGE_SIZE = 10;
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['company-list', query],
queryFn: async ({ pageParam = 1 }) => {
const response = await products_api.getCompany({
page: pageParam,
page_size: PAGE_SIZE,
search: query,
});
return response.data.data;
},
getNextPageParam: (lastPage) =>
lastPage.current_page < lastPage.total_pages ? lastPage.current_page + 1 : undefined,
initialPageParam: 1,
});
const allCompanies = data?.pages.flatMap((page) => page.results) ?? [];
const handlePresentModal = useCallback((company: CompanyResponse) => {
setSelectedCompany(company);
bottomSheetModalRef.current?.present();
}, []);
const renderBackdrop = useCallback(
(props: any) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
),
[]
);
if (isLoading) {
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={allCompanies}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<TouchableOpacity
style={[styles.card, isDark ? styles.darkCard : styles.lightCard]}
activeOpacity={0.8}
onPress={() => handlePresentModal(item)}
>
<View style={styles.cardHeader}>
<Building2 size={22} color="#3b82f6" />
<Text style={[styles.cardTitle, isDark ? styles.darkText : styles.lightText]}>
{item.company_name}
</Text>
<ChevronRight size={20} color={isDark ? '#64748b' : '#94a3b8'} />
</View>
<View style={styles.cardLocation}>
<MapPin size={16} color={isDark ? '#64748b' : '#94a3b8'} />
<Text
style={[styles.cardLocationText, isDark ? styles.darkSubText : styles.lightSubText]}
>
{item.country_name}, {item.region_name}
</Text>
</View>
</TouchableOpacity>
)}
onEndReached={() => hasNextPage && !isFetchingNextPage && fetchNextPage()}
onEndReachedThreshold={0.4}
ListFooterComponent={
isFetchingNextPage ? <ActivityIndicator color="#3b82f6" style={{ margin: 20 }} /> : null
}
showsVerticalScrollIndicator={false}
/>
<BottomSheetModal
ref={bottomSheetModalRef}
index={0}
snapPoints={['60%', '90%']}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{ backgroundColor: '#94a3b8', width: 50 }}
backgroundStyle={{
backgroundColor: isDark ? '#0f172a' : '#ffffff',
borderRadius: 24,
}}
enablePanDownToClose
>
<BottomSheetScrollView contentContainerStyle={styles.sheetContent}>
{selectedCompany && (
<>
<View style={styles.sheetHeader}>
<Building2 size={28} color="#3b82f6" />
<Text style={[styles.sheetTitle, isDark ? styles.darkText : styles.lightText]}>
{selectedCompany.company_name}
</Text>
<View style={styles.sheetLocation}>
<MapPin size={16} color={isDark ? '#64748b' : '#94a3b8'} />
<Text
style={[
styles.sheetLocationText,
isDark ? styles.darkSubText : styles.lightSubText,
]}
>
{selectedCompany.country_name}, {selectedCompany.region_name},{' '}
{selectedCompany.district_name}
</Text>
</View>
</View>
<View style={[styles.divider, isDark ? styles.darkDivider : styles.lightDivider]} />
{selectedCompany.product_service_company &&
selectedCompany.product_service_company.length > 0 && (
<>
<Text
style={[
styles.sectionLabel,
isDark ? styles.darkSubText : styles.lightSubText,
]}
>
{t('Mahsulotlar')} ({selectedCompany.product_service_company.length})
</Text>
{selectedCompany.product_service_company.map((product) => (
<View
key={product.id}
style={[
styles.productCard,
isDark ? styles.darkProductCard : styles.lightProductCard,
]}
>
<View
style={[
styles.productImage,
isDark ? styles.darkProductImage : styles.lightProductImage,
]}
>
{product.files?.[0]?.file ? (
<Image
source={{ uri: BASE_URL + product.files[0].file }}
style={styles.productImg}
/>
) : (
<Package size={28} color={isDark ? '#64748b' : '#94a3b8'} />
)}
</View>
<View style={styles.productInfo}>
<Text
style={[
styles.productTitle,
isDark ? styles.darkText : styles.lightText,
]}
>
{product.title}
</Text>
<Text
style={[
styles.productDesc,
isDark ? styles.darkSubText : styles.lightSubText,
]}
>
{product.description || 'Tavsif mavjud emas.'}
</Text>
</View>
</View>
))}
</>
)}
</>
)}
</BottomSheetScrollView>
</BottomSheetModal>
</>
);
}
const CARD_WIDTH = SCREEN_WIDTH - 32;
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
// --- Company Card ---
card: {
width: CARD_WIDTH - 4,
marginLeft: 2,
borderRadius: 16,
padding: 16,
marginBottom: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 2,
},
darkCard: {
backgroundColor: '#1e293b',
},
lightCard: {
backgroundColor: '#ffffff',
},
cardHeader: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 },
cardTitle: { fontSize: 17, fontWeight: '700', flex: 1 },
darkText: {
color: '#f1f5f9',
},
lightText: {
color: '#0f172a',
},
cardLocation: { flexDirection: 'row', alignItems: 'center', gap: 6 },
cardLocationText: { fontSize: 13 },
darkSubText: {
color: '#64748b',
},
lightSubText: {
color: '#94a3b8',
},
// --- Bottom Sheet ---
sheetContent: { padding: 20, paddingBottom: 40 },
sheetHeader: { alignItems: 'center', marginBottom: 20 },
sheetTitle: {
fontSize: 22,
fontWeight: '800',
marginTop: 8,
textAlign: 'center',
},
sheetLocation: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 6 },
sheetLocationText: { fontSize: 14 },
divider: { height: 1, marginBottom: 16 },
darkDivider: {
backgroundColor: '#334155',
},
lightDivider: {
backgroundColor: '#e2e8f0',
},
sectionLabel: {
fontSize: 14,
fontWeight: '700',
textTransform: 'uppercase',
marginBottom: 12,
},
// --- Product Card ---
productCard: {
flexDirection: 'row',
borderRadius: 12,
padding: 16,
marginBottom: 12,
gap: 16,
alignItems: 'flex-start',
},
darkProductCard: {
backgroundColor: '#1e293b',
},
lightProductCard: {
backgroundColor: '#f8fafc',
},
productImage: {
width: 60,
height: 60,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderWidth: 1,
},
darkProductImage: {
backgroundColor: '#0f172a',
borderColor: '#334155',
},
lightProductImage: {
backgroundColor: '#ffffff',
borderColor: '#e2e8f0',
},
productImg: { width: '100%', height: '100%', borderRadius: 8 },
productInfo: { flex: 1 },
productTitle: { fontSize: 15, fontWeight: '700' },
productDesc: { fontSize: 13, marginTop: 2 },
});

View File

@@ -0,0 +1,221 @@
import { useTheme } from '@/components/ThemeContext';
import { products_api } from '@/screens/home/lib/api';
import { useInfiniteQuery } from '@tanstack/react-query';
import { Building2, ChevronDown, ChevronUp } from 'lucide-react-native';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Dimensions,
FlatList,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const PAGE_SIZE = 10;
export default function CountriesList({ search }: { search: string }) {
const { isDark } = useTheme();
const { t } = useTranslation();
const [openedCountryId, setOpenedCountryId] = useState<number | null>(null);
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['countries-list-infinite', search],
queryFn: async ({ pageParam = 1 }) => {
const response = await products_api.getCountry({
page: pageParam,
page_size: PAGE_SIZE,
search: search,
});
return response.data.data;
},
getNextPageParam: (lastPage) =>
lastPage.current_page < lastPage.total_pages ? lastPage.current_page + 1 : undefined,
initialPageParam: 1,
});
const allCountries = data?.pages.flatMap((page) => page.results) ?? [];
const toggleAccordion = (id: number) => setOpenedCountryId((prev) => (prev === id ? null : id));
const loadMore = () => hasNextPage && !isFetchingNextPage && fetchNextPage();
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#3b82f6" />
</View>
);
}
if (isError) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{t('Xatolik yuz berdi')}</Text>
</View>
);
}
if (allCountries.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, isDark ? styles.darkSubText : styles.lightSubText]}>
{t('Natija topilmadi')}
</Text>
</View>
);
}
return (
<FlatList
data={allCountries}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={{ gap: 12 }}
onEndReached={loadMore}
onEndReachedThreshold={0.4}
ListFooterComponent={
isFetchingNextPage ? <ActivityIndicator color="#3b82f6" style={{ margin: 20 }} /> : null
}
showsVerticalScrollIndicator={false}
renderItem={({ item }) => {
const isOpen = openedCountryId === item.id;
return (
<View style={[styles.countryCard, isDark ? styles.darkCard : styles.lightCard]}>
{/* Davlat sarlavhasi */}
<TouchableOpacity
style={styles.countryHeader}
onPress={() => toggleAccordion(item.id)}
activeOpacity={0.8}
>
<Text style={[styles.countryName, isDark ? styles.darkText : styles.lightText]}>
{item.name}
</Text>
<View style={styles.rightSide}>
<Text
style={[styles.companyCount, isDark ? styles.darkSubText : styles.lightSubText]}
>
{item.companies.length} {t('ta korxona')}
</Text>
{isOpen ? (
<ChevronUp size={20} color={isDark ? '#64748b' : '#94a3b8'} />
) : (
<ChevronDown size={20} color={isDark ? '#64748b' : '#94a3b8'} />
)}
</View>
</TouchableOpacity>
{/* Ochiladigan qism */}
{isOpen && (
<View style={styles.companiesContainer}>
{item.companies.map((company) => (
<View
key={company.id}
style={[
styles.companyItem,
isDark ? styles.darkCompanyItem : styles.lightCompanyItem,
]}
>
<Building2 size={18} color="#3b82f6" />
<View style={styles.companyInfo}>
<Text
style={[styles.companyName, isDark ? styles.darkText : styles.lightText]}
>
{company.company_name}
</Text>
<Text
style={[
styles.serviceCount,
isDark ? styles.darkSubText : styles.lightSubText,
]}
>
{company.service_count} {t('ta mahsulot/xizmat')}
</Text>
</View>
</View>
))}
</View>
)}
</View>
);
}}
/>
);
}
const CARD_WIDTH = SCREEN_WIDTH - 32;
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 40 },
loadingText: { fontSize: 16, marginTop: 10 },
errorText: { fontSize: 16, color: '#ef4444', textAlign: 'center' },
emptyContainer: { padding: 40, alignItems: 'center' },
emptyText: { fontSize: 16 },
countryCard: {
width: CARD_WIDTH - 4,
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 6,
elevation: 2,
marginLeft: 2,
},
darkCard: {
backgroundColor: '#1e293b',
},
lightCard: {
backgroundColor: '#ffffff',
},
countryHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
},
countryName: { fontSize: 17, fontWeight: '700' },
darkText: {
color: '#f1f5f9',
},
lightText: {
color: '#0f172a',
},
rightSide: { flexDirection: 'row', alignItems: 'center', gap: 8 },
companyCount: { fontSize: 14 },
darkSubText: {
color: '#64748b',
},
lightSubText: {
color: '#94a3b8',
},
companiesContainer: { paddingHorizontal: 16, paddingBottom: 16, gap: 10 },
companyItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 3,
},
darkCompanyItem: {
backgroundColor: '#0f172a',
},
lightCompanyItem: {
backgroundColor: '#f8fafc',
},
companyInfo: { flex: 1 },
companyName: { fontSize: 15, fontWeight: '600' },
serviceCount: { fontSize: 13, marginTop: 2 },
});

View File

@@ -0,0 +1,91 @@
import { FilterData } from '@/types';
import { cities, companies, countries, industries, states } from '@/types/data';
import createContextHook from '@nkzw/create-context-hook';
import { useEffect, useMemo, useState } from 'react';
export const [FilterProvider, useFilter] = createContextHook(() => {
const [filterData, setFilterDataState] = useState<FilterData>({
country: { name: '', iso: 'all' },
state: { name: '', iso: 'all' },
city: { name: '', iso: 'all' },
industries: [],
});
const [step, setStep] = useState<'filter' | 'items'>('filter');
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const setFilterData = (updates: Partial<FilterData>) => {
setFilterDataState((prev) => ({ ...prev, ...updates }));
};
const availableStates = useMemo(() => {
if (filterData.country.iso === 'all' || !filterData.country.iso) {
return states;
}
return states.filter((s) => s.country_iso === filterData.country.iso);
}, [filterData.country.iso]);
const availableCities = useMemo(() => {
if (filterData.state.iso === 'all' || !filterData.state.iso) {
return cities;
}
return cities.filter((c) => c.state_iso === filterData.state.iso);
}, [filterData.state.iso]);
const filteredCompanies = useMemo(() => {
return companies.filter((company) => {
const countryMatch =
!filterData.country.iso ||
filterData.country.iso === 'all' ||
company.country === filterData.country.iso;
const industryMatch =
filterData.industries.length === 0 ||
(company.industry && filterData.industries.includes(company.industry));
return countryMatch && industryMatch;
});
}, [filterData]);
const totalItems = filteredCompanies.length;
const totalPages = Math.ceil(totalItems / itemsPerPage);
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
return filteredCompanies.slice(startIndex, endIndex);
}, [filteredCompanies, currentPage]);
useEffect(() => {
if (filterData.country.iso !== 'all') {
const hasState = availableStates.some((s) => s.iso === filterData.state.iso);
if (!hasState) {
setFilterData({ state: { name: '', iso: 'all' }, city: { name: '', iso: 'all' } });
}
}
}, [filterData.country.iso, availableStates]);
useEffect(() => {
if (filterData.state.iso !== 'all') {
const hasCity = availableCities.some((c) => c.iso === filterData.city.iso);
if (!hasCity) {
setFilterData({ city: { name: '', iso: 'all' } });
}
}
}, [filterData.state.iso, availableCities]);
return {
filterData,
setFilterData,
countries,
states: availableStates,
cities: availableCities,
industries,
totalItems,
step,
setStep,
allData: paginatedData,
currentPage,
setCurrentPage,
totalPages,
};
});

350
components/ui/FilterUI.tsx Normal file
View File

@@ -0,0 +1,350 @@
import { useTheme } from '@/components/ThemeContext';
import { products_api } from '@/screens/home/lib/api';
import { useMutation, useQuery } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { XIcon } from 'lucide-react-native';
import React, { Dispatch, SetStateAction, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import CategorySelect from './CategorySelect';
interface FilterUIProps {
back: () => void;
onApply?: (data: any) => void;
setStep: Dispatch<SetStateAction<'filter' | 'items'>>;
setFiltered: Dispatch<SetStateAction<any[]>>;
}
interface Category {
id: number;
name: string;
code: string;
external_id: string | null;
level: number;
is_leaf: boolean;
icon_name: string | null;
}
export default function FilterUI({ back, onApply, setStep, setFiltered }: FilterUIProps) {
const { isDark } = useTheme();
const { t } = useTranslation();
const [selectedCategories, setSelectedCategories] = useState<Category | null>(null);
const [selectedCountry, setSelectedCountry] = useState<string>('all');
const [selectedRegion, setSelectedRegion] = useState<string>('all');
const [selectedDistrict, setSelectedDistrict] = useState<string>('all');
const { data: countryResponse, isLoading } = useQuery({
queryKey: ['country-detail'],
queryFn: async () => products_api.getStates(),
select: (res) => res.data?.data || [],
});
const { mutate } = useMutation({
mutationFn: (params: any) => products_api.businessAbout(params),
onSuccess: (data) => {
setStep('items');
setFiltered(data.data.data.results);
},
onError: (error: AxiosError) => console.log(error),
});
const handleApply = () => {
const countryObj = countryResponse?.find((c) => c.id?.toString() === selectedCountry);
const regionObj = countryObj?.region.find((r) => r.id?.toString() === selectedRegion);
const districtObj = regionObj?.districts.find((d) => d.id?.toString() === selectedDistrict);
mutate({
country: countryObj?.name || '',
region: regionObj?.name || '',
district: districtObj?.name || '',
types: selectedCategories ? selectedCategories.id : undefined,
});
};
const regions = useMemo(() => {
if (selectedCountry === 'all') return [];
const country = countryResponse?.find((c) => c.id?.toString() === selectedCountry);
return country?.region || [];
}, [countryResponse, selectedCountry]);
const districts = useMemo(() => {
if (selectedRegion === 'all') return [];
const region = regions.find((r) => r.id?.toString() === selectedRegion);
return region?.districts || [];
}, [regions, selectedRegion]);
if (isLoading) {
return (
<View
style={[
styles.container,
isDark ? styles.darkBg : styles.lightBg,
{ justifyContent: 'center', alignItems: 'center' },
]}
>
<ActivityIndicator size="large" color="#3b82f6" />
</View>
);
}
// Single Tag Component
const Tag = ({
label,
selected,
onPress,
}: {
label: string;
selected: boolean;
onPress: () => void;
}) => (
<TouchableOpacity
style={[
styles.tag,
isDark ? styles.darkTag : styles.lightTag,
selected && styles.tagSelected,
]}
onPress={onPress}
activeOpacity={0.7}
>
<Text
style={[
styles.tagText,
isDark ? styles.darkTagText : styles.lightTagText,
selected && styles.tagTextSelected,
]}
>
{label}
</Text>
</TouchableOpacity>
);
return (
<View style={[styles.container, isDark ? styles.darkBg : styles.lightBg]}>
{/* Header */}
<View style={styles.btn}>
<TouchableOpacity
onPress={back}
style={[styles.backBtn, isDark ? styles.darkBackBtn : styles.lightBackBtn]}
>
<XIcon color={isDark ? 'white' : '#0f172a'} fontSize={24} />
</TouchableOpacity>
</View>
{/* Scrollable Content */}
<ScrollView
style={{ padding: 16 }}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 140 }}
>
{/* Country Filter */}
<Text style={[styles.sectionTitle, isDark ? styles.darkText : styles.lightText]}>
{t('Davlat')}
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.scrollRow}>
<Tag
label={t('Barchasi')}
selected={selectedCountry === 'all'}
onPress={() => setSelectedCountry('all')}
/>
{countryResponse?.map((c) => (
<Tag
key={c.id}
label={c.name}
selected={selectedCountry === c.id?.toString()}
onPress={() => {
setSelectedCountry(c.id?.toString() || 'all');
setSelectedRegion('all');
setSelectedDistrict('all');
}}
/>
))}
</ScrollView>
{/* Region Filter */}
{regions.length > 0 && (
<>
<Text style={[styles.sectionTitle, isDark ? styles.darkText : styles.lightText]}>
{t('Viloyat')}
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.scrollRow}>
<Tag
label={t('Barchasi')}
selected={selectedRegion === 'all'}
onPress={() => setSelectedRegion('all')}
/>
{regions.map((r) => (
<Tag
key={r.id}
label={r.name}
selected={selectedRegion === r.id?.toString()}
onPress={() => {
setSelectedRegion(r.id?.toString() || 'all');
setSelectedDistrict('all');
}}
/>
))}
</ScrollView>
</>
)}
{/* District Filter */}
{districts.length > 0 && (
<>
<Text style={[styles.sectionTitle, isDark ? styles.darkText : styles.lightText]}>
{t('Tuman')}
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.scrollRow}>
<Tag
label={t('Barchasi')}
selected={selectedDistrict === 'all'}
onPress={() => setSelectedDistrict('all')}
/>
{districts.map((d) => (
<Tag
key={d.id}
label={d.name}
selected={selectedDistrict === d.id?.toString()}
onPress={() => setSelectedDistrict(d.id?.toString() || 'all')}
/>
))}
</ScrollView>
</>
)}
{/* Industry Selection */}
<Text style={[styles.sectionTitle, isDark ? styles.darkText : styles.lightText]}>
{t('Sohalar')}
</Text>
<CategorySelect
selectedCategories={selectedCategories}
setSelectedCategories={setSelectedCategories}
/>
</ScrollView>
{/* Fixed Apply Button */}
<View style={styles.applyBtnWrapper}>
<TouchableOpacity style={styles.applyBtn} onPress={handleApply}>
<Text style={styles.applyBtnText}>{t("Natijalarni ko'rish")}</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
darkBg: {
backgroundColor: '#0f172a',
},
lightBg: {
backgroundColor: '#f8fafc',
},
backBtn: {
paddingHorizontal: 10,
paddingVertical: 10,
borderRadius: 10,
marginTop: 10,
borderWidth: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
darkBackBtn: {
backgroundColor: '#1e293b',
borderColor: '#334155',
},
lightBackBtn: {
backgroundColor: '#ffffff',
borderColor: '#e2e8f0',
},
btn: {
justifyContent: 'flex-end',
alignItems: 'flex-end',
paddingHorizontal: 16,
},
sectionTitle: {
fontSize: 16,
fontWeight: '700',
marginBottom: 10,
},
darkText: {
color: '#f1f5f9',
},
lightText: {
color: '#0f172a',
},
scrollRow: { flexDirection: 'row', marginBottom: 12, gap: 10 },
tag: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 12,
marginRight: 10,
borderWidth: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
darkTag: {
backgroundColor: '#1e293b',
borderColor: '#334155',
},
lightTag: {
backgroundColor: '#ffffff',
borderColor: '#e2e8f0',
},
tagSelected: {
backgroundColor: '#3b82f6',
borderColor: '#3b82f6',
shadowColor: '#3b82f6',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 5,
},
tagText: {
fontWeight: '500',
},
darkTagText: {
color: '#cbd5e1',
},
lightTagText: {
color: '#64748b',
},
tagTextSelected: {
color: '#ffffff',
fontWeight: '600',
},
applyBtnWrapper: {
position: 'absolute',
bottom: 55,
left: 16,
right: 16,
zIndex: 10,
},
applyBtn: {
backgroundColor: '#3b82f6',
padding: 16,
borderRadius: 12,
alignItems: 'center',
shadowColor: '#3b82f6',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 5,
marginBottom: 20,
},
applyBtnText: { color: '#ffffff', fontWeight: '700', fontSize: 16 },
});

View File

@@ -0,0 +1,369 @@
import { useTheme } from '@/components/ThemeContext';
import { products_api } from '@/screens/home/lib/api';
import { businessAboutDetailResData } from '@/screens/home/lib/types';
import { useMutation } from '@tanstack/react-query';
import { ChevronLeft, FileText, Phone, User } from 'lucide-react-native';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
FlatList,
Image,
Linking,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
interface FilteredItemsProps {
data: { id: number; company_name: string }[];
back: () => void;
}
export default function FilteredItems({ data, back }: FilteredItemsProps) {
const { isDark } = useTheme();
const { t } = useTranslation();
const [selectedItem, setSelectedItem] = useState<businessAboutDetailResData | null>(null);
const { mutate, isPending } = useMutation({
mutationFn: (id: number) => products_api.businessAboutDetail(id),
onSuccess: (data) => setSelectedItem(data.data.data),
});
if (isPending) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#3b82f6" />
</View>
);
}
if (selectedItem) {
return (
<ScrollView
style={[styles.container, isDark ? styles.darkBg : styles.lightBg]}
contentContainerStyle={{ paddingBottom: 70 }}
>
<View style={styles.content}>
{/* Back Button */}
<TouchableOpacity
onPress={() => setSelectedItem(null)}
style={[styles.backButton, isDark ? styles.darkBackButton : styles.lightBackButton]}
>
<ChevronLeft size={24} color="#3b82f6" />
</TouchableOpacity>
{/* Company Name */}
<Text style={[styles.detailTitle, isDark ? styles.darkText : styles.lightText]}>
{selectedItem.company_name}
</Text>
{/* Company Image */}
{selectedItem.company_image && (
<Image
source={{ uri: selectedItem.company_image }}
style={[styles.companyImage, isDark ? styles.darkImageBg : styles.lightImageBg]}
resizeMode="cover"
/>
)}
{/* Info Card */}
<View style={[styles.infoCard, isDark ? styles.darkCard : styles.lightCard]}>
{selectedItem.director_full_name && (
<View style={styles.infoRow}>
<User size={20} color={isDark ? '#64748b' : '#94a3b8'} />
<View style={styles.infoTextContainer}>
<Text
style={[styles.infoLabel, isDark ? styles.darkSubText : styles.lightSubText]}
>
{t('Rahbar')}
</Text>
<Text style={[styles.infoValue, isDark ? styles.darkText : styles.lightText]}>
{selectedItem.director_full_name}
</Text>
</View>
</View>
)}
{selectedItem.phone && (
<TouchableOpacity
style={styles.infoRow}
onPress={() => Linking.openURL(`tel:+${selectedItem.phone}`)}
>
<Phone size={20} color="#3b82f6" />
<View style={styles.infoTextContainer}>
<Text
style={[styles.infoLabel, isDark ? styles.darkSubText : styles.lightSubText]}
>
{t('Telefon raqami')}
</Text>
<Text style={[styles.infoValue, { color: '#3b82f6' }]}>
+{selectedItem.phone}
</Text>
</View>
</TouchableOpacity>
)}
{selectedItem.product_service_company?.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<FileText size={20} color="#3b82f6" />
<Text style={[styles.sectionTitle, isDark ? styles.darkText : styles.lightText]}>
{t('Mahsulot va xizmatlar')}
</Text>
</View>
{selectedItem.product_service_company.map((item, index) => (
<View
key={index}
style={[
styles.serviceItem,
isDark ? styles.darkServiceItem : styles.lightServiceItem,
]}
>
<Text
style={[styles.serviceTitle, isDark ? styles.darkText : styles.lightText]}
>
{item.title}
</Text>
{item.description && (
<Text
style={[
styles.serviceDescription,
isDark ? styles.darkSubText : styles.lightSubText,
]}
>
{item.description}
</Text>
)}
{item.category?.length > 0 && (
<View style={styles.tagsContainer}>
{item.category.map((cat) => (
<View
key={cat.id}
style={[styles.tag, isDark ? styles.darkTag : styles.lightTag]}
>
<Text
style={[
styles.tagText,
isDark ? styles.darkSubText : styles.lightSubText,
]}
>
{cat.name}
</Text>
</View>
))}
</View>
)}
{item.files?.length > 0 && (
<View style={styles.filesContainer}>
<Text
style={[styles.filesTitle, isDark ? styles.darkText : styles.lightText]}
>
{t('Fayllar')}:
</Text>
{item.files.map((file, idx) => (
<TouchableOpacity
key={idx}
onPress={() => Linking.openURL(file.file)}
style={styles.fileLink}
>
<Text style={styles.fileLinkText}>
{t('Fayl')} {idx + 1} {t('ochish')}
</Text>
</TouchableOpacity>
))}
</View>
)}
</View>
))}
</View>
)}
</View>
</View>
</ScrollView>
);
}
// List view
return (
<View style={[styles.container, isDark ? styles.darkBg : styles.lightBg]}>
<View style={styles.content}>
<TouchableOpacity
onPress={back}
style={[styles.backButton, isDark ? styles.darkBackButton : styles.lightBackButton]}
>
<ChevronLeft size={24} color="#3b82f6" />
</TouchableOpacity>
{data && data.length > 0 ? (
<FlatList
data={data}
style={{ marginBottom: 120 }}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<TouchableOpacity
style={[styles.itemCard, isDark ? styles.darkItemCard : styles.lightItemCard]}
onPress={() => mutate(item.id)}
>
<Text style={[styles.itemTitle, isDark ? styles.darkText : styles.lightText]}>
{item.company_name}
</Text>
</TouchableOpacity>
)}
contentContainerStyle={styles.listContainer}
/>
) : (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, isDark ? styles.darkSubText : styles.lightSubText]}>
{t('Natija topilmadi')}
</Text>
</View>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
darkBg: {
backgroundColor: '#0f172a',
},
lightBg: {
backgroundColor: '#f8fafc',
},
content: {
padding: 16,
maxWidth: 500,
width: '100%',
alignSelf: 'center',
},
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
backButton: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
padding: 10,
borderRadius: 12,
alignSelf: 'flex-start',
gap: 8,
},
darkBackButton: {
backgroundColor: '#1e293b',
},
lightBackButton: {
backgroundColor: '#ffffff',
},
backText: { fontSize: 16, color: '#3b82f6', fontWeight: '600' },
listContainer: { gap: 12 },
itemCard: {
borderRadius: 12,
padding: 16,
borderWidth: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
darkItemCard: {
backgroundColor: '#1e293b',
borderColor: '#334155',
},
lightItemCard: {
backgroundColor: '#ffffff',
borderColor: '#e2e8f0',
},
itemTitle: { fontSize: 17, fontWeight: '600' },
darkText: {
color: '#f1f5f9',
},
lightText: {
color: '#0f172a',
},
emptyContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 40 },
emptyText: { fontSize: 16, textAlign: 'center' },
darkSubText: {
color: '#64748b',
},
lightSubText: {
color: '#94a3b8',
},
// Detail
detailTitle: { fontSize: 26, fontWeight: '700', marginBottom: 16 },
companyImage: {
width: '100%',
height: 220,
borderRadius: 16,
marginBottom: 20,
},
darkImageBg: {
backgroundColor: '#1e293b',
},
lightImageBg: {
backgroundColor: '#f1f5f9',
},
infoCard: {
borderRadius: 16,
padding: 20,
borderWidth: 1,
gap: 16,
},
darkCard: {
backgroundColor: '#1e293b',
borderColor: '#334155',
},
lightCard: {
backgroundColor: '#ffffff',
borderColor: '#e2e8f0',
},
infoRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 8 },
infoTextContainer: { flex: 1 },
infoLabel: { fontSize: 14, marginBottom: 4 },
infoValue: { fontSize: 16, fontWeight: '500' },
section: { marginTop: 8 },
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 },
sectionTitle: { fontSize: 18, fontWeight: '700' },
serviceItem: {
borderRadius: 12,
padding: 16,
marginBottom: 12,
},
darkServiceItem: {
backgroundColor: '#0f172a',
},
lightServiceItem: {
backgroundColor: '#f8fafc',
},
serviceTitle: { fontSize: 17, fontWeight: '600', marginBottom: 8 },
serviceDescription: { fontSize: 15, lineHeight: 22, marginBottom: 12 },
tagsContainer: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8 },
tag: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
},
darkTag: {
backgroundColor: '#1e293b',
},
lightTag: {
backgroundColor: '#ffffff',
},
tagText: { fontSize: 13, fontWeight: '500' },
filesContainer: { marginTop: 12 },
filesTitle: { fontSize: 14, fontWeight: '600', marginBottom: 8 },
fileLink: { paddingVertical: 6 },
fileLinkText: { color: '#3b82f6', fontSize: 15, textDecorationLine: 'underline' },
});

92
components/ui/Header.tsx Normal file
View File

@@ -0,0 +1,92 @@
import Logo from '@/assets/images/logo.png';
import { useTheme } from '@/components/ThemeContext';
import { LogOut } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useAuth } from '../AuthProvider';
export const CustomHeader = ({ logoutbtn = false }: { logoutbtn?: boolean }) => {
const { isDark } = useTheme();
const { t } = useTranslation();
const { logout } = useAuth();
return (
<View style={[styles.container, isDark ? styles.darkBg : styles.lightBg]}>
<View style={styles.logoWrapper}>
<View style={styles.logoContainer}>
<Image source={Logo} style={styles.logo} />
</View>
<Text style={[styles.title, isDark ? styles.darkText : styles.lightText]}>
{t('app.name', 'InfoTarget')}
</Text>
</View>
{logoutbtn && (
<TouchableOpacity onPress={logout}>
<LogOut color={'red'} />
</TouchableOpacity>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 3,
},
darkBg: {
backgroundColor: '#0f172a',
borderBottomColor: '#1e293b',
},
lightBg: {
backgroundColor: '#ffffff',
borderBottomColor: '#e2e8f0',
},
logoWrapper: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
logoContainer: {
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: 'rgba(59, 130, 246, 0.1)',
alignItems: 'center',
justifyContent: 'center',
padding: 4,
},
logo: {
width: 32,
height: 32,
resizeMode: 'contain',
},
title: {
fontSize: 20,
fontWeight: '700',
letterSpacing: 0.3,
},
darkText: {
color: '#f8fafc',
},
lightText: {
color: '#0f172a',
},
});

View File

@@ -0,0 +1,236 @@
import { products_api } from '@/screens/home/lib/api';
import { useMutation, useQuery } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { ChevronLeft, ChevronRight } from 'lucide-react-native';
import React, { Dispatch, SetStateAction, useState } from 'react';
import {
ActivityIndicator,
FlatList,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useTheme } from '../ThemeContext';
interface Category {
id: number;
name: string;
code: string;
external_id: string | null;
level: number;
is_leaf: boolean;
icon_name: string | null;
}
interface CategoryResponse {
data: {
data: Category[];
};
}
interface Props {
selectedCategories: Category[];
setSelectedCategories: Dispatch<SetStateAction<Category[]>>;
}
export default function CategorySelection({ selectedCategories, setSelectedCategories }: Props) {
const [currentCategories, setCurrentCategories] = useState<Category[]>([]);
const [history, setHistory] = useState<{ parentId: number | null; categories: Category[] }[]>([]);
const [currentParentId, setCurrentParentId] = useState<number | null>(null);
const { isDark } = useTheme();
const theme = {
cardBg: isDark ? '#1e293b' : '#f8fafc',
cardBorder: isDark ? '#334155' : '#e2e8f0',
text: isDark ? '#cbd5e1' : '#334155',
textSelected: '#ffffff',
primary: '#2563eb',
primaryBg: '#3b82f6',
error: '#ef4444',
backButtonBg: isDark ? '#1e293b' : '#e2e8f0',
chevronColor: isDark ? '#94a3b8' : '#64748b',
shadow: isDark ? '#000' : '#94a3b8',
};
const {
data: rootData,
isLoading: rootLoading,
error: rootError,
} = useQuery<CategoryResponse>({
queryKey: ['categories'],
queryFn: async () => products_api.getCategorys(),
select(data) {
setCurrentCategories(data.data.data);
setCurrentParentId(null);
return data;
},
});
const { mutate, isPending: mutatePending } = useMutation({
mutationFn: (id: number) => products_api.getCategorys({ parent: id }),
onSuccess: (response: CategoryResponse, id) => {
const childCategories = response.data.data;
setHistory((prev) => [...prev, { parentId: currentParentId, categories: currentCategories }]);
setCurrentCategories(childCategories);
setCurrentParentId(id);
},
onError: (err: AxiosError) => {
console.error('Child yuklashda xato:', err);
},
});
const toggleCategory = (category: Category) => {
if (category.is_leaf) {
setSelectedCategories((prev) => {
const exists = prev.find((c) => c.id === category.id);
if (exists) return prev.filter((c) => c.id !== category.id);
return [...prev, category];
});
} else {
mutate(category.id);
}
};
const goBack = () => {
if (history.length > 0) {
const previous = history[history.length - 1];
setCurrentCategories(previous.categories);
setCurrentParentId(previous.parentId);
setHistory((prev) => prev.slice(0, -1));
}
};
const isLoading = rootLoading || mutatePending;
const error = rootError;
const renderCategory = ({ item: category }: { item: Category }) => {
const isSelected = selectedCategories.some((c) => c.id === category.id);
return (
<TouchableOpacity
style={[
styles.chip,
{
backgroundColor: isSelected ? theme.primaryBg : theme.cardBg,
borderColor: isSelected ? theme.primaryBg : theme.cardBorder,
shadowColor: isSelected ? theme.primaryBg : theme.shadow,
},
isSelected && styles.chipSelected,
]}
onPress={() => toggleCategory(category)}
>
<Text
style={[
styles.chipText,
{ color: isSelected ? theme.textSelected : theme.text },
isSelected && styles.chipTextSelected,
]}
>
{category.name}
</Text>
{!category.is_leaf && (
<ChevronRight size={20} color={isSelected ? theme.textSelected : theme.chevronColor} />
)}
</TouchableOpacity>
);
};
if (isLoading && currentCategories.length === 0) {
return (
<View style={[styles.centerContainer]}>
<ActivityIndicator size="small" color={theme.primary} />
</View>
);
}
if (error && currentCategories.length === 0) {
return (
<View style={[styles.centerContainer]}>
<Text style={{ color: theme.error }}>Ma'lumot yuklashda xatolik yuz berdi</Text>
</View>
);
}
if (currentCategories.length === 0) {
return (
<View style={[styles.centerContainer]}>
<Text style={{ color: theme.text }}>Kategoriyalar topilmadi</Text>
</View>
);
}
return (
<View style={[styles.container]}>
<View style={styles.header}>
{history.length > 0 && (
<TouchableOpacity
onPress={goBack}
style={[styles.backButton, { backgroundColor: theme.backButtonBg }]}
>
<ChevronLeft color={theme.primary} size={20} />
</TouchableOpacity>
)}
</View>
<FlatList
data={currentCategories}
renderItem={renderCategory}
scrollEnabled={false}
keyExtractor={(item) => item.id.toString()}
showsVerticalScrollIndicator={true}
ItemSeparatorComponent={() => <View style={{ height: 10 }} />}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
marginBottom: 20,
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginBottom: 12,
paddingHorizontal: 12,
},
backButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
},
chip: {
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 20,
borderWidth: 1,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
chipSelected: {
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 3,
},
chipText: {
fontSize: 14,
fontWeight: '500',
flex: 1,
},
chipTextSelected: {
fontWeight: '600',
},
});

View File

@@ -0,0 +1,376 @@
import { useTheme } from '@/components/ThemeContext';
import { products_api } from '@/screens/home/lib/api';
import { ProductResponse } from '@/screens/home/lib/types';
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet';
import { useInfiniteQuery } 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 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: 8 }}
contentContainerStyle={{}}
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}
>
{selectedProduct && (
<>
<View style={styles.carouselWrapper}>
<FlatList
nestedScrollEnabled={true}
data={selectedProduct.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);
}}
/>
{selectedProduct.files.length > 1 && (
<View style={styles.pagination}>
{selectedProduct.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]}>
{selectedProduct.title}
</Text>
<View style={styles.sheetCompanyBadge}>
<Text style={styles.sheetCompanyText}>{selectedProduct.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]}
>
{selectedProduct.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' },
});

View File

@@ -0,0 +1,37 @@
import { useQueryClient } from '@tanstack/react-query';
import { createContext, useCallback, useContext, useState } from 'react';
type RefreshContextType = {
refreshing: boolean;
onRefresh: () => Promise<void>;
};
const RefreshContext = createContext<RefreshContextType | null>(null);
export function RefreshProvider({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient();
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
try {
await queryClient.refetchQueries();
} catch (err) {
console.error('Global refresh error:', err);
} finally {
setRefreshing(false);
}
}, [queryClient, refreshing]);
return (
<RefreshContext.Provider value={{ refreshing, onRefresh }}>{children}</RefreshContext.Provider>
);
}
export function useGlobalRefresh() {
const ctx = useContext(RefreshContext);
if (!ctx) throw new Error('useGlobalRefresh must be used inside RefreshProvider');
return ctx;
}

View File

@@ -0,0 +1,132 @@
import { useTheme } from '@/components/ThemeContext';
import { TabKey } from '@/types';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
interface SearchTabsProps {
value: TabKey;
onChange: (tab: TabKey) => void;
}
export default function SearchTabs({ value, onChange }: SearchTabsProps) {
const { isDark } = useTheme();
const { t } = useTranslation();
const tabs: { key: TabKey; label: string }[] = [
{ key: 'products', label: 'Tovar va xizmatlar' },
{ key: 'companies', label: 'Yuridik shaxslar' },
{ key: 'countries', label: 'Davlatlar' },
];
const renderTab = ({ item }: { item: { key: TabKey; label: string } }) => {
const isActive = value === item.key;
return (
<TouchableOpacity
style={[
styles.tab,
isDark ? styles.darkTab : styles.lightTab,
isActive && styles.activeTab,
]}
onPress={() => onChange(item.key)}
activeOpacity={0.8}
>
<Text
style={[
styles.tabText,
isDark ? styles.darkTabText : styles.lightTabText,
isActive && styles.activeTabText,
]}
>
{t(item.label)}
</Text>
</TouchableOpacity>
);
};
return (
<View style={[styles.shadowWrapper]}>
<View style={[styles.wrapper, isDark ? styles.darkWrapper : styles.lightWrapper]}>
<FlatList
data={tabs}
renderItem={renderTab}
keyExtractor={(item) => item.key}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.container}
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
shadowWrapper: {
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
wrapper: {
borderRadius: 12,
overflow: 'hidden', // ENDI ISHLAYDI
},
darkWrapper: {
backgroundColor: '#0f172a',
},
lightWrapper: {
backgroundColor: '#ffffff',
},
container: {
paddingHorizontal: 2,
paddingVertical: 2,
overflow: 'hidden',
},
tab: {
paddingVertical: 10,
paddingHorizontal: 20,
borderRadius: 12,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
darkTab: {
backgroundColor: '#1e293b',
},
lightTab: {
backgroundColor: '#f8fafc',
},
activeTab: {
backgroundColor: '#3b82f6',
shadowColor: '#3b82f6',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 3,
},
tabText: {
fontSize: 14,
fontWeight: '500',
},
darkTabText: {
color: '#cbd5e1',
},
lightTabText: {
color: '#64748b',
},
activeTabText: {
color: '#ffffff',
fontWeight: '600',
},
});

View File

@@ -0,0 +1,309 @@
'use client';
import { vars } from 'nativewind';
export const config = {
light: vars({
'--color-primary-0': '179 179 179',
'--color-primary-50': '153 153 153',
'--color-primary-100': '128 128 128',
'--color-primary-200': '115 115 115',
'--color-primary-300': '102 102 102',
'--color-primary-400': '82 82 82',
'--color-primary-500': '51 51 51',
'--color-primary-600': '41 41 41',
'--color-primary-700': '31 31 31',
'--color-primary-800': '13 13 13',
'--color-primary-900': '10 10 10',
'--color-primary-950': '8 8 8',
/* Secondary */
'--color-secondary-0': '253 253 253',
'--color-secondary-50': '251 251 251',
'--color-secondary-100': '246 246 246',
'--color-secondary-200': '242 242 242',
'--color-secondary-300': '237 237 237',
'--color-secondary-400': '230 230 231',
'--color-secondary-500': '217 217 219',
'--color-secondary-600': '198 199 199',
'--color-secondary-700': '189 189 189',
'--color-secondary-800': '177 177 177',
'--color-secondary-900': '165 164 164',
'--color-secondary-950': '157 157 157',
/* Tertiary */
'--color-tertiary-0': '255 250 245',
'--color-tertiary-50': '255 242 229',
'--color-tertiary-100': '255 233 213',
'--color-tertiary-200': '254 209 170',
'--color-tertiary-300': '253 180 116',
'--color-tertiary-400': '251 157 75',
'--color-tertiary-500': '231 129 40',
'--color-tertiary-600': '215 117 31',
'--color-tertiary-700': '180 98 26',
'--color-tertiary-800': '130 73 23',
'--color-tertiary-900': '108 61 19',
'--color-tertiary-950': '84 49 18',
/* Error */
'--color-error-0': '254 233 233',
'--color-error-50': '254 226 226',
'--color-error-100': '254 202 202',
'--color-error-200': '252 165 165',
'--color-error-300': '248 113 113',
'--color-error-400': '239 68 68',
'--color-error-500': '230 53 53',
'--color-error-600': '220 38 38',
'--color-error-700': '185 28 28',
'--color-error-800': '153 27 27',
'--color-error-900': '127 29 29',
'--color-error-950': '83 19 19',
/* Success */
'--color-success-0': '228 255 244',
'--color-success-50': '202 255 232',
'--color-success-100': '162 241 192',
'--color-success-200': '132 211 162',
'--color-success-300': '102 181 132',
'--color-success-400': '72 151 102',
'--color-success-500': '52 131 82',
'--color-success-600': '42 121 72',
'--color-success-700': '32 111 62',
'--color-success-800': '22 101 52',
'--color-success-900': '20 83 45',
'--color-success-950': '27 50 36',
/* Warning */
'--color-warning-0': '255 249 245',
'--color-warning-50': '255 244 236',
'--color-warning-100': '255 231 213',
'--color-warning-200': '254 205 170',
'--color-warning-300': '253 173 116',
'--color-warning-400': '251 149 75',
'--color-warning-500': '231 120 40',
'--color-warning-600': '215 108 31',
'--color-warning-700': '180 90 26',
'--color-warning-800': '130 68 23',
'--color-warning-900': '108 56 19',
'--color-warning-950': '84 45 18',
/* Info */
'--color-info-0': '236 248 254',
'--color-info-50': '199 235 252',
'--color-info-100': '162 221 250',
'--color-info-200': '124 207 248',
'--color-info-300': '87 194 246',
'--color-info-400': '50 180 244',
'--color-info-500': '13 166 242',
'--color-info-600': '11 141 205',
'--color-info-700': '9 115 168',
'--color-info-800': '7 90 131',
'--color-info-900': '5 64 93',
'--color-info-950': '3 38 56',
/* Typography */
'--color-typography-0': '254 254 255',
'--color-typography-50': '245 245 245',
'--color-typography-100': '229 229 229',
'--color-typography-200': '219 219 220',
'--color-typography-300': '212 212 212',
'--color-typography-400': '163 163 163',
'--color-typography-500': '140 140 140',
'--color-typography-600': '115 115 115',
'--color-typography-700': '82 82 82',
'--color-typography-800': '64 64 64',
'--color-typography-900': '38 38 39',
'--color-typography-950': '23 23 23',
/* Outline */
'--color-outline-0': '253 254 254',
'--color-outline-50': '243 243 243',
'--color-outline-100': '230 230 230',
'--color-outline-200': '221 220 219',
'--color-outline-300': '211 211 211',
'--color-outline-400': '165 163 163',
'--color-outline-500': '140 141 141',
'--color-outline-600': '115 116 116',
'--color-outline-700': '83 82 82',
'--color-outline-800': '65 65 65',
'--color-outline-900': '39 38 36',
'--color-outline-950': '26 23 23',
/* Background */
'--color-background-0': '255 255 255',
'--color-background-50': '246 246 246',
'--color-background-100': '242 241 241',
'--color-background-200': '220 219 219',
'--color-background-300': '213 212 212',
'--color-background-400': '162 163 163',
'--color-background-500': '142 142 142',
'--color-background-600': '116 116 116',
'--color-background-700': '83 82 82',
'--color-background-800': '65 64 64',
'--color-background-900': '39 38 37',
'--color-background-950': '18 18 18',
/* Background Special */
'--color-background-error': '254 241 241',
'--color-background-warning': '255 243 234',
'--color-background-success': '237 252 242',
'--color-background-muted': '247 248 247',
'--color-background-info': '235 248 254',
/* Focus Ring Indicator */
'--color-indicator-primary': '55 55 55',
'--color-indicator-info': '83 153 236',
'--color-indicator-error': '185 28 28',
}),
dark: vars({
'--color-primary-0': '166 166 166',
'--color-primary-50': '175 175 175',
'--color-primary-100': '186 186 186',
'--color-primary-200': '197 197 197',
'--color-primary-300': '212 212 212',
'--color-primary-400': '221 221 221',
'--color-primary-500': '230 230 230',
'--color-primary-600': '240 240 240',
'--color-primary-700': '250 250 250',
'--color-primary-800': '253 253 253',
'--color-primary-900': '254 249 249',
'--color-primary-950': '253 252 252',
/* Secondary */
'--color-secondary-0': '20 20 20',
'--color-secondary-50': '23 23 23',
'--color-secondary-100': '31 31 31',
'--color-secondary-200': '39 39 39',
'--color-secondary-300': '44 44 44',
'--color-secondary-400': '56 57 57',
'--color-secondary-500': '63 64 64',
'--color-secondary-600': '86 86 86',
'--color-secondary-700': '110 110 110',
'--color-secondary-800': '135 135 135',
'--color-secondary-900': '150 150 150',
'--color-secondary-950': '164 164 164',
/* Tertiary */
'--color-tertiary-0': '84 49 18',
'--color-tertiary-50': '108 61 19',
'--color-tertiary-100': '130 73 23',
'--color-tertiary-200': '180 98 26',
'--color-tertiary-300': '215 117 31',
'--color-tertiary-400': '231 129 40',
'--color-tertiary-500': '251 157 75',
'--color-tertiary-600': '253 180 116',
'--color-tertiary-700': '254 209 170',
'--color-tertiary-800': '255 233 213',
'--color-tertiary-900': '255 242 229',
'--color-tertiary-950': '255 250 245',
/* Error */
'--color-error-0': '83 19 19',
'--color-error-50': '127 29 29',
'--color-error-100': '153 27 27',
'--color-error-200': '185 28 28',
'--color-error-300': '220 38 38',
'--color-error-400': '230 53 53',
'--color-error-500': '239 68 68',
'--color-error-600': '249 97 96',
'--color-error-700': '229 91 90',
'--color-error-800': '254 202 202',
'--color-error-900': '254 226 226',
'--color-error-950': '254 233 233',
/* Success */
'--color-success-0': '27 50 36',
'--color-success-50': '20 83 45',
'--color-success-100': '22 101 52',
'--color-success-200': '32 111 62',
'--color-success-300': '42 121 72',
'--color-success-400': '52 131 82',
'--color-success-500': '72 151 102',
'--color-success-600': '102 181 132',
'--color-success-700': '132 211 162',
'--color-success-800': '162 241 192',
'--color-success-900': '202 255 232',
'--color-success-950': '228 255 244',
/* Warning */
'--color-warning-0': '84 45 18',
'--color-warning-50': '108 56 19',
'--color-warning-100': '130 68 23',
'--color-warning-200': '180 90 26',
'--color-warning-300': '215 108 31',
'--color-warning-400': '231 120 40',
'--color-warning-500': '251 149 75',
'--color-warning-600': '253 173 116',
'--color-warning-700': '254 205 170',
'--color-warning-800': '255 231 213',
'--color-warning-900': '255 244 237',
'--color-warning-950': '255 249 245',
/* Info */
'--color-info-0': '3 38 56',
'--color-info-50': '5 64 93',
'--color-info-100': '7 90 131',
'--color-info-200': '9 115 168',
'--color-info-300': '11 141 205',
'--color-info-400': '13 166 242',
'--color-info-500': '50 180 244',
'--color-info-600': '87 194 246',
'--color-info-700': '124 207 248',
'--color-info-800': '162 221 250',
'--color-info-900': '199 235 252',
'--color-info-950': '236 248 254',
/* Typography */
'--color-typography-0': '23 23 23',
'--color-typography-50': '38 38 39',
'--color-typography-100': '64 64 64',
'--color-typography-200': '82 82 82',
'--color-typography-300': '115 115 115',
'--color-typography-400': '140 140 140',
'--color-typography-500': '163 163 163',
'--color-typography-600': '212 212 212',
'--color-typography-700': '219 219 220',
'--color-typography-800': '229 229 229',
'--color-typography-900': '245 245 245',
'--color-typography-950': '254 254 255',
/* Outline */
'--color-outline-0': '26 23 23',
'--color-outline-50': '39 38 36',
'--color-outline-100': '65 65 65',
'--color-outline-200': '83 82 82',
'--color-outline-300': '115 116 116',
'--color-outline-400': '140 141 141',
'--color-outline-500': '165 163 163',
'--color-outline-600': '211 211 211',
'--color-outline-700': '221 220 219',
'--color-outline-800': '230 230 230',
'--color-outline-900': '243 243 243',
'--color-outline-950': '253 254 254',
/* Background */
'--color-background-0': '18 18 18',
'--color-background-50': '39 38 37',
'--color-background-100': '65 64 64',
'--color-background-200': '83 82 82',
'--color-background-300': '116 116 116',
'--color-background-400': '142 142 142',
'--color-background-500': '162 163 163',
'--color-background-600': '213 212 212',
'--color-background-700': '229 228 228',
'--color-background-800': '242 241 241',
'--color-background-900': '246 246 246',
'--color-background-950': '255 255 255',
/* Background Special */
'--color-background-error': '66 43 43',
'--color-background-warning': '65 47 35',
'--color-background-success': '28 43 33',
'--color-background-muted': '51 51 51',
'--color-background-info': '26 40 46',
/* Focus Ring Indicator */
'--color-indicator-primary': '247 247 247',
'--color-indicator-info': '161 199 245',
'--color-indicator-error': '232 70 69',
}),
};

View File

@@ -0,0 +1,87 @@
// This is a Next.js 15 compatible version of the GluestackUIProvider
'use client';
import React, { useEffect, useLayoutEffect } from 'react';
import { config } from './config';
import { OverlayProvider } from '@gluestack-ui/core/overlay/creator';
import { ToastProvider } from '@gluestack-ui/core/toast/creator';
import { setFlushStyles } from '@gluestack-ui/utils/nativewind-utils';
import { script } from './script';
const variableStyleTagId = 'nativewind-style';
const createStyle = (styleTagId: string) => {
const style = document.createElement('style');
style.id = styleTagId;
style.appendChild(document.createTextNode(''));
return style;
};
export const useSafeLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
export function GluestackUIProvider({
mode = 'light',
...props
}: {
mode?: 'light' | 'dark' | 'system';
children?: React.ReactNode;
}) {
let cssVariablesWithMode = ``;
Object.keys(config).forEach((configKey) => {
cssVariablesWithMode +=
configKey === 'dark' ? `\n .dark {\n ` : `\n:root {\n`;
const cssVariables = Object.keys(
config[configKey as keyof typeof config]
).reduce((acc: string, curr: string) => {
acc += `${curr}:${config[configKey as keyof typeof config][curr]}; `;
return acc;
}, '');
cssVariablesWithMode += `${cssVariables} \n}`;
});
setFlushStyles(cssVariablesWithMode);
const handleMediaQuery = React.useCallback((e: MediaQueryListEvent) => {
script(e.matches ? 'dark' : 'light');
}, []);
useSafeLayoutEffect(() => {
if (mode !== 'system') {
const documentElement = document.documentElement;
if (documentElement) {
documentElement.classList.add(mode);
documentElement.classList.remove(mode === 'light' ? 'dark' : 'light');
documentElement.style.colorScheme = mode;
}
}
}, [mode]);
useSafeLayoutEffect(() => {
if (mode !== 'system') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
media.addListener(handleMediaQuery);
return () => media.removeListener(handleMediaQuery);
}, [handleMediaQuery]);
useSafeLayoutEffect(() => {
if (typeof window !== 'undefined') {
const documentElement = document.documentElement;
if (documentElement) {
const head = documentElement.querySelector('head');
let style = head?.querySelector(`[id='${variableStyleTagId}']`);
if (!style) {
style = createStyle(variableStyleTagId);
style.innerHTML = cssVariablesWithMode;
if (head) head.appendChild(style);
}
}
}
}, []);
return (
<OverlayProvider>
<ToastProvider>{props.children}</ToastProvider>
</OverlayProvider>
);
}

View File

@@ -0,0 +1,38 @@
import React, { useEffect } from 'react';
import { config } from './config';
import { View, ViewProps } from 'react-native';
import { OverlayProvider } from '@gluestack-ui/core/overlay/creator';
import { ToastProvider } from '@gluestack-ui/core/toast/creator';
import { useColorScheme } from 'nativewind';
export type ModeType = 'light' | 'dark' | 'system';
export function GluestackUIProvider({
mode = 'light',
...props
}: {
mode?: ModeType;
children?: React.ReactNode;
style?: ViewProps['style'];
}) {
const { colorScheme, setColorScheme } = useColorScheme();
useEffect(() => {
setColorScheme(mode);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
return (
<View
style={[
config[colorScheme!],
{ flex: 1, height: '100%', width: '100%' },
props.style,
]}
>
<OverlayProvider>
<ToastProvider>{props.children}</ToastProvider>
</OverlayProvider>
</View>
);
}

View File

@@ -0,0 +1,96 @@
'use client';
import React, { useEffect, useLayoutEffect } from 'react';
import { config } from './config';
import { OverlayProvider } from '@gluestack-ui/core/overlay/creator';
import { ToastProvider } from '@gluestack-ui/core/toast/creator';
import { setFlushStyles } from '@gluestack-ui/utils/nativewind-utils';
import { script } from './script';
export type ModeType = 'light' | 'dark' | 'system';
const variableStyleTagId = 'nativewind-style';
const createStyle = (styleTagId: string) => {
const style = document.createElement('style');
style.id = styleTagId;
style.appendChild(document.createTextNode(''));
return style;
};
export const useSafeLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
export function GluestackUIProvider({
mode = 'light',
...props
}: {
mode?: ModeType;
children?: React.ReactNode;
}) {
let cssVariablesWithMode = ``;
Object.keys(config).forEach((configKey) => {
cssVariablesWithMode +=
configKey === 'dark' ? `\n .dark {\n ` : `\n:root {\n`;
const cssVariables = Object.keys(
config[configKey as keyof typeof config]
).reduce((acc: string, curr: string) => {
acc += `${curr}:${config[configKey as keyof typeof config][curr]}; `;
return acc;
}, '');
cssVariablesWithMode += `${cssVariables} \n}`;
});
setFlushStyles(cssVariablesWithMode);
const handleMediaQuery = React.useCallback((e: MediaQueryListEvent) => {
script(e.matches ? 'dark' : 'light');
}, []);
useSafeLayoutEffect(() => {
if (mode !== 'system') {
const documentElement = document.documentElement;
if (documentElement) {
documentElement.classList.add(mode);
documentElement.classList.remove(mode === 'light' ? 'dark' : 'light');
documentElement.style.colorScheme = mode;
}
}
}, [mode]);
useSafeLayoutEffect(() => {
if (mode !== 'system') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
media.addListener(handleMediaQuery);
return () => media.removeListener(handleMediaQuery);
}, [handleMediaQuery]);
useSafeLayoutEffect(() => {
if (typeof window !== 'undefined') {
const documentElement = document.documentElement;
if (documentElement) {
const head = documentElement.querySelector('head');
let style = head?.querySelector(`[id='${variableStyleTagId}']`);
if (!style) {
style = createStyle(variableStyleTagId);
style.innerHTML = cssVariablesWithMode;
if (head) head.appendChild(style);
}
}
}
}, []);
return (
<>
<script
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `(${script.toString()})('${mode}')`,
}}
/>
<OverlayProvider>
<ToastProvider>{props.children}</ToastProvider>
</OverlayProvider>
</>
);
}

View File

@@ -0,0 +1,19 @@
export const script = (mode: string) => {
const documentElement = document.documentElement;
function getSystemColorMode() {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
try {
const isSystem = mode === 'system';
const theme = isSystem ? getSystemColorMode() : mode;
documentElement.classList.remove(theme === 'light' ? 'dark' : 'light');
documentElement.classList.add(theme);
documentElement.style.colorScheme = theme;
} catch (e) {
console.error(e);
}
};

1588
components/ui/icon/index.tsx Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,277 @@
'use client';
import React from 'react';
import { tva } from '@gluestack-ui/utils/nativewind-utils';
import { PrimitiveIcon, UIIcon } from '@gluestack-ui/core/icon/creator';
import {
withStyleContext,
useStyleContext,
} from '@gluestack-ui/utils/nativewind-utils';
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils';
import { createSelect } from '@gluestack-ui/core/select/creator';
import { cssInterop } from 'nativewind';
import {
Actionsheet,
ActionsheetContent,
ActionsheetItem,
ActionsheetItemText,
ActionsheetDragIndicator,
ActionsheetDragIndicatorWrapper,
ActionsheetBackdrop,
ActionsheetScrollView,
ActionsheetVirtualizedList,
ActionsheetFlatList,
ActionsheetSectionList,
ActionsheetSectionHeaderText,
} from './select-actionsheet';
import { Pressable, View, TextInput } from 'react-native';
const SelectTriggerWrapper = React.forwardRef<
React.ComponentRef<typeof Pressable>,
React.ComponentProps<typeof Pressable>
>(function SelectTriggerWrapper({ ...props }, ref) {
return <Pressable {...props} ref={ref} />;
});
const selectIconStyle = tva({
base: 'text-background-500 fill-none',
parentVariants: {
size: {
'2xs': 'h-3 w-3',
'xs': 'h-3.5 w-3.5',
'sm': 'h-4 w-4',
'md': 'h-[18px] w-[18px]',
'lg': 'h-5 w-5',
'xl': 'h-6 w-6',
},
},
});
const selectStyle = tva({
base: '',
});
const selectTriggerStyle = tva({
base: 'border border-background-300 rounded flex-row items-center overflow-hidden data-[hover=true]:border-outline-400 data-[focus=true]:border-primary-700 data-[disabled=true]:opacity-40 data-[disabled=true]:data-[hover=true]:border-background-300',
variants: {
size: {
xl: 'h-12',
lg: 'h-11',
md: 'h-10',
sm: 'h-9',
},
variant: {
underlined:
'border-0 border-b rounded-none data-[hover=true]:border-primary-700 data-[focus=true]:border-primary-700 data-[focus=true]:web:shadow-[inset_0_-1px_0_0] data-[focus=true]:web:shadow-primary-700 data-[invalid=true]:border-error-700 data-[invalid=true]:web:shadow-error-700',
outline:
'data-[focus=true]:border-primary-700 data-[focus=true]:web:shadow-[inset_0_0_0_1px] data-[focus=true]:data-[hover=true]:web:shadow-primary-600 data-[invalid=true]:web:shadow-[inset_0_0_0_1px] data-[invalid=true]:border-error-700 data-[invalid=true]:web:shadow-error-700 data-[invalid=true]:data-[hover=true]:border-error-700',
rounded:
'rounded-full data-[focus=true]:border-primary-700 data-[focus=true]:web:shadow-[inset_0_0_0_1px] data-[focus=true]:web:shadow-primary-700 data-[invalid=true]:border-error-700 data-[invalid=true]:web:shadow-error-700',
},
},
});
const selectInputStyle = tva({
base: 'px-3 placeholder:text-typography-500 web:w-full h-full text-typography-900 pointer-events-none web:outline-none ios:leading-[0px] py-0',
parentVariants: {
size: {
xl: 'text-xl',
lg: 'text-lg',
md: 'text-base',
sm: 'text-sm',
},
variant: {
underlined: 'px-0',
outline: '',
rounded: 'px-4',
},
},
});
const UISelect = createSelect(
{
Root: View,
Trigger: withStyleContext(SelectTriggerWrapper),
Input: TextInput,
Icon: UIIcon,
},
{
Portal: Actionsheet,
Backdrop: ActionsheetBackdrop,
Content: ActionsheetContent,
DragIndicator: ActionsheetDragIndicator,
DragIndicatorWrapper: ActionsheetDragIndicatorWrapper,
Item: ActionsheetItem,
ItemText: ActionsheetItemText,
ScrollView: ActionsheetScrollView,
VirtualizedList: ActionsheetVirtualizedList,
FlatList: ActionsheetFlatList,
SectionList: ActionsheetSectionList,
SectionHeaderText: ActionsheetSectionHeaderText,
}
);
cssInterop(UISelect, { className: 'style' });
cssInterop(UISelect.Input, {
className: { target: 'style', nativeStyleToProp: { textAlign: true } },
});
cssInterop(SelectTriggerWrapper, { className: 'style' });
cssInterop(PrimitiveIcon, {
className: {
target: 'style',
nativeStyleToProp: {
height: true,
width: true,
fill: true,
color: 'classNameColor',
stroke: true,
},
},
});
type ISelectProps = VariantProps<typeof selectStyle> &
React.ComponentProps<typeof UISelect> & { className?: string };
const Select = React.forwardRef<
React.ComponentRef<typeof UISelect>,
ISelectProps
>(function Select({ className, ...props }, ref) {
return (
<UISelect
className={selectStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
type ISelectTriggerProps = VariantProps<typeof selectTriggerStyle> &
React.ComponentProps<typeof UISelect.Trigger> & { className?: string };
const SelectTrigger = React.forwardRef<
React.ComponentRef<typeof UISelect.Trigger>,
ISelectTriggerProps
>(function SelectTrigger(
{ className, size = 'md', variant = 'outline', ...props },
ref
) {
return (
<UISelect.Trigger
className={selectTriggerStyle({
class: className,
size,
variant,
})}
ref={ref}
context={{ size, variant }}
{...props}
/>
);
});
type ISelectInputProps = VariantProps<typeof selectInputStyle> &
React.ComponentProps<typeof UISelect.Input> & { className?: string };
const SelectInput = React.forwardRef<
React.ComponentRef<typeof UISelect.Input>,
ISelectInputProps
>(function SelectInput({ className, ...props }, ref) {
const { size: parentSize, variant: parentVariant } = useStyleContext();
return (
<UISelect.Input
className={selectInputStyle({
class: className,
parentVariants: {
size: parentSize,
variant: parentVariant,
},
})}
ref={ref}
{...props}
/>
);
});
type ISelectIcon = VariantProps<typeof selectIconStyle> &
React.ComponentProps<typeof UISelect.Icon> & { className?: string };
const SelectIcon = React.forwardRef<
React.ComponentRef<typeof UISelect.Icon>,
ISelectIcon
>(function SelectIcon({ className, size, ...props }, ref) {
const { size: parentSize } = useStyleContext();
if (typeof size === 'number') {
return (
<UISelect.Icon
ref={ref}
{...props}
className={selectIconStyle({ class: className })}
size={size}
/>
);
} else if (
//@ts-expect-error : web only
(props?.height !== undefined || props?.width !== undefined) &&
size === undefined
) {
return (
<UISelect.Icon
ref={ref}
{...props}
className={selectIconStyle({ class: className })}
/>
);
}
return (
<UISelect.Icon
className={selectIconStyle({
class: className,
size,
parentVariants: {
size: parentSize,
},
})}
ref={ref}
{...props}
/>
);
});
Select.displayName = 'Select';
SelectTrigger.displayName = 'SelectTrigger';
SelectInput.displayName = 'SelectInput';
SelectIcon.displayName = 'SelectIcon';
// Actionsheet Components
const SelectPortal = UISelect.Portal;
const SelectBackdrop = UISelect.Backdrop;
const SelectContent = UISelect.Content;
const SelectDragIndicator = UISelect.DragIndicator;
const SelectDragIndicatorWrapper = UISelect.DragIndicatorWrapper;
const SelectItem = UISelect.Item;
const SelectScrollView = UISelect.ScrollView;
const SelectVirtualizedList = UISelect.VirtualizedList;
const SelectFlatList = UISelect.FlatList;
const SelectSectionList = UISelect.SectionList;
const SelectSectionHeaderText = UISelect.SectionHeaderText;
export {
Select,
SelectTrigger,
SelectInput,
SelectIcon,
SelectPortal,
SelectBackdrop,
SelectContent,
SelectDragIndicator,
SelectDragIndicatorWrapper,
SelectItem,
SelectScrollView,
SelectVirtualizedList,
SelectFlatList,
SelectSectionList,
SelectSectionHeaderText,
};

View File

@@ -0,0 +1,562 @@
'use client';
import { H4 } from '@expo/html-elements';
import { createActionsheet } from '@gluestack-ui/core/actionsheet/creator';
import {
Pressable,
View,
Text,
ScrollView,
VirtualizedList,
FlatList,
SectionList,
ViewStyle,
} from 'react-native';
import { PrimitiveIcon, UIIcon } from '@gluestack-ui/core/icon/creator';
import { tva } from '@gluestack-ui/utils/nativewind-utils';
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils';
import { withStyleContext } from '@gluestack-ui/utils/nativewind-utils';
import { cssInterop } from 'nativewind';
import {
Motion,
AnimatePresence,
createMotionAnimatedComponent,
MotionComponentProps,
} from '@legendapp/motion';
import React from 'react';
type IAnimatedPressableProps = React.ComponentProps<typeof Pressable> &
MotionComponentProps<typeof Pressable, ViewStyle, unknown, unknown, unknown>;
const AnimatedPressable = createMotionAnimatedComponent(
Pressable
) as React.ComponentType<IAnimatedPressableProps>;
type IMotionViewProps = React.ComponentProps<typeof View> &
MotionComponentProps<typeof View, ViewStyle, unknown, unknown, unknown>;
const MotionView = Motion.View as React.ComponentType<IMotionViewProps>;
export const UIActionsheet = createActionsheet({
Root: View,
Content: withStyleContext(MotionView),
Item: withStyleContext(Pressable),
ItemText: Text,
DragIndicator: View,
IndicatorWrapper: View,
Backdrop: AnimatedPressable,
ScrollView: ScrollView,
VirtualizedList: VirtualizedList,
FlatList: FlatList,
SectionList: SectionList,
SectionHeaderText: H4,
Icon: UIIcon,
AnimatePresence: AnimatePresence,
});
cssInterop(UIActionsheet, { className: 'style' });
cssInterop(UIActionsheet.Content, { className: 'style' });
cssInterop(UIActionsheet.Item, { className: 'style' });
cssInterop(UIActionsheet.ItemText, { className: 'style' });
cssInterop(UIActionsheet.DragIndicator, { className: 'style' });
cssInterop(UIActionsheet.DragIndicatorWrapper, { className: 'style' });
cssInterop(UIActionsheet.Backdrop, { className: 'style' });
cssInterop(UIActionsheet.ScrollView, {
className: 'style',
contentContainerClassName: 'contentContainerStyle',
indicatorClassName: 'indicatorStyle',
});
cssInterop(UIActionsheet.VirtualizedList, {
className: 'style',
ListFooterComponentClassName: 'ListFooterComponentStyle',
ListHeaderComponentClassName: 'ListHeaderComponentStyle',
contentContainerClassName: 'contentContainerStyle',
indicatorClassName: 'indicatorStyle',
});
cssInterop(UIActionsheet.FlatList, {
className: 'style',
ListFooterComponentClassName: 'ListFooterComponentStyle',
ListHeaderComponentClassName: 'ListHeaderComponentStyle',
columnWrapperClassName: 'columnWrapperStyle',
contentContainerClassName: 'contentContainerStyle',
indicatorClassName: 'indicatorStyle',
});
cssInterop(UIActionsheet.SectionList, { className: 'style' });
cssInterop(UIActionsheet.SectionHeaderText, { className: 'style' });
cssInterop(PrimitiveIcon, {
className: {
target: 'style',
nativeStyleToProp: {
height: true,
width: true,
fill: true,
color: 'classNameColor',
stroke: true,
},
},
});
const actionsheetStyle = tva({ base: 'w-full h-full web:pointer-events-none' });
const actionsheetContentStyle = tva({
base: 'items-center rounded-tl-3xl rounded-tr-3xl p-2 bg-background-0 web:pointer-events-auto web:select-none shadow-lg pb-safe',
});
const actionsheetItemStyle = tva({
base: 'w-full flex-row items-center p-3 rounded-sm data-[disabled=true]:opacity-40 data-[disabled=true]:web:pointer-events-auto data-[disabled=true]:web:cursor-not-allowed hover:bg-background-50 active:bg-background-100 data-[focus=true]:bg-background-100 web:data-[focus-visible=true]:bg-background-100 data-[checked=true]:bg-background-100',
});
const actionsheetItemTextStyle = tva({
base: 'text-typography-700 font-normal font-body tracking-md text-left mx-2',
variants: {
isTruncated: {
true: '',
},
bold: {
true: 'font-bold',
},
underline: {
true: 'underline',
},
strikeThrough: {
true: 'line-through',
},
size: {
'2xs': 'text-2xs',
'xs': 'text-xs',
'sm': 'text-sm',
'md': 'text-base',
'lg': 'text-lg',
'xl': 'text-xl',
'2xl': 'text-2xl',
'3xl': 'text-3xl',
'4xl': 'text-4xl',
'5xl': 'text-5xl',
'6xl': 'text-6xl',
},
},
defaultVariants: {
size: 'md',
},
});
const actionsheetDragIndicatorStyle = tva({
base: 'w-16 h-1 bg-background-400 rounded-full',
});
const actionsheetDragIndicatorWrapperStyle = tva({
base: 'w-full py-1 items-center',
});
const actionsheetBackdropStyle = tva({
base: 'absolute left-0 top-0 right-0 bottom-0 bg-background-dark web:cursor-default web:pointer-events-auto',
});
const actionsheetScrollViewStyle = tva({
base: 'w-full h-auto',
});
const actionsheetVirtualizedListStyle = tva({
base: 'w-full h-auto',
});
const actionsheetFlatListStyle = tva({
base: 'w-full h-auto',
});
const actionsheetSectionListStyle = tva({
base: 'w-full h-auto',
});
const actionsheetSectionHeaderTextStyle = tva({
base: 'leading-5 font-bold font-heading my-0 text-typography-500 p-3 uppercase',
variants: {
isTruncated: {
true: '',
},
bold: {
true: 'font-bold',
},
underline: {
true: 'underline',
},
strikeThrough: {
true: 'line-through',
},
size: {
'5xl': 'text-5xl',
'4xl': 'text-4xl',
'3xl': 'text-3xl',
'2xl': 'text-2xl',
'xl': 'text-xl',
'lg': 'text-lg',
'md': 'text-base',
'sm': 'text-sm',
'xs': 'text-xs',
},
sub: {
true: 'text-xs',
},
italic: {
true: 'italic',
},
highlight: {
true: 'bg-yellow500',
},
},
defaultVariants: {
size: 'xs',
},
});
const actionsheetIconStyle = tva({
base: 'text-typography-900',
variants: {
size: {
'2xs': 'h-3 w-3',
'xs': 'h-3.5 w-3.5',
'sm': 'h-4 w-4',
'md': 'w-4 h-4',
'lg': 'h-5 w-5',
'xl': 'h-6 w-6',
},
},
});
type IActionsheetProps = VariantProps<typeof actionsheetStyle> &
React.ComponentProps<typeof UIActionsheet> & { className?: string };
type IActionsheetContentProps = VariantProps<typeof actionsheetContentStyle> &
React.ComponentProps<typeof UIActionsheet.Content> & { className?: string };
type IActionsheetItemProps = VariantProps<typeof actionsheetItemStyle> &
React.ComponentProps<typeof UIActionsheet.Item> & { className?: string };
type IActionsheetItemTextProps = VariantProps<typeof actionsheetItemTextStyle> &
React.ComponentProps<typeof UIActionsheet.ItemText> & { className?: string };
type IActionsheetDragIndicatorProps = VariantProps<
typeof actionsheetDragIndicatorStyle
> &
React.ComponentProps<typeof UIActionsheet.DragIndicator> & {
className?: string;
};
type IActionsheetDragIndicatorWrapperProps = VariantProps<
typeof actionsheetDragIndicatorWrapperStyle
> &
React.ComponentProps<typeof UIActionsheet.DragIndicatorWrapper> & {
className?: string;
};
type IActionsheetBackdropProps = VariantProps<typeof actionsheetBackdropStyle> &
React.ComponentProps<typeof UIActionsheet.Backdrop> & {
className?: string;
};
type IActionsheetScrollViewProps = VariantProps<
typeof actionsheetScrollViewStyle
> &
React.ComponentProps<typeof UIActionsheet.ScrollView> & {
className?: string;
};
type IActionsheetVirtualizedListProps = VariantProps<
typeof actionsheetVirtualizedListStyle
> &
React.ComponentProps<typeof UIActionsheet.VirtualizedList> & {
className?: string;
};
type IActionsheetFlatListProps = VariantProps<typeof actionsheetFlatListStyle> &
React.ComponentProps<typeof UIActionsheet.FlatList> & {
className?: string;
};
type IActionsheetSectionListProps = VariantProps<
typeof actionsheetSectionListStyle
> &
React.ComponentProps<typeof UIActionsheet.SectionList> & {
className?: string;
};
type IActionsheetSectionHeaderTextProps = VariantProps<
typeof actionsheetSectionHeaderTextStyle
> &
React.ComponentProps<typeof UIActionsheet.SectionHeaderText> & {
className?: string;
};
type IActionsheetIconProps = VariantProps<typeof actionsheetIconStyle> &
React.ComponentProps<typeof UIActionsheet.Icon> & {
className?: string;
as?: React.ElementType;
};
const Actionsheet = React.forwardRef<
React.ComponentRef<typeof UIActionsheet>,
IActionsheetProps
>(function Actionsheet({ className, ...props }, ref) {
return (
<UIActionsheet
className={actionsheetStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetContent = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.Content>,
IActionsheetContentProps & { className?: string }
>(function ActionsheetContent({ className, ...props }, ref) {
return (
<UIActionsheet.Content
className={actionsheetContentStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetItem = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.Item>,
IActionsheetItemProps
>(function ActionsheetItem({ className, ...props }, ref) {
return (
<UIActionsheet.Item
className={actionsheetItemStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetItemText = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.ItemText>,
IActionsheetItemTextProps
>(function ActionsheetItemText(
{ className, isTruncated, bold, underline, strikeThrough, size, ...props },
ref
) {
return (
<UIActionsheet.ItemText
className={actionsheetItemTextStyle({
class: className,
isTruncated: isTruncated as boolean,
bold: bold as boolean,
underline: underline as boolean,
strikeThrough: strikeThrough as boolean,
size,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetDragIndicator = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.DragIndicator>,
IActionsheetDragIndicatorProps
>(function ActionsheetDragIndicator({ className, ...props }, ref) {
return (
<UIActionsheet.DragIndicator
className={actionsheetDragIndicatorStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetDragIndicatorWrapper = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.DragIndicatorWrapper>,
IActionsheetDragIndicatorWrapperProps
>(function ActionsheetDragIndicatorWrapper({ className, ...props }, ref) {
return (
<UIActionsheet.DragIndicatorWrapper
className={actionsheetDragIndicatorWrapperStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetBackdrop = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.Backdrop>,
IActionsheetBackdropProps
>(function ActionsheetBackdrop({ className, ...props }, ref) {
return (
<UIActionsheet.Backdrop
initial={{
opacity: 0,
}}
animate={{
opacity: 0.5,
}}
exit={{
opacity: 0,
}}
{...props}
className={actionsheetBackdropStyle({
class: className,
})}
ref={ref}
/>
);
});
const ActionsheetScrollView = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.ScrollView>,
IActionsheetScrollViewProps
>(function ActionsheetScrollView({ className, ...props }, ref) {
return (
<UIActionsheet.ScrollView
className={actionsheetScrollViewStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetVirtualizedList = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.VirtualizedList>,
IActionsheetVirtualizedListProps
>(function ActionsheetVirtualizedList({ className, ...props }, ref) {
return (
<UIActionsheet.VirtualizedList
className={actionsheetVirtualizedListStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetFlatList = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.FlatList>,
IActionsheetFlatListProps
>(function ActionsheetFlatList({ className, ...props }, ref) {
return (
<UIActionsheet.FlatList
className={actionsheetFlatListStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetSectionList = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.SectionList>,
IActionsheetSectionListProps
>(function ActionsheetSectionList({ className, ...props }, ref) {
return (
<UIActionsheet.SectionList
className={actionsheetSectionListStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetSectionHeaderText = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.SectionHeaderText>,
IActionsheetSectionHeaderTextProps
>(function ActionsheetSectionHeaderText(
{
className,
isTruncated,
bold,
underline,
strikeThrough,
size,
sub,
italic,
highlight,
...props
},
ref
) {
return (
<UIActionsheet.SectionHeaderText
className={actionsheetSectionHeaderTextStyle({
class: className,
isTruncated: isTruncated as boolean,
bold: bold as boolean,
underline: underline as boolean,
strikeThrough: strikeThrough as boolean,
size,
sub: sub as boolean,
italic: italic as boolean,
highlight: highlight as boolean,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetIcon = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.Icon>,
IActionsheetIconProps
>(function ActionsheetIcon(
{ className, as: AsComp, size = 'sm', ...props },
ref
) {
if (AsComp) {
return (
<AsComp
className={actionsheetIconStyle({
class: className,
size,
})}
ref={ref}
{...props}
/>
);
}
return (
<UIActionsheet.Icon
className={actionsheetIconStyle({
class: className,
size,
})}
ref={ref}
{...props}
/>
);
});
export {
Actionsheet,
ActionsheetContent,
ActionsheetItem,
ActionsheetItemText,
ActionsheetDragIndicator,
ActionsheetDragIndicatorWrapper,
ActionsheetBackdrop,
ActionsheetScrollView,
ActionsheetVirtualizedList,
ActionsheetFlatList,
ActionsheetSectionList,
ActionsheetSectionHeaderText,
ActionsheetIcon,
};