fitst commit
This commit is contained in:
19
screens/announcements/lib/api.ts
Normal file
19
screens/announcements/lib/api.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import httpClient from '@/api/httpClient';
|
||||
import { API_URLS } from '@/api/URLs';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { AnnouncementDetailBody, AnnouncementListBody } from './type';
|
||||
|
||||
export const announcement_api = {
|
||||
async list(params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
}): Promise<AxiosResponse<AnnouncementListBody>> {
|
||||
const res = await httpClient.get(API_URLS.DASHBOARD_ADS, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async detail(id: number): Promise<AxiosResponse<AnnouncementDetailBody>> {
|
||||
const res = await httpClient.get(API_URLS.DASHBOARD_ADS_DETAIL(id));
|
||||
return res;
|
||||
},
|
||||
};
|
||||
54
screens/announcements/lib/type.ts
Normal file
54
screens/announcements/lib/type.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export interface AnnouncementListBody {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: string | null;
|
||||
next: string | null;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: AnnouncementListBodyRes[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnnouncementListBodyRes {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
total_view_count: number;
|
||||
files: [
|
||||
{
|
||||
file: string;
|
||||
}
|
||||
];
|
||||
status: 'pending' | 'paid' | 'verified' | 'canceled';
|
||||
types: {
|
||||
id: number;
|
||||
name: string;
|
||||
icon_name: string;
|
||||
}[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AnnouncementDetailBody {
|
||||
status: boolean;
|
||||
data: {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
total_view_count: number;
|
||||
files: {
|
||||
id: number;
|
||||
file: string;
|
||||
}[];
|
||||
status: 'pending' | 'paid' | 'verified' | 'canceled';
|
||||
types: {
|
||||
id: number;
|
||||
name: string;
|
||||
icon_name: string;
|
||||
}[];
|
||||
created_at: string;
|
||||
};
|
||||
}
|
||||
38
screens/announcements/styles/styles.ts
Normal file
38
screens/announcements/styles/styles.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FAFBFF',
|
||||
},
|
||||
loader: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
card: {
|
||||
flex: 1,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
media: {
|
||||
width: '100%',
|
||||
height: 120,
|
||||
},
|
||||
content: {
|
||||
padding: 10,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
},
|
||||
desc: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
close: {
|
||||
fontSize: 22,
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
});
|
||||
227
screens/announcements/ui/AnnouncementCard.tsx
Normal file
227
screens/announcements/ui/AnnouncementCard.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ResizeMode, Video } from 'expo-av';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Package, PlayCircle } from 'lucide-react-native';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Dimensions, Image, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { announcement_api } from '../lib/api';
|
||||
import { AnnouncementListBodyRes } from '../lib/type';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const cardWidth = (width - 44) / 2;
|
||||
|
||||
export default function AnnouncementCard({
|
||||
announcement,
|
||||
}: {
|
||||
announcement: AnnouncementListBodyRes;
|
||||
}) {
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const file = announcement.files?.[0]?.file;
|
||||
const isVideo = file?.endsWith('.mp4');
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const theme = {
|
||||
cardBg: isDark ? '#1e293b' : '#ffffff',
|
||||
cardBorder: isDark ? '#334155' : '#e2e8f0',
|
||||
mediaBg: isDark ? '#0f172a' : '#f1f5f9',
|
||||
text: isDark ? '#f1f5f9' : '#0f172a',
|
||||
textSecondary: isDark ? '#cbd5e1' : '#64748b',
|
||||
textTertiary: isDark ? '#94a3b8' : '#94a3b8',
|
||||
sheetBg: isDark ? '#0f172a' : '#ffffff',
|
||||
indicator: isDark ? '#94a3b8' : '#cbd5e1',
|
||||
shadow: isDark ? '#000' : '#64748b',
|
||||
error: '#ef4444',
|
||||
placeholder: isDark ? '#cbd5e1' : '#94a3b8',
|
||||
};
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: any) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.4} />
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const openSheet = () => {
|
||||
bottomSheetRef.current?.present();
|
||||
setSheetOpen(true);
|
||||
};
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['announcement_detail', announcement.id],
|
||||
queryFn: () => announcement_api.detail(announcement.id),
|
||||
enabled: sheetOpen,
|
||||
});
|
||||
|
||||
const detail = data?.data?.data;
|
||||
const files = detail?.files || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Card */}
|
||||
<Pressable
|
||||
style={[styles.card, { backgroundColor: theme.cardBg, shadowColor: theme.shadow }]}
|
||||
onPress={openSheet}
|
||||
android_ripple={{ color: 'rgba(99, 102, 241, 0.1)' }}
|
||||
>
|
||||
<View style={[styles.mediaContainer, { backgroundColor: theme.mediaBg }]}>
|
||||
{file ? (
|
||||
<>
|
||||
<Image source={{ uri: file }} 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={theme.placeholder} />
|
||||
</View>
|
||||
)}
|
||||
<LinearGradient colors={['transparent', 'rgba(0,0,0,0.6)']} style={styles.gradient} />
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>
|
||||
{announcement.title}
|
||||
</Text>
|
||||
<Text style={[styles.desc, { color: theme.textSecondary }]} numberOfLines={2}>
|
||||
{announcement.description}
|
||||
</Text>
|
||||
<View style={styles.footer}>
|
||||
<Ionicons name="time-outline" size={14} color={theme.textTertiary} />
|
||||
<Text style={[styles.date, { color: theme.textTertiary }]}>
|
||||
{new Date(announcement.created_at).toLocaleDateString('uz-UZ')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
index={0}
|
||||
snapPoints={['70%', '95%']}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{ backgroundColor: theme.indicator, width: 50 }}
|
||||
backgroundStyle={{ backgroundColor: theme.sheetBg }}
|
||||
enablePanDownToClose
|
||||
>
|
||||
<BottomSheetScrollView contentContainerStyle={styles.sheetContent}>
|
||||
{isLoading && (
|
||||
<Text style={[styles.loading, { color: theme.textSecondary }]}>Yuklanmoqda...</Text>
|
||||
)}
|
||||
{isError && <Text style={[styles.error, { color: theme.error }]}>Xatolik yuz berdi</Text>}
|
||||
|
||||
{detail && (
|
||||
<>
|
||||
{/* Media carousel */}
|
||||
{files.length > 0 && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.carousel}
|
||||
>
|
||||
{files.map((f) => {
|
||||
const fileIsVideo = f.file?.endsWith('.mp4');
|
||||
return (
|
||||
<View key={f.id} style={styles.sheetMediaContainer}>
|
||||
{fileIsVideo ? (
|
||||
<Video
|
||||
source={{ uri: f.file }}
|
||||
style={styles.media}
|
||||
useNativeControls
|
||||
resizeMode={ResizeMode.CONTAIN}
|
||||
/>
|
||||
) : (
|
||||
<Image source={{ uri: f.file }} style={styles.media} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<Text style={[styles.sheetTitle, { color: theme.text }]}>{detail.title}</Text>
|
||||
|
||||
{/* Meta */}
|
||||
<View style={styles.metaRow}>
|
||||
<View style={styles.metaItem}>
|
||||
<Ionicons name="calendar-outline" size={16} color={theme.textTertiary} />
|
||||
<Text style={[styles.metaText, { color: theme.textTertiary }]}>
|
||||
{new Date(detail.created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaItem}>
|
||||
<Ionicons name="eye-outline" size={16} color={theme.textTertiary} />
|
||||
<Text style={[styles.metaText, { color: theme.textTertiary }]}>
|
||||
{detail.total_view_count} ko'rildi
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
<Text style={[styles.sheetDesc, { color: theme.textSecondary }]}>
|
||||
{detail.description}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
width: cardWidth,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
marginBottom: 16,
|
||||
},
|
||||
mediaContainer: {
|
||||
width: '100%',
|
||||
height: 160,
|
||||
position: 'relative',
|
||||
},
|
||||
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' },
|
||||
gradient: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 60 },
|
||||
content: { padding: 12 },
|
||||
title: { fontSize: 15, fontWeight: '700', marginBottom: 4 },
|
||||
desc: { fontSize: 13, marginBottom: 6 },
|
||||
footer: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
||||
date: { fontSize: 12 },
|
||||
|
||||
// BottomSheet styles
|
||||
sheetContent: { padding: 20, gap: 12 },
|
||||
carousel: { marginBottom: 12 },
|
||||
sheetMediaContainer: { width: width - 40, height: 200, marginRight: 12 },
|
||||
media: { width: '100%', height: '100%', borderRadius: 16 },
|
||||
sheetTitle: { fontSize: 18, fontWeight: '700' },
|
||||
metaRow: { flexDirection: 'row', gap: 16, marginVertical: 8 },
|
||||
metaItem: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
metaText: { fontSize: 14 },
|
||||
sheetDesc: { fontSize: 14, lineHeight: 20 },
|
||||
loading: { fontSize: 16 },
|
||||
error: { fontSize: 16 },
|
||||
});
|
||||
125
screens/announcements/ui/AnnouncementDetail.tsx
Normal file
125
screens/announcements/ui/AnnouncementDetail.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import BottomSheet, { BottomSheetScrollView } from '@gorhom/bottom-sheet';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ResizeMode, Video } from 'expo-av';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import { Dimensions, Image, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { announcement_api } from '../lib/api';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
export default function AnnouncementDetailSheet({ id }: { id: number }) {
|
||||
const bottomSheetRef = useRef<BottomSheet>(null);
|
||||
|
||||
// Sheet sizes
|
||||
const snapPoints = useMemo(() => [height * 0.3, height * 0.7], []);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['announcement_detail', id],
|
||||
queryFn: () => announcement_api.detail(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const announcement = data?.data?.data;
|
||||
const files = announcement?.files || [];
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
ref={bottomSheetRef}
|
||||
index={0}
|
||||
snapPoints={snapPoints}
|
||||
handleIndicatorStyle={{ backgroundColor: '#94a3b8' }}
|
||||
backgroundStyle={{ backgroundColor: '#0f172a', borderRadius: 24 }}
|
||||
>
|
||||
<BottomSheetScrollView contentContainerStyle={styles.contentContainer}>
|
||||
{isLoading && <Text style={styles.loading}>Yuklanmoqda...</Text>}
|
||||
{isError && <Text style={styles.error}>Xatolik yuz berdi</Text>}
|
||||
|
||||
{announcement && (
|
||||
<>
|
||||
{/* Carousel */}
|
||||
{files.length > 0 && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.carousel}
|
||||
>
|
||||
{files.map((file) => {
|
||||
const isVideo = file.file?.endsWith('.mp4');
|
||||
return (
|
||||
<View key={file.id} style={styles.mediaContainer}>
|
||||
{isVideo ? (
|
||||
<Video
|
||||
source={{ uri: file.file }}
|
||||
style={styles.media}
|
||||
useNativeControls
|
||||
resizeMode={ResizeMode.CONTAIN}
|
||||
/>
|
||||
) : (
|
||||
<Image source={{ uri: file.file }} style={styles.media} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<Text style={styles.title}>{announcement.title}</Text>
|
||||
|
||||
{/* Meta */}
|
||||
<View style={styles.metaRow}>
|
||||
<View style={styles.metaItem}>
|
||||
<Ionicons name="calendar-outline" size={16} color="#94a3b8" />
|
||||
<Text style={styles.metaText}>
|
||||
{new Date(announcement.created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaItem}>
|
||||
<Ionicons name="eye-outline" size={16} color="#94a3b8" />
|
||||
<Text style={styles.metaText}>{announcement.total_view_count} ko'rildi</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Status */}
|
||||
{/* <View style={styles.statusBadge}>
|
||||
<Text style={styles.statusText}>
|
||||
{announcement.status === 'pending' && 'Kutilmoqda'}
|
||||
{announcement.status === 'paid' && "To'langan"}
|
||||
{announcement.status === 'verified' && 'Tasdiqlangan'}
|
||||
{announcement.status === 'canceled' && 'Bekor qilingan'}
|
||||
</Text>
|
||||
</View> */}
|
||||
|
||||
{/* Description */}
|
||||
<Text style={styles.desc}>{announcement.description}</Text>
|
||||
</>
|
||||
)}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
contentContainer: { padding: 20, gap: 12 },
|
||||
carousel: { marginBottom: 12 },
|
||||
mediaContainer: { width: width - 40, height: 200, marginRight: 12 },
|
||||
media: { width: '100%', height: '100%', borderRadius: 16 },
|
||||
title: { fontSize: 18, fontWeight: '700', color: '#f1f5f9' },
|
||||
metaRow: { flexDirection: 'row', gap: 16, marginVertical: 8 },
|
||||
metaItem: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
metaText: { color: '#94a3b8', fontSize: 14 },
|
||||
statusBadge: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#2563eb',
|
||||
marginBottom: 12,
|
||||
},
|
||||
statusText: { color: '#f1f5f9', fontWeight: '600' },
|
||||
desc: { color: '#cbd5f5', fontSize: 14, lineHeight: 20 },
|
||||
loading: { color: '#cbd5f5', fontSize: 16 },
|
||||
error: { color: '#ef4444', fontSize: 16 },
|
||||
});
|
||||
84
screens/announcements/ui/AnnouncementHeader.tsx
Normal file
84
screens/announcements/ui/AnnouncementHeader.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { styles } from '@/screens/welcome/styles/welcomeStyle';
|
||||
import LanguageSelect from '@/screens/welcome/ui/LanguageSelect';
|
||||
import AntDesign from '@expo/vector-icons/AntDesign';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Animated, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
export default function AnnouncementHeader({ menuOpen }: { onMenuPress: any; menuOpen: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const rotateAnim = useRef(new Animated.Value(0)).current;
|
||||
const scaleAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (menuOpen) {
|
||||
Animated.parallel([
|
||||
Animated.spring(rotateAnim, {
|
||||
toValue: 1,
|
||||
tension: 100,
|
||||
friction: 8,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.sequence([
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 0.8,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
tension: 100,
|
||||
friction: 5,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.spring(rotateAnim, {
|
||||
toValue: 0,
|
||||
tension: 100,
|
||||
friction: 8,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.sequence([
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 0.8,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
tension: 100,
|
||||
friction: 5,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
]).start();
|
||||
}
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerContent}>
|
||||
<View style={styles.logoBox}>
|
||||
<TouchableOpacity onPress={() => router.back()}>
|
||||
<AntDesign name="left" size={18} color="black" />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.logoCircle}>
|
||||
<ThemedText style={styles.logoText}>IT</ThemedText>
|
||||
</View>
|
||||
<ThemedText style={styles.brandText}>{t('common.target')}</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerRight}>
|
||||
<LanguageSelect />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
115
screens/announcements/ui/AnnouncementsList.tsx
Normal file
115
screens/announcements/ui/AnnouncementsList.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ActivityIndicator, Animated, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { announcement_api } from '../lib/api';
|
||||
import { AnnouncementListBodyRes } from '../lib/type';
|
||||
import AnnouncementCard from './AnnouncementCard';
|
||||
import EmptyState from './EmptyState';
|
||||
|
||||
export default function DashboardScreen() {
|
||||
const [announcements, setAnnouncements] = useState<AnnouncementListBodyRes[]>([]);
|
||||
const queryClient = useQueryClient();
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const theme = {
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
primary: '#2563eb',
|
||||
loaderBg: isDark ? '#0f172a' : '#ffffff',
|
||||
};
|
||||
|
||||
const { data, isLoading, isRefetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: ['announcements_list'],
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
const res = await announcement_api.list({ page: pageParam, page_size: 10 });
|
||||
return res.data.data;
|
||||
},
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.current_page < lastPage.total_pages ? lastPage.current_page + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
});
|
||||
|
||||
const allAnnouncements = data?.pages.flatMap((p) => p.results) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
setAnnouncements(allAnnouncements);
|
||||
|
||||
fadeAnim.setValue(0);
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [allAnnouncements]);
|
||||
|
||||
const onRefresh = () => {
|
||||
queryClient.refetchQueries({ queryKey: ['announcements_list'] });
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
if (hasNextPage) fetchNextPage();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<View style={[styles.loaderBox, { backgroundColor: theme.loaderBg }]}>
|
||||
<ActivityIndicator size="large" color={theme.primary} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<Text style={{ color: 'white', fontSize: 20, marginBottom: 10 }}>
|
||||
{t("E'lonlar ro'yxati")}
|
||||
</Text>
|
||||
{announcements.length > 0 ? (
|
||||
<Animated.FlatList
|
||||
style={{ flex: 1 }}
|
||||
data={announcements}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
numColumns={2}
|
||||
columnWrapperStyle={styles.columnWrapper}
|
||||
renderItem={({ item }) => <AnnouncementCard announcement={item} />}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefetching}
|
||||
onRefresh={onRefresh}
|
||||
colors={[theme.primary]}
|
||||
tintColor={theme.primary}
|
||||
progressBackgroundColor={theme.background}
|
||||
/>
|
||||
}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState onRefresh={onRefresh} isRefreshing={isRefetching} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
marginTop: 20,
|
||||
},
|
||||
loaderBox: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
columnWrapper: {
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
});
|
||||
136
screens/announcements/ui/EmptyState.tsx
Normal file
136
screens/announcements/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
};
|
||||
|
||||
export default function EmptyState({
|
||||
title = 'Maʼlumot topilmadi',
|
||||
description = 'Hozircha hech qanday eʼlon mavjud emas',
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
}: Props) {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const theme = {
|
||||
gradientColors: isDark
|
||||
? (['#1e293b', '#334155'] as [string, string])
|
||||
: (['#EEF2FF', '#E0E7FF'] as [string, string]),
|
||||
iconColor: '#6366F1',
|
||||
title: isDark ? '#f8fafc' : '#1F2937',
|
||||
description: isDark ? '#94a3b8' : '#6B7280',
|
||||
buttonBg: '#6366F1',
|
||||
buttonText: '#ffffff',
|
||||
dotColor: '#6366F1',
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={emptyStyles.container}>
|
||||
<LinearGradient colors={theme.gradientColors} style={emptyStyles.iconContainer}>
|
||||
<Ionicons name="megaphone-outline" size={64} color={theme.iconColor} />
|
||||
</LinearGradient>
|
||||
|
||||
<Text style={[emptyStyles.title, { color: theme.title }]}>{title}</Text>
|
||||
<Text style={[emptyStyles.description, { color: theme.description }]}>{description}</Text>
|
||||
|
||||
{onRefresh && (
|
||||
<TouchableOpacity
|
||||
style={[emptyStyles.refreshBtn, { backgroundColor: theme.buttonBg }]}
|
||||
onPress={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<ActivityIndicator color={theme.buttonText} size="small" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="refresh" size={20} color={theme.buttonText} />
|
||||
<Text style={[emptyStyles.refreshText, { color: theme.buttonText }]}>Yangilash</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<View style={emptyStyles.decoration}>
|
||||
<View style={[emptyStyles.dot, { backgroundColor: theme.dotColor }]} />
|
||||
<View
|
||||
style={[emptyStyles.dot, emptyStyles.dotMedium, { backgroundColor: theme.dotColor }]}
|
||||
/>
|
||||
<View
|
||||
style={[emptyStyles.dot, emptyStyles.dotSmall, { backgroundColor: theme.dotColor }]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
description: {
|
||||
fontSize: 15,
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
marginBottom: 24,
|
||||
},
|
||||
refreshBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 12,
|
||||
shadowColor: '#6366F1',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
minWidth: 140,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
refreshText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
decoration: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginTop: 32,
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
dotMedium: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
dotSmall: {
|
||||
opacity: 0.3,
|
||||
},
|
||||
});
|
||||
84
screens/announcements/ui/PaginationLite.tsx
Normal file
84
screens/announcements/ui/PaginationLite.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
export default function PaginationLite({ currentPage, totalPages, onChange }: any) {
|
||||
const canGoPrev = currentPage > 1;
|
||||
const canGoNext = currentPage < totalPages;
|
||||
|
||||
return (
|
||||
<View style={paginationStyles.container}>
|
||||
<Pressable
|
||||
disabled={!canGoPrev}
|
||||
onPress={() => onChange(currentPage - 1)}
|
||||
style={[paginationStyles.btn, !canGoPrev && paginationStyles.btnDisabled]}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={20} color={canGoPrev ? '#6366F1' : '#D1D5DB'} />
|
||||
</Pressable>
|
||||
|
||||
<View style={paginationStyles.indicator}>
|
||||
<Text style={paginationStyles.currentPage}>{currentPage}</Text>
|
||||
<Text style={paginationStyles.separator}>/</Text>
|
||||
<Text style={paginationStyles.totalPages}>{totalPages}</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
disabled={!canGoNext}
|
||||
onPress={() => onChange(currentPage + 1)}
|
||||
style={[paginationStyles.btn, !canGoNext && paginationStyles.btnDisabled]}
|
||||
>
|
||||
<Ionicons name="chevron-forward" size={20} color={canGoNext ? '#6366F1' : '#D1D5DB'} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const paginationStyles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
paddingVertical: 20,
|
||||
backgroundColor: '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#F0F2F8',
|
||||
},
|
||||
btn: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#F9FAFB',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnDisabled: {
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderColor: '#E5E7EB',
|
||||
},
|
||||
indicator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: '#EEF2FF',
|
||||
borderRadius: 12,
|
||||
},
|
||||
currentPage: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#6366F1',
|
||||
},
|
||||
separator: {
|
||||
fontSize: 16,
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
totalPages: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#6B7280',
|
||||
},
|
||||
});
|
||||
239
screens/auth/confirm/ConfirmForm.tsx
Normal file
239
screens/auth/confirm/ConfirmForm.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Keyboard,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
interface OtpFormProps {
|
||||
phone?: string;
|
||||
initialCode?: string;
|
||||
onSubmit?: (otp: string) => void;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
onResendPress?: () => void;
|
||||
resendTimer: number;
|
||||
}
|
||||
|
||||
const ConfirmForm = ({
|
||||
initialCode,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
error,
|
||||
onResendPress,
|
||||
resendTimer,
|
||||
}: OtpFormProps) => {
|
||||
const [otp, setOtp] = useState<string[]>(Array(4).fill(''));
|
||||
const [focusedIndex, setFocusedIndex] = useState<number>(0);
|
||||
const inputRefs = useRef<TextInput[]>([]);
|
||||
const shakeAnimation = useRef(new Animated.Value(0)).current;
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
Animated.sequence([
|
||||
Animated.timing(shakeAnimation, { toValue: 10, duration: 50, useNativeDriver: true }),
|
||||
Animated.timing(shakeAnimation, { toValue: -10, duration: 50, useNativeDriver: true }),
|
||||
Animated.timing(shakeAnimation, { toValue: 10, duration: 50, useNativeDriver: true }),
|
||||
Animated.timing(shakeAnimation, { toValue: 0, duration: 50, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCode && initialCode.length === 4 && /^\d{4}$/.test(initialCode)) {
|
||||
setOtp(initialCode.split(''));
|
||||
}
|
||||
}, [initialCode]);
|
||||
|
||||
// Raqam kiritilganda yoki o'chirilganda
|
||||
const handleChange = (value: string, index: number) => {
|
||||
const cleanValue = value.replace(/[^0-9]/g, '');
|
||||
const newOtp = [...otp];
|
||||
|
||||
// Agar qiymat bo'sh bo'lsa (o'chirilgan bo'lsa)
|
||||
if (value === '') {
|
||||
newOtp[index] = '';
|
||||
setOtp(newOtp);
|
||||
return;
|
||||
}
|
||||
|
||||
// Faqat oxirgi kiritilgan raqamni olish
|
||||
newOtp[index] = cleanValue.slice(-1);
|
||||
setOtp(newOtp);
|
||||
|
||||
// Keyingi katakka o'tish
|
||||
if (newOtp[index] !== '' && index < 3) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
|
||||
// Hamma raqam kiritilgan bo'lsa avtomat yuborish
|
||||
const fullCode = newOtp.join('');
|
||||
if (fullCode.length === 4) {
|
||||
Keyboard.dismiss();
|
||||
onSubmit?.(fullCode);
|
||||
}
|
||||
};
|
||||
|
||||
// Maxsus tugmalar (Backspace) uchun
|
||||
const handleKeyPress = ({ nativeEvent }: any, index: number) => {
|
||||
if (nativeEvent.key === 'Backspace') {
|
||||
// Agar katakda raqam bo'lsa, uni o'chiradi
|
||||
if (otp[index] !== '') {
|
||||
const newOtp = [...otp];
|
||||
newOtp[index] = '';
|
||||
setOtp(newOtp);
|
||||
}
|
||||
// Agar katak bo'sh bo'lsa, oldingisiga o'tib uni ham o'chiradi
|
||||
else if (index > 0) {
|
||||
const newOtp = [...otp];
|
||||
newOtp[index - 1] = '';
|
||||
setOtp(newOtp);
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Animated.View style={[styles.otpContainer, { transform: [{ translateX: shakeAnimation }] }]}>
|
||||
{otp.map((digit, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
activeOpacity={1}
|
||||
onPress={() => inputRefs.current[index]?.focus()}
|
||||
style={[
|
||||
styles.inputBox,
|
||||
focusedIndex === index && styles.inputBoxFocused,
|
||||
digit !== '' && styles.inputBoxFilled,
|
||||
error ? styles.inputBoxError : null,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.inputText, digit === '' && styles.placeholderText]}>
|
||||
{digit || '•'}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
ref={(ref) => {
|
||||
if (ref) inputRefs.current[index] = ref;
|
||||
}}
|
||||
value={digit}
|
||||
onChangeText={(v) => handleChange(v, index)}
|
||||
onKeyPress={(e) => handleKeyPress(e, index)}
|
||||
onFocus={() => setFocusedIndex(index)}
|
||||
onBlur={() => setFocusedIndex(-1)}
|
||||
keyboardType="number-pad"
|
||||
maxLength={1}
|
||||
style={styles.hiddenInput}
|
||||
caretHidden
|
||||
selectTextOnFocus // Bu 2 marta bosish muammosini oldini olishga yordam beradi
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</Animated.View>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => onSubmit?.(otp.join(''))}
|
||||
disabled={isLoading || otp.join('').length !== 4}
|
||||
activeOpacity={0.8}
|
||||
style={[
|
||||
styles.submitButton,
|
||||
(isLoading || otp.join('').length !== 4) && styles.submitButtonDisabled,
|
||||
]}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<Text style={styles.submitText}>{t('Kodni tasdiqlash')}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.resendContainer}>
|
||||
{resendTimer > 0 ? (
|
||||
<Text style={styles.timerText}>
|
||||
{t('Qayta yuborish vaqti')}: <Text style={styles.timerCount}>{resendTimer}s</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<TouchableOpacity onPress={onResendPress} style={styles.resendButton}>
|
||||
<Text style={styles.resendText}>{t('Kodni qayta yuborish')}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { width: '100%', paddingVertical: 10 },
|
||||
otpContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 20,
|
||||
paddingHorizontal: 5,
|
||||
},
|
||||
inputBox: {
|
||||
width: 60,
|
||||
height: 65,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#E2E8F0',
|
||||
backgroundColor: '#F8FAFC',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 1,
|
||||
},
|
||||
inputBoxFocused: {
|
||||
borderColor: '#3B82F6',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderWidth: 2,
|
||||
elevation: 4,
|
||||
},
|
||||
inputBoxFilled: {
|
||||
borderColor: '#3B82F6',
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
inputBoxError: {
|
||||
borderColor: '#EF4444',
|
||||
backgroundColor: '#FFF1F2',
|
||||
},
|
||||
inputText: { fontSize: 26, fontWeight: '700', color: '#1E293B' },
|
||||
placeholderText: { color: '#CBD5E1', fontSize: 18 },
|
||||
hiddenInput: { position: 'absolute', width: '100%', height: '100%', opacity: 0 },
|
||||
errorText: {
|
||||
color: '#EF4444',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
fontWeight: '500',
|
||||
},
|
||||
submitButton: {
|
||||
height: 56,
|
||||
backgroundColor: '#3B82F6',
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 3,
|
||||
},
|
||||
submitButtonDisabled: { backgroundColor: '#94A3B8', elevation: 0 },
|
||||
submitText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600' },
|
||||
resendContainer: { alignItems: 'center', marginTop: 25 },
|
||||
timerText: { color: '#64748B', fontSize: 14 },
|
||||
timerCount: { color: '#1E293B', fontWeight: '700' },
|
||||
resendButton: { paddingVertical: 5 },
|
||||
resendText: {
|
||||
color: '#3B82F6',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
});
|
||||
|
||||
export default ConfirmForm;
|
||||
324
screens/auth/confirm/ConfirmScreen.tsx
Normal file
324
screens/auth/confirm/ConfirmScreen.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { useAuth } from '@/components/AuthProvider';
|
||||
import AuthHeader from '@/components/ui/AuthHeader';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Redirect, useRouter } from 'expo-router';
|
||||
import { ArrowLeft, MessageCircle, ShieldCheck } from 'lucide-react-native';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Linking,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
ToastAndroid,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { auth_api } from '../login/lib/api';
|
||||
import ConfirmForm from './ConfirmForm';
|
||||
|
||||
const ConfirmScreen = () => {
|
||||
const router = useRouter();
|
||||
const [phoneOTP, setPhone] = useState<string | null>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
const { login } = useAuth();
|
||||
|
||||
const [resendTimer, setResendTimer] = useState<number>(60);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const loadPhone = async () => {
|
||||
try {
|
||||
const storedPhone = await AsyncStorage.getItem('phone');
|
||||
if (storedPhone) setPhone(storedPhone);
|
||||
else setPhone(null);
|
||||
} catch (error) {
|
||||
console.log('AsyncStorage error:', error);
|
||||
}
|
||||
};
|
||||
loadPhone();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (resendTimer === 0) return;
|
||||
const timer = setTimeout(() => {
|
||||
setResendTimer((prev) => prev - 1);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [resendTimer]);
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (body: { code: string; phone: string }) => auth_api.verify_otp(body),
|
||||
onSuccess: async (res) => {
|
||||
await AsyncStorage.removeItem('phone');
|
||||
await AsyncStorage.setItem('access_token', res.data.data.token.access);
|
||||
await login(res.data.data.token.access);
|
||||
await AsyncStorage.setItem('refresh_token', res.data.data.token.refresh);
|
||||
router.replace('/(dashboard)');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const errorMessage = err?.response?.data?.data?.detail || t("Kod noto'g'ri kiritildi");
|
||||
Alert.alert(t('Xatolik yuz berdi'), errorMessage);
|
||||
setError(errorMessage);
|
||||
},
|
||||
});
|
||||
|
||||
const resendMutation = useMutation({
|
||||
mutationFn: async (body: { phone: string }) => auth_api.resend_otp(body),
|
||||
onSuccess: () => {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setResendTimer(60);
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show(t('Kod qayta yuborildi'), ToastAndroid.SHORT);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
Alert.alert(t('Xatolik yuz berdi'), t('Kodni qayta yuborishda xatolik yuz berdi'));
|
||||
},
|
||||
});
|
||||
|
||||
const openBotLink = () => {
|
||||
const linkApp = `tg://resolve?domain=infotargetbot&start=register_${phoneOTP}`;
|
||||
Linking.openURL(linkApp).catch(() => {
|
||||
const webLink = `https://t.me/infotargetbot?start=register_${phoneOTP}`;
|
||||
Linking.openURL(webLink);
|
||||
});
|
||||
};
|
||||
|
||||
if (phoneOTP === null) {
|
||||
return <Redirect href={'/'} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={['#0f172a', '#1e293b', '#334155']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
<View style={styles.decorCircle1} />
|
||||
<View style={styles.decorCircle2} />
|
||||
<AuthHeader />
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<KeyboardAwareScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.iconContainer}>
|
||||
<LinearGradient colors={['#3b82f6', '#2563eb']} style={styles.iconGradient}>
|
||||
<ShieldCheck size={32} color="#ffffff" strokeWidth={2.2} />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>{t('Kodni tasdiqlash')}</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{t("Tasdiqlash kodi sizning Telegram botingizga yuboriladi. Botni ko'rish")}
|
||||
</Text>
|
||||
|
||||
<View style={styles.phoneBadge}>
|
||||
<Text style={styles.phoneText}>+{phoneOTP}</Text>
|
||||
</View>
|
||||
|
||||
{/* Telegram Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.telegramBanner}
|
||||
onPress={openBotLink}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#0088cc', '#00a2ed']}
|
||||
style={styles.telegramGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
<View style={styles.botIconCircle}>
|
||||
<MessageCircle size={20} color="#0088cc" fill="#fff" />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.telegramTitle}>{t('Botni ochish')}</Text>
|
||||
<Text style={styles.telegramSub}>
|
||||
{t('Telegram botni ochish uchun tugmani bosing va kodni oling')}
|
||||
</Text>
|
||||
</View>
|
||||
<ArrowLeft size={20} color="#fff" style={{ transform: [{ rotate: '180deg' }] }} />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<ConfirmForm
|
||||
onSubmit={(otp) => mutate({ code: otp, phone: phoneOTP || '' })}
|
||||
isLoading={isPending}
|
||||
error={error}
|
||||
onResendPress={() => resendMutation.mutate({ phone: phoneOTP || '' })}
|
||||
resendTimer={resendTimer}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* <View style={styles.infoBox}>
|
||||
<Text style={styles.infoText}>
|
||||
<Text style={{ fontWeight: '700' }}>Eslatma:</Text> Kod SMS orqali kelmaydi. Agar
|
||||
botni ishga tushirmagan bo'lsangiz, yuqoridagi tugmani bosing.
|
||||
</Text>
|
||||
</View> */}
|
||||
</KeyboardAwareScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0f172a' },
|
||||
scrollContent: {
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 40,
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
languageHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
zIndex: 1000,
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
},
|
||||
languageButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
},
|
||||
languageText: { fontSize: 14, fontWeight: '600', color: '#94a3b8' },
|
||||
header: { alignItems: 'center', marginBottom: 24 },
|
||||
iconContainer: { marginBottom: 20 },
|
||||
iconGradient: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 8,
|
||||
shadowColor: '#3b82f6',
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 12,
|
||||
},
|
||||
title: { fontSize: 28, fontWeight: '800', color: '#ffffff', marginBottom: 8 },
|
||||
subtitle: {
|
||||
fontSize: 15,
|
||||
color: '#94a3b8',
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
highlightText: { color: '#38bdf8', fontWeight: '700' },
|
||||
phoneBadge: {
|
||||
marginTop: 16,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
phoneText: { color: '#60a5fa', fontWeight: '700', fontSize: 16 },
|
||||
telegramBanner: {
|
||||
marginTop: 24,
|
||||
width: '100%',
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
elevation: 6,
|
||||
shadowColor: '#0088cc',
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 10,
|
||||
},
|
||||
telegramGradient: { flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 },
|
||||
botIconCircle: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
telegramTitle: { color: '#ffffff', fontSize: 16, fontWeight: '700' },
|
||||
telegramSub: { color: 'rgba(255, 255, 255, 0.8)', fontSize: 12 },
|
||||
card: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 28,
|
||||
padding: 24,
|
||||
elevation: 10,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 15,
|
||||
},
|
||||
infoBox: {
|
||||
marginTop: 20,
|
||||
padding: 16,
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.08)',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(245, 158, 11, 0.2)',
|
||||
},
|
||||
infoText: { fontSize: 13, color: '#fbbf24', textAlign: 'center', lineHeight: 20 },
|
||||
dropdown: {
|
||||
position: 'absolute',
|
||||
top: 55,
|
||||
right: 0,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
padding: 8,
|
||||
minWidth: 170,
|
||||
elevation: 10,
|
||||
zIndex: 2000,
|
||||
},
|
||||
dropdownOption: { padding: 12, borderRadius: 10 },
|
||||
dropdownOptionActive: { backgroundColor: '#eff6ff' },
|
||||
dropdownOptionText: { fontSize: 14, color: '#475569', fontWeight: '600' },
|
||||
dropdownOptionTextActive: { color: '#3b82f6' },
|
||||
decorCircle1: {
|
||||
position: 'absolute',
|
||||
top: -100,
|
||||
right: -80,
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: 150,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
decorCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: -50,
|
||||
left: -100,
|
||||
width: 250,
|
||||
height: 250,
|
||||
borderRadius: 125,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.05)',
|
||||
},
|
||||
});
|
||||
|
||||
export default ConfirmScreen;
|
||||
55
screens/auth/login/lib/api.ts
Normal file
55
screens/auth/login/lib/api.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import httpClient from '@/api/httpClient';
|
||||
import { API_URLS } from '@/api/URLs';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
|
||||
interface ConfirmBody {
|
||||
status: boolean;
|
||||
data: {
|
||||
detail: string;
|
||||
token: {
|
||||
access: string;
|
||||
refresh: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const auth_api = {
|
||||
async login(body: { phone: string }) {
|
||||
const res = await httpClient.post(API_URLS.LOGIN, body);
|
||||
return res;
|
||||
},
|
||||
|
||||
async verify_otp(body: { code: string; phone: string }): Promise<AxiosResponse<ConfirmBody>> {
|
||||
const res = await httpClient.post(API_URLS.LoginConfirm, body);
|
||||
return res;
|
||||
},
|
||||
async resend_otp(body: { phone: string }) {
|
||||
const res = await httpClient.post(API_URLS.ResendOTP, body);
|
||||
return res;
|
||||
},
|
||||
|
||||
async get_info(inn: string) {
|
||||
const res = await axios.get(`https://devapi.goodsign.biz/v1/profile/${inn}`);
|
||||
return res;
|
||||
},
|
||||
|
||||
async register(body: {
|
||||
phone: string;
|
||||
stir: string;
|
||||
person_type: string;
|
||||
activate_types: number[];
|
||||
}) {
|
||||
const res = await httpClient.post(API_URLS.Register, body);
|
||||
return res;
|
||||
},
|
||||
|
||||
async register_confirm(body: { phone: string; code: string }) {
|
||||
const res = await httpClient.post(API_URLS.Register_Confirm, body);
|
||||
return res;
|
||||
},
|
||||
|
||||
async register_resend(body: { phone: string }) {
|
||||
const res = await httpClient.post(API_URLS.Register_Resend, body);
|
||||
return res;
|
||||
},
|
||||
};
|
||||
20
screens/auth/login/lib/storage.ts
Normal file
20
screens/auth/login/lib/storage.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type State = {
|
||||
phone: string | null;
|
||||
userType: string | null;
|
||||
};
|
||||
|
||||
type Actions = {
|
||||
savedPhone: (phone: string | null) => void;
|
||||
savedUserType: (userType: string | null) => void;
|
||||
};
|
||||
|
||||
const userInfoStore = create<State & Actions>((set) => ({
|
||||
phone: null,
|
||||
savedPhone: (phone: string | null) => set(() => ({ phone })),
|
||||
userType: null,
|
||||
savedUserType: (userType: string | null) => set(() => ({ userType })),
|
||||
}));
|
||||
|
||||
export default userInfoStore;
|
||||
242
screens/auth/login/ui/LoginForm.tsx
Normal file
242
screens/auth/login/ui/LoginForm.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { formatPhone, normalizeDigits } from '@/constants/formatPhone';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Check } from 'lucide-react-native';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import PhonePrefix from './PhonePrefix';
|
||||
import { UseLoginForm } from './UseLoginForm';
|
||||
|
||||
export default function LoginForm() {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scaleAnim = useRef(new Animated.Value(1)).current;
|
||||
const { phone, setPhone, submit, loading, error } = UseLoginForm();
|
||||
console.log(error);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleChange = useCallback(
|
||||
(text: string) => {
|
||||
setPhone(normalizeDigits(text));
|
||||
},
|
||||
[setPhone]
|
||||
);
|
||||
|
||||
const handlePressIn = () => {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 0.96,
|
||||
friction: 7,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
friction: 7,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
const isComplete = phone.length === 9;
|
||||
const hasError = !!error;
|
||||
|
||||
return (
|
||||
<View style={styles.form}>
|
||||
<Text style={styles.label}>{t('Telefon raqami')}</Text>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.inputContainer,
|
||||
focused && styles.inputFocused,
|
||||
hasError && styles.inputError,
|
||||
isComplete && styles.inputComplete,
|
||||
]}
|
||||
>
|
||||
<PhonePrefix focused={focused} />
|
||||
|
||||
<TextInput
|
||||
value={formatPhone(phone)}
|
||||
onChangeText={handleChange}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
keyboardType="phone-pad"
|
||||
placeholder="90 123 45 67"
|
||||
placeholderTextColor="#94a3b8"
|
||||
style={styles.input}
|
||||
maxLength={12}
|
||||
/>
|
||||
|
||||
{isComplete && (
|
||||
<View style={styles.iconCheck}>
|
||||
<Check size={18} color="#10b981" strokeWidth={3} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{phone.length > 0 && (
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={styles.progressBar}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{ width: `${Math.min((phone.length / 9) * 100, 100)}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.progressText}>{phone.length}/9</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
disabled={loading || !isComplete}
|
||||
onPress={async () => {
|
||||
const fullPhone = `998${phone}`;
|
||||
await AsyncStorage.setItem('phone', fullPhone);
|
||||
await AsyncStorage.setItem('userType', 'legal_entity');
|
||||
submit();
|
||||
}}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
style={[styles.button, (loading || !isComplete) && styles.buttonDisabled]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#ffffff" size="small" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>{t('Tasdiqlash kodini yuborish')}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
form: {
|
||||
gap: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#475569',
|
||||
marginBottom: 4,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 14,
|
||||
borderWidth: 2,
|
||||
borderColor: '#e2e8f0',
|
||||
paddingHorizontal: 16,
|
||||
height: 56,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
inputFocused: {
|
||||
borderColor: '#3b82f6',
|
||||
shadowColor: '#3b82f6',
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
inputError: {
|
||||
borderColor: '#ef4444',
|
||||
backgroundColor: '#fef2f2',
|
||||
},
|
||||
inputComplete: {
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: '#f0fdf4',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#0f172a',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
iconCheck: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#d1fae5',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
progressContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
progressBar: {
|
||||
flex: 1,
|
||||
height: 3,
|
||||
backgroundColor: '#e2e8f0',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: '#3b82f6',
|
||||
borderRadius: 2,
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 12,
|
||||
color: '#64748b',
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorContainer: {
|
||||
marginTop: -8,
|
||||
},
|
||||
errorText: {
|
||||
color: '#ef4444',
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
button: {
|
||||
height: 56,
|
||||
backgroundColor: '#3b82f6',
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#3b82f6',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 6,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
shadowOpacity: 0.1,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
});
|
||||
282
screens/auth/login/ui/LoginScreens.tsx
Normal file
282
screens/auth/login/ui/LoginScreens.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import AuthHeader from '@/components/ui/AuthHeader';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Phone, UserPlus } from 'lucide-react-native';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import LoginForm from './LoginForm';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={['#0f172a', '#1e293b', '#334155']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
<View style={styles.decorCircle1} />
|
||||
<View style={styles.decorCircle2} />
|
||||
<AuthHeader back={false} />
|
||||
<View style={styles.scrollContent}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.iconContainer}>
|
||||
<LinearGradient
|
||||
colors={['#3b82f6', '#2563eb']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.iconGradient}
|
||||
>
|
||||
<Phone size={32} color="#ffffff" strokeWidth={2} />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>{t('Kirish')}</Text>
|
||||
<Text style={styles.subtitle}>{t('Davom etish uchun tizimga kiring')}</Text>
|
||||
</View>
|
||||
|
||||
{/* Login Form */}
|
||||
<View style={styles.card}>
|
||||
<LoginForm />
|
||||
</View>
|
||||
|
||||
{/* Register bo'limi */}
|
||||
<View style={styles.registerSection}>
|
||||
<View style={styles.dividerContainer}>
|
||||
<View style={styles.divider} />
|
||||
<Text style={styles.dividerText}>{t('YOKI')}</Text>
|
||||
<View style={styles.divider} />
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.registerButton}
|
||||
onPress={() => router.push('/register')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['rgba(59, 130, 246, 0.1)', 'rgba(37, 99, 235, 0.15)']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.registerGradient}
|
||||
>
|
||||
<View style={styles.registerIconContainer}>
|
||||
<UserPlus size={20} color="#3b82f6" strokeWidth={2.5} />
|
||||
</View>
|
||||
<View style={styles.registerTextContainer}>
|
||||
<Text style={styles.registerTitle}>{t("Hisobingiz yo'qmi?")}</Text>
|
||||
<Text style={styles.registerSubtitle}>{t("Ro'yxatdan o'tish")}</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0f172a',
|
||||
},
|
||||
decorCircle1: {
|
||||
position: 'absolute',
|
||||
top: -150,
|
||||
right: -100,
|
||||
width: 400,
|
||||
height: 400,
|
||||
borderRadius: 200,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
decorCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: -100,
|
||||
left: -150,
|
||||
width: 350,
|
||||
height: 350,
|
||||
borderRadius: 175,
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.08)',
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 40,
|
||||
paddingTop: 10,
|
||||
},
|
||||
languageHeader: {
|
||||
alignItems: 'flex-end',
|
||||
marginBottom: 24,
|
||||
position: 'relative',
|
||||
zIndex: 1000,
|
||||
},
|
||||
languageButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
languageText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#94a3b8',
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginTop: 20,
|
||||
marginBottom: 40,
|
||||
},
|
||||
iconContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
iconGradient: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#3b82f6',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 16,
|
||||
elevation: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
color: '#ffffff',
|
||||
marginBottom: 12,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 15,
|
||||
color: '#94a3b8',
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 24,
|
||||
padding: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
},
|
||||
registerSection: {
|
||||
marginTop: 32,
|
||||
},
|
||||
dividerContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
divider: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(148, 163, 184, 0.3)',
|
||||
},
|
||||
dividerText: {
|
||||
marginHorizontal: 16,
|
||||
fontSize: 13,
|
||||
color: '#94a3b8',
|
||||
fontWeight: '600',
|
||||
},
|
||||
registerButton: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
registerGradient: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(59, 130, 246, 0.3)',
|
||||
borderRadius: 16,
|
||||
},
|
||||
registerIconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.15)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 14,
|
||||
},
|
||||
registerTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
registerTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: '#e2e8f0',
|
||||
marginBottom: 3,
|
||||
},
|
||||
registerSubtitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
dropdown: {
|
||||
position: 'absolute',
|
||||
top: 50,
|
||||
right: 0,
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 12,
|
||||
padding: 8,
|
||||
minWidth: 160,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
dropdownOption: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
dropdownOptionActive: {
|
||||
backgroundColor: '#e0e7ff',
|
||||
},
|
||||
dropdownOptionText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#475569',
|
||||
},
|
||||
dropdownOptionTextActive: {
|
||||
color: '#3b82f6',
|
||||
},
|
||||
checkmark: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#3b82f6',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
checkmarkText: {
|
||||
color: '#ffffff',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
34
screens/auth/login/ui/PhonePrefix.tsx
Normal file
34
screens/auth/login/ui/PhonePrefix.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
export default function PhonePrefix({ focused }: { focused: boolean }) {
|
||||
return (
|
||||
<View style={styles.prefixContainer}>
|
||||
<Text style={[styles.prefix, focused && styles.prefixFocused]}>+998</Text>
|
||||
<View style={styles.divider} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
prefixContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
prefix: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#475569',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
prefixFocused: {
|
||||
color: '#0f172a',
|
||||
},
|
||||
divider: {
|
||||
width: 1.5,
|
||||
height: 24,
|
||||
backgroundColor: '#e2e8f0',
|
||||
marginLeft: 12,
|
||||
},
|
||||
});
|
||||
72
screens/auth/login/ui/UseLoginForm.tsx
Normal file
72
screens/auth/login/ui/UseLoginForm.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { auth_api } from '../lib/api';
|
||||
|
||||
type Lang = 'uz' | 'ru' | 'en';
|
||||
|
||||
export function UseLoginForm() {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (body: { phone: string }) => auth_api.login(body),
|
||||
onError: (err: any) => {
|
||||
const errorMessage =
|
||||
err?.response?.data?.data?.detail || err?.response?.data?.data?.phone?.[0];
|
||||
|
||||
setError(errorMessage || t('auth.error_general'));
|
||||
},
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
if (phone.length !== 9) {
|
||||
setError(t('auth.error_incomplete'));
|
||||
return;
|
||||
}
|
||||
|
||||
mutate(
|
||||
{ phone: `998${phone}` },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setError('');
|
||||
router.push('/(auth)/confirm');
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// ✅ MUHIM: wrapper function
|
||||
const changeLanguage = async (lang: Lang) => {
|
||||
await i18n.changeLanguage(lang);
|
||||
};
|
||||
|
||||
const getLanguageName = () => {
|
||||
switch (i18n.language) {
|
||||
case 'uz':
|
||||
return 'O‘zbek';
|
||||
case 'ru':
|
||||
return 'Русский';
|
||||
case 'en':
|
||||
return 'English';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
phone,
|
||||
setPhone,
|
||||
submit,
|
||||
loading: isPending,
|
||||
error,
|
||||
t,
|
||||
language: i18n.language,
|
||||
changeLanguage, // ✅ endi undefined EMAS
|
||||
getLanguageName,
|
||||
};
|
||||
}
|
||||
239
screens/auth/register-confirm/ConfirmForm.tsx
Normal file
239
screens/auth/register-confirm/ConfirmForm.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Keyboard,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
interface OtpFormProps {
|
||||
phone?: string;
|
||||
initialCode?: string;
|
||||
onSubmit?: (otp: string) => void;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
onResendPress?: () => void;
|
||||
resendTimer: number;
|
||||
}
|
||||
|
||||
const RegisterConfirmForm = ({
|
||||
initialCode,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
error,
|
||||
onResendPress,
|
||||
resendTimer,
|
||||
}: OtpFormProps) => {
|
||||
const [otp, setOtp] = useState<string[]>(Array(4).fill(''));
|
||||
const [focusedIndex, setFocusedIndex] = useState<number>(0);
|
||||
const inputRefs = useRef<TextInput[]>([]);
|
||||
const shakeAnimation = useRef(new Animated.Value(0)).current;
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
Animated.sequence([
|
||||
Animated.timing(shakeAnimation, { toValue: 10, duration: 50, useNativeDriver: true }),
|
||||
Animated.timing(shakeAnimation, { toValue: -10, duration: 50, useNativeDriver: true }),
|
||||
Animated.timing(shakeAnimation, { toValue: 10, duration: 50, useNativeDriver: true }),
|
||||
Animated.timing(shakeAnimation, { toValue: 0, duration: 50, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCode && initialCode.length === 4 && /^\d{4}$/.test(initialCode)) {
|
||||
setOtp(initialCode.split(''));
|
||||
}
|
||||
}, [initialCode]);
|
||||
|
||||
// Raqam kiritilganda yoki o'chirilganda
|
||||
const handleChange = (value: string, index: number) => {
|
||||
const cleanValue = value.replace(/[^0-9]/g, '');
|
||||
const newOtp = [...otp];
|
||||
|
||||
// Agar qiymat bo'sh bo'lsa (o'chirilgan bo'lsa)
|
||||
if (value === '') {
|
||||
newOtp[index] = '';
|
||||
setOtp(newOtp);
|
||||
return;
|
||||
}
|
||||
|
||||
// Faqat oxirgi kiritilgan raqamni olish
|
||||
newOtp[index] = cleanValue.slice(-1);
|
||||
setOtp(newOtp);
|
||||
|
||||
// Keyingi katakka o'tish
|
||||
if (newOtp[index] !== '' && index < 3) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
|
||||
// Hamma raqam kiritilgan bo'lsa avtomat yuborish
|
||||
const fullCode = newOtp.join('');
|
||||
if (fullCode.length === 4) {
|
||||
Keyboard.dismiss();
|
||||
onSubmit?.(fullCode);
|
||||
}
|
||||
};
|
||||
|
||||
// Maxsus tugmalar (Backspace) uchun
|
||||
const handleKeyPress = ({ nativeEvent }: any, index: number) => {
|
||||
if (nativeEvent.key === 'Backspace') {
|
||||
// Agar katakda raqam bo'lsa, uni o'chiradi
|
||||
if (otp[index] !== '') {
|
||||
const newOtp = [...otp];
|
||||
newOtp[index] = '';
|
||||
setOtp(newOtp);
|
||||
}
|
||||
// Agar katak bo'sh bo'lsa, oldingisiga o'tib uni ham o'chiradi
|
||||
else if (index > 0) {
|
||||
const newOtp = [...otp];
|
||||
newOtp[index - 1] = '';
|
||||
setOtp(newOtp);
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Animated.View style={[styles.otpContainer, { transform: [{ translateX: shakeAnimation }] }]}>
|
||||
{otp.map((digit, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
activeOpacity={1}
|
||||
onPress={() => inputRefs.current[index]?.focus()}
|
||||
style={[
|
||||
styles.inputBox,
|
||||
focusedIndex === index && styles.inputBoxFocused,
|
||||
digit !== '' && styles.inputBoxFilled,
|
||||
error ? styles.inputBoxError : null,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.inputText, digit === '' && styles.placeholderText]}>
|
||||
{digit || '•'}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
ref={(ref) => {
|
||||
if (ref) inputRefs.current[index] = ref;
|
||||
}}
|
||||
value={digit}
|
||||
onChangeText={(v) => handleChange(v, index)}
|
||||
onKeyPress={(e) => handleKeyPress(e, index)}
|
||||
onFocus={() => setFocusedIndex(index)}
|
||||
onBlur={() => setFocusedIndex(-1)}
|
||||
keyboardType="number-pad"
|
||||
maxLength={1}
|
||||
style={styles.hiddenInput}
|
||||
caretHidden
|
||||
selectTextOnFocus // Bu 2 marta bosish muammosini oldini olishga yordam beradi
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</Animated.View>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => onSubmit?.(otp.join(''))}
|
||||
disabled={isLoading || otp.join('').length !== 4}
|
||||
activeOpacity={0.8}
|
||||
style={[
|
||||
styles.submitButton,
|
||||
(isLoading || otp.join('').length !== 4) && styles.submitButtonDisabled,
|
||||
]}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<Text style={styles.submitText}>{t('Kodni tasdiqlash')}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.resendContainer}>
|
||||
{resendTimer > 0 ? (
|
||||
<Text style={styles.timerText}>
|
||||
{t('Qayta yuborish vaqti')}:<Text style={styles.timerCount}>{resendTimer}s</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<TouchableOpacity onPress={onResendPress} style={styles.resendButton}>
|
||||
<Text style={styles.resendText}>{t('Kodni qayta yuborish')}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { width: '100%', paddingVertical: 10 },
|
||||
otpContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 20,
|
||||
paddingHorizontal: 5,
|
||||
},
|
||||
inputBox: {
|
||||
width: 60,
|
||||
height: 65,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#E2E8F0',
|
||||
backgroundColor: '#F8FAFC',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 1,
|
||||
},
|
||||
inputBoxFocused: {
|
||||
borderColor: '#3B82F6',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderWidth: 2,
|
||||
elevation: 4,
|
||||
},
|
||||
inputBoxFilled: {
|
||||
borderColor: '#3B82F6',
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
inputBoxError: {
|
||||
borderColor: '#EF4444',
|
||||
backgroundColor: '#FFF1F2',
|
||||
},
|
||||
inputText: { fontSize: 26, fontWeight: '700', color: '#1E293B' },
|
||||
placeholderText: { color: '#CBD5E1', fontSize: 18 },
|
||||
hiddenInput: { position: 'absolute', width: '100%', height: '100%', opacity: 0 },
|
||||
errorText: {
|
||||
color: '#EF4444',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
fontWeight: '500',
|
||||
},
|
||||
submitButton: {
|
||||
height: 56,
|
||||
backgroundColor: '#3B82F6',
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 3,
|
||||
},
|
||||
submitButtonDisabled: { backgroundColor: '#94A3B8', elevation: 0 },
|
||||
submitText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600' },
|
||||
resendContainer: { alignItems: 'center', marginTop: 25 },
|
||||
timerText: { color: '#64748B', fontSize: 14 },
|
||||
timerCount: { color: '#1E293B', fontWeight: '700' },
|
||||
resendButton: { paddingVertical: 5 },
|
||||
resendText: {
|
||||
color: '#3B82F6',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
});
|
||||
|
||||
export default RegisterConfirmForm;
|
||||
324
screens/auth/register-confirm/ConfirmScreen.tsx
Normal file
324
screens/auth/register-confirm/ConfirmScreen.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { useAuth } from '@/components/AuthProvider';
|
||||
import AuthHeader from '@/components/ui/AuthHeader';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Redirect, useRouter } from 'expo-router';
|
||||
import { ArrowLeft, MessageCircle, ShieldCheck } from 'lucide-react-native';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Linking,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
ToastAndroid,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { auth_api } from '../login/lib/api';
|
||||
import ConfirmForm from './ConfirmForm';
|
||||
|
||||
const RegisterConfirmScreen = () => {
|
||||
const router = useRouter();
|
||||
const [phoneOTP, setPhone] = useState<string | null>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
const { login } = useAuth();
|
||||
|
||||
const [resendTimer, setResendTimer] = useState<number>(60);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const loadPhone = async () => {
|
||||
try {
|
||||
const storedPhone = await AsyncStorage.getItem('phone');
|
||||
if (storedPhone) setPhone(storedPhone);
|
||||
else setPhone(null);
|
||||
} catch (error) {
|
||||
console.log('AsyncStorage error:', error);
|
||||
}
|
||||
};
|
||||
loadPhone();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (resendTimer === 0) return;
|
||||
const timer = setTimeout(() => {
|
||||
setResendTimer((prev) => prev - 1);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [resendTimer]);
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (body: { code: string; phone: string }) => auth_api.register_confirm(body),
|
||||
onSuccess: async (res) => {
|
||||
await AsyncStorage.removeItem('phone');
|
||||
await AsyncStorage.setItem('access_token', res.data.data.token.access);
|
||||
await AsyncStorage.setItem('refresh_token', res.data.data.token.refresh);
|
||||
await login(res.data.data.token.access);
|
||||
router.replace('/(dashboard)');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const errorMessage = err?.response?.data?.data?.detail || t("Kod noto'g'ri kiritildi");
|
||||
Alert.alert(t('Xatolik yuz berdi'), errorMessage);
|
||||
setError(errorMessage);
|
||||
},
|
||||
});
|
||||
|
||||
const resendMutation = useMutation({
|
||||
mutationFn: async (body: { phone: string }) => auth_api.register_resend(body),
|
||||
onSuccess: () => {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setResendTimer(60);
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show(t('Kod qayta yuborildi'), ToastAndroid.SHORT);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
Alert.alert(t('Xatolik yuz berdi'), t('Kodni qayta yuborishda xatolik yuz berdi'));
|
||||
},
|
||||
});
|
||||
|
||||
const openBotLink = () => {
|
||||
const linkApp = `tg://resolve?domain=infotargetbot&start=register_${phoneOTP}`;
|
||||
Linking.openURL(linkApp).catch(() => {
|
||||
const webLink = `https://t.me/infotargetbot?start=register_${phoneOTP}`;
|
||||
Linking.openURL(webLink);
|
||||
});
|
||||
};
|
||||
|
||||
if (phoneOTP === null) {
|
||||
return <Redirect href={'/'} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={['#0f172a', '#1e293b', '#334155']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
<View style={styles.decorCircle1} />
|
||||
<View style={styles.decorCircle2} />
|
||||
<AuthHeader />
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<KeyboardAwareScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.iconContainer}>
|
||||
<LinearGradient colors={['#3b82f6', '#2563eb']} style={styles.iconGradient}>
|
||||
<ShieldCheck size={32} color="#ffffff" strokeWidth={2.2} />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>{t('Kodni tasdiqlash')}</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{t("Tasdiqlash kodi sizning Telegram botingizga yuboriladi. Botni ko'rish")}
|
||||
</Text>
|
||||
|
||||
<View style={styles.phoneBadge}>
|
||||
<Text style={styles.phoneText}>+998 {phoneOTP}</Text>
|
||||
</View>
|
||||
|
||||
{/* Telegram Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.telegramBanner}
|
||||
onPress={openBotLink}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#0088cc', '#00a2ed']}
|
||||
style={styles.telegramGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
<View style={styles.botIconCircle}>
|
||||
<MessageCircle size={20} color="#0088cc" fill="#fff" />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.telegramTitle}>{t('Botni ochish')}</Text>
|
||||
<Text style={styles.telegramSub}>
|
||||
{t('Telegram botni ochish uchun tugmani bosing va kodni oling')}
|
||||
</Text>
|
||||
</View>
|
||||
<ArrowLeft size={20} color="#fff" style={{ transform: [{ rotate: '180deg' }] }} />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<ConfirmForm
|
||||
onSubmit={(otp) => mutate({ code: otp, phone: `998${phoneOTP}` || '' })}
|
||||
isLoading={isPending}
|
||||
error={error}
|
||||
onResendPress={() => resendMutation.mutate({ phone: `998${phoneOTP}` || '' })}
|
||||
resendTimer={resendTimer}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* <View style={styles.infoBox}>
|
||||
<Text style={styles.infoText}>
|
||||
<Text style={{ fontWeight: '700' }}>Eslatma:</Text> Kod SMS orqali kelmaydi. Agar
|
||||
botni ishga tushirmagan bo'lsangiz, yuqoridagi tugmani bosing.
|
||||
</Text>
|
||||
</View> */}
|
||||
</KeyboardAwareScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0f172a' },
|
||||
scrollContent: {
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 40,
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
languageHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
zIndex: 1000,
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
},
|
||||
languageButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
},
|
||||
languageText: { fontSize: 14, fontWeight: '600', color: '#94a3b8' },
|
||||
header: { alignItems: 'center', marginBottom: 24 },
|
||||
iconContainer: { marginBottom: 20 },
|
||||
iconGradient: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 8,
|
||||
shadowColor: '#3b82f6',
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 12,
|
||||
},
|
||||
title: { fontSize: 28, fontWeight: '800', color: '#ffffff', marginBottom: 8 },
|
||||
subtitle: {
|
||||
fontSize: 15,
|
||||
color: '#94a3b8',
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
highlightText: { color: '#38bdf8', fontWeight: '700' },
|
||||
phoneBadge: {
|
||||
marginTop: 16,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
phoneText: { color: '#60a5fa', fontWeight: '700', fontSize: 16 },
|
||||
telegramBanner: {
|
||||
marginTop: 24,
|
||||
width: '100%',
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
elevation: 6,
|
||||
shadowColor: '#0088cc',
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 10,
|
||||
},
|
||||
telegramGradient: { flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 },
|
||||
botIconCircle: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
telegramTitle: { color: '#ffffff', fontSize: 16, fontWeight: '700' },
|
||||
telegramSub: { color: 'rgba(255, 255, 255, 0.8)', fontSize: 12 },
|
||||
card: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 28,
|
||||
padding: 24,
|
||||
elevation: 10,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 15,
|
||||
},
|
||||
infoBox: {
|
||||
marginTop: 20,
|
||||
padding: 16,
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.08)',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(245, 158, 11, 0.2)',
|
||||
},
|
||||
infoText: { fontSize: 13, color: '#fbbf24', textAlign: 'center', lineHeight: 20 },
|
||||
dropdown: {
|
||||
position: 'absolute',
|
||||
top: 55,
|
||||
right: 0,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
padding: 8,
|
||||
minWidth: 170,
|
||||
elevation: 10,
|
||||
zIndex: 2000,
|
||||
},
|
||||
dropdownOption: { padding: 12, borderRadius: 10 },
|
||||
dropdownOptionActive: { backgroundColor: '#eff6ff' },
|
||||
dropdownOptionText: { fontSize: 14, color: '#475569', fontWeight: '600' },
|
||||
dropdownOptionTextActive: { color: '#3b82f6' },
|
||||
decorCircle1: {
|
||||
position: 'absolute',
|
||||
top: -100,
|
||||
right: -80,
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: 150,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
decorCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: -50,
|
||||
left: -100,
|
||||
width: 250,
|
||||
height: 250,
|
||||
borderRadius: 125,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.05)',
|
||||
},
|
||||
});
|
||||
|
||||
export default RegisterConfirmScreen;
|
||||
103
screens/auth/register/RegisterCategorySelection.tsx
Normal file
103
screens/auth/register/RegisterCategorySelection.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
// app/auth/register/category.tsx
|
||||
import { auth_api } from '@/screens/auth/login/lib/api';
|
||||
import { products_api } from '@/screens/home/lib/api';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Check } from 'lucide-react-native';
|
||||
import React from 'react';
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity } from 'react-native';
|
||||
|
||||
export default function CategorySelectScreen() {
|
||||
const router = useRouter();
|
||||
const { phone, stir, person_type } = useLocalSearchParams<{
|
||||
phone: string;
|
||||
stir: string;
|
||||
person_type: 'band' | 'ytt';
|
||||
}>();
|
||||
|
||||
const [selected, setSelected] = React.useState<number | null>(null);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => products_api.getCategorys(),
|
||||
});
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (body: {
|
||||
phone: string;
|
||||
stir: string;
|
||||
person_type: string;
|
||||
activate_types: number[];
|
||||
}) => auth_api.register(body),
|
||||
onSuccess: () => router.replace('/'),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Yo‘nalishni tanlang' }} />
|
||||
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
{data?.data?.data.map((c: any) => {
|
||||
const active = selected === c.id;
|
||||
return (
|
||||
<TouchableOpacity key={c.id} style={styles.item} onPress={() => setSelected(c.id)}>
|
||||
<Text style={styles.text}>{c.name}</Text>
|
||||
{active && <Check size={20} color="#2563eb" />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
<TouchableOpacity
|
||||
disabled={!selected || isPending}
|
||||
style={[styles.bottom, (!selected || isPending) && { opacity: 0.5 }]}
|
||||
onPress={() => {
|
||||
if (phone && stir && person_type && selected) {
|
||||
mutate({
|
||||
activate_types: [selected],
|
||||
person_type: person_type,
|
||||
phone: `998${phone}`,
|
||||
stir: stir,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.bottomText}>{isPending ? 'Yuborilmoqda...' : 'Tasdiqlash'}</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
paddingBottom: 120,
|
||||
},
|
||||
item: {
|
||||
paddingVertical: 18,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: '#e2e8f0',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
text: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
bottom: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: 16,
|
||||
right: 16,
|
||||
height: 54,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#2563eb',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
bottomText: {
|
||||
color: '#fff',
|
||||
fontWeight: '800',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
206
screens/auth/register/RegisterForm.tsx
Normal file
206
screens/auth/register/RegisterForm.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { formatPhone, normalizeDigits } from '@/constants/formatPhone';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Hash } from 'lucide-react-native';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { auth_api } from '../login/lib/api';
|
||||
import PhonePrefix from '../login/ui/PhonePrefix';
|
||||
import { UseLoginForm } from '../login/ui/UseLoginForm';
|
||||
|
||||
export default function RegisterForm() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { phone, setPhone } = UseLoginForm();
|
||||
|
||||
const [stir, setStir] = useState('');
|
||||
const [info, setInfo] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [directorTinInput, setDirectorTinInput] = useState('');
|
||||
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: (stir: string) => auth_api.get_info(stir),
|
||||
onSuccess: (res) => {
|
||||
setInfo(res.data);
|
||||
setLoading(false);
|
||||
},
|
||||
onError: () => {
|
||||
setInfo(null);
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
const hasDirectorTin = info?.directorTin && String(info.directorTin).length > 0;
|
||||
|
||||
const isDirectorTinValid = !hasDirectorTin || directorTinInput === String(info.directorTin);
|
||||
const hasValidName = Boolean(info?.name || info?.fullName);
|
||||
|
||||
const valid =
|
||||
phone.length === 9 &&
|
||||
(stir.length === 9 || stir.length === 14) &&
|
||||
info &&
|
||||
hasValidName &&
|
||||
isDirectorTinValid;
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior="position">
|
||||
<View style={{ gap: 16 }}>
|
||||
{/* STIR */}
|
||||
<View>
|
||||
<Text style={styles.label}>{t('STIR')}</Text>
|
||||
<View style={styles.input}>
|
||||
<Hash size={18} color="#94a3b8" />
|
||||
<TextInput
|
||||
value={stir}
|
||||
keyboardType="numeric"
|
||||
placeholder={t('STIR')}
|
||||
placeholderTextColor="#94a3b8"
|
||||
style={{ flex: 1 }}
|
||||
onChangeText={(text) => {
|
||||
const v = normalizeDigits(text).slice(0, 14);
|
||||
setStir(v);
|
||||
|
||||
if (v.length === 9 || v.length === 14) {
|
||||
setLoading(true);
|
||||
mutate(v);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{loading && <ActivityIndicator size="small" />}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* PHONE */}
|
||||
<View>
|
||||
<Text style={styles.label}>{t('Telefon raqami')}</Text>
|
||||
<View style={styles.input}>
|
||||
<PhonePrefix focused={false} />
|
||||
<TextInput
|
||||
value={formatPhone(phone)}
|
||||
placeholder="90 123 45 67"
|
||||
placeholderTextColor="#94a3b8"
|
||||
keyboardType="phone-pad"
|
||||
style={{ flex: 1 }}
|
||||
onChangeText={(t) => setPhone(normalizeDigits(t))}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* DIRECTOR TIN */}
|
||||
{hasDirectorTin && (
|
||||
<View>
|
||||
<Text style={styles.label}>{t('Direktor STIR')}</Text>
|
||||
<View style={styles.input}>
|
||||
<Hash size={18} color="#94a3b8" />
|
||||
<TextInput
|
||||
value={directorTinInput}
|
||||
keyboardType="numeric"
|
||||
placeholder={t('Direktor STIR')}
|
||||
placeholderTextColor="#94a3b8"
|
||||
style={{ flex: 1 }}
|
||||
onChangeText={(t) => setDirectorTinInput(normalizeDigits(t))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{directorTinInput.length > 0 && !isDirectorTinValid && (
|
||||
<Text style={styles.error}>{t('Direktor STIR noto‘g‘ri')}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* INFO */}
|
||||
{info &&
|
||||
(hasValidName ? (
|
||||
<Text style={styles.info}>{info.fullName || info.name}</Text>
|
||||
) : (
|
||||
<Text style={styles.notFound}>{t('Foydalanuvchi topilmadi')}</Text>
|
||||
))}
|
||||
|
||||
{/* BUTTON */}
|
||||
<TouchableOpacity
|
||||
disabled={!valid}
|
||||
style={[styles.btn, !valid && styles.disabled]}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/(auth)/select-category',
|
||||
params: {
|
||||
phone,
|
||||
company_name: info?.fullname,
|
||||
address: info?.fullAddress,
|
||||
director_full_name: info?.director,
|
||||
stir,
|
||||
person_type: stir.length === 9 ? 'legal_entity' : info?.company ? 'ytt' : 'band',
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text style={styles.btnText}>{t('Davom etish')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
label: {
|
||||
fontWeight: '700',
|
||||
color: '#475569',
|
||||
},
|
||||
notFound: {
|
||||
backgroundColor: '#fef2f2',
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
fontWeight: '700',
|
||||
color: '#dc2626',
|
||||
borderWidth: 1,
|
||||
borderColor: '#fecaca',
|
||||
},
|
||||
input: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
height: 50,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e2e8f0',
|
||||
gap: 8,
|
||||
},
|
||||
info: {
|
||||
backgroundColor: '#f1f5f9',
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
btn: {
|
||||
height: 52,
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
btnText: {
|
||||
color: '#fff',
|
||||
fontWeight: '800',
|
||||
fontSize: 16,
|
||||
},
|
||||
error: {
|
||||
color: '#dc2626',
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
222
screens/auth/register/RegisterScreen.tsx
Normal file
222
screens/auth/register/RegisterScreen.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import AuthHeader from '@/components/ui/AuthHeader';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { UserPlus } from 'lucide-react-native';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import RegisterForm from './RegisterForm';
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Background Decorations */}
|
||||
<LinearGradient
|
||||
colors={['#0f172a', '#1e293b', '#334155']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={styles.decorCircle1} />
|
||||
<View style={styles.decorCircle2} />
|
||||
|
||||
<AuthHeader />
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header Section */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.iconContainer}>
|
||||
<LinearGradient
|
||||
colors={['#10b981', '#059669']}
|
||||
style={styles.iconGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<UserPlus size={32} color="#ffffff" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<Text style={styles.title}>{t("Ro'yxatdan o'tish")}</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{t('Tizimdan foydalanish uchun STIR raqami yoki JSHSHR kiritishingiz kerak.')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Form Card */}
|
||||
<View style={styles.card}>
|
||||
<RegisterForm />
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity onPress={() => router.push('/')}>
|
||||
<Text style={styles.footerText}>
|
||||
{t('Hisobingiz bormi?')} <Text style={styles.footerLink}>{t('Kirish')}</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0f172a' },
|
||||
|
||||
// Header Navigatsiya qismi (LoginScreen kabi)
|
||||
languageHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
zIndex: 1000,
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
},
|
||||
languageButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
},
|
||||
languageText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#94a3b8',
|
||||
},
|
||||
|
||||
// Scroll va Forma joylashuvi
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 40,
|
||||
paddingTop: 10,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
},
|
||||
iconContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
iconGradient: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#10b981',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#ffffff',
|
||||
marginBottom: 10,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 15,
|
||||
color: '#94a3b8',
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 28,
|
||||
padding: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
},
|
||||
|
||||
// Dropdown (LoginScreen bilan bir xil)
|
||||
dropdown: {
|
||||
position: 'absolute',
|
||||
top: 55,
|
||||
right: 0,
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 16,
|
||||
padding: 8,
|
||||
minWidth: 180,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 20,
|
||||
elevation: 15,
|
||||
borderWidth: 1,
|
||||
borderColor: '#f1f5f9',
|
||||
},
|
||||
dropdownOption: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
dropdownOptionActive: { backgroundColor: '#eff6ff' },
|
||||
dropdownOptionText: { fontSize: 14, fontWeight: '600', color: '#475569' },
|
||||
dropdownOptionTextActive: { color: '#3b82f6' },
|
||||
checkmark: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
backgroundColor: '#3b82f6',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
checkmarkText: { color: '#ffffff', fontSize: 12, fontWeight: 'bold' },
|
||||
|
||||
footer: { marginTop: 24, alignItems: 'center' },
|
||||
footerText: { color: '#94a3b8', fontSize: 14 },
|
||||
footerLink: { color: '#3b82f6', fontWeight: '700' },
|
||||
decorCircle1: {
|
||||
position: 'absolute',
|
||||
top: -150,
|
||||
right: -100,
|
||||
width: 400,
|
||||
height: 400,
|
||||
borderRadius: 200,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
decorCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: -100,
|
||||
left: -150,
|
||||
width: 350,
|
||||
height: 350,
|
||||
borderRadius: 175,
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.08)',
|
||||
},
|
||||
});
|
||||
32
screens/create-ads/lib/api.ts
Normal file
32
screens/create-ads/lib/api.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import httpClient from '@/api/httpClient';
|
||||
import { API_URLS } from '@/api/URLs';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { CreateAdsResponse, PriceCalculationRes } from './types';
|
||||
|
||||
export const price_calculation = {
|
||||
async calculation(params: {
|
||||
country: string;
|
||||
region: string;
|
||||
district: string | 'all';
|
||||
letters: string | any;
|
||||
types: string;
|
||||
}): Promise<AxiosResponse<PriceCalculationRes>> {
|
||||
const res = await httpClient.get(API_URLS.Price_Calculation, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async ad(body: FormData): Promise<AxiosResponse<CreateAdsResponse>> {
|
||||
const res = await httpClient.post(API_URLS.Add_Ads, body, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return res;
|
||||
},
|
||||
async payment(body: { return_url: string; adId: number; paymentType: 'payme' | 'referral' }) {
|
||||
const res = await httpClient.post(API_URLS.Payment_Ads(body.paymentType, body.adId), {
|
||||
return_url: body.return_url,
|
||||
});
|
||||
return res;
|
||||
},
|
||||
};
|
||||
21
screens/create-ads/lib/types.ts
Normal file
21
screens/create-ads/lib/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface PriceCalculationRes {
|
||||
country: string;
|
||||
region: string;
|
||||
district: string;
|
||||
letters: string[];
|
||||
one_person_price: number;
|
||||
user_count: number;
|
||||
total_price: number;
|
||||
user_ids: number[];
|
||||
}
|
||||
|
||||
export interface CreateAdsResponse {
|
||||
status: boolean;
|
||||
data: {
|
||||
description: string;
|
||||
id: number;
|
||||
phone_number: string;
|
||||
title: string;
|
||||
total_price: number;
|
||||
};
|
||||
}
|
||||
139
screens/create-ads/ui/CategorySelectorBottomSheet.tsx
Normal file
139
screens/create-ads/ui/CategorySelectorBottomSheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
} from '@gorhom/bottom-sheet';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
export type Option = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type CategorySelectorProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedValue: string;
|
||||
onSelect: (value: string) => void;
|
||||
data: Option[];
|
||||
};
|
||||
|
||||
export default function CategorySelectorBottomSheet({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedValue,
|
||||
onSelect,
|
||||
data = [],
|
||||
}: CategorySelectorProps) {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const theme = {
|
||||
background: isDark ? '#1e293b' : '#ffffff',
|
||||
text: isDark ? '#f8fafc' : '#0f172a',
|
||||
border: isDark ? '#334155' : '#e2e8f0',
|
||||
selectedBg: '#2563eb',
|
||||
selectedText: '#ffffff',
|
||||
indicator: isDark ? '#cbd5e1' : '#94a3b8',
|
||||
};
|
||||
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ['60%', '85%'], []);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
bottomSheetRef.current?.present();
|
||||
} else {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
index={0}
|
||||
snapPoints={snapPoints}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
onDismiss={onClose}
|
||||
handleIndicatorStyle={{ backgroundColor: theme.indicator, width: 50 }}
|
||||
backgroundStyle={{
|
||||
backgroundColor: theme.background,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
}}
|
||||
>
|
||||
<View style={[styles.header, { borderBottomColor: theme.border }]}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>{t('Tanlang')}</Text>
|
||||
</View>
|
||||
|
||||
<BottomSheetScrollView style={styles.content} contentContainerStyle={styles.contentContainer}>
|
||||
{data.map((item) => (
|
||||
<TouchableOpacity
|
||||
key={item.value}
|
||||
style={[
|
||||
styles.optionRow,
|
||||
{ borderBottomColor: theme.border },
|
||||
selectedValue === item.value && { backgroundColor: theme.selectedBg },
|
||||
]}
|
||||
onPress={() => {
|
||||
onSelect(item.value);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.optionText,
|
||||
{ color: selectedValue === item.value ? theme.selectedText : theme.text },
|
||||
selectedValue === item.value && styles.optionTextSelected,
|
||||
]}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
borderBottomWidth: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingHorizontal: 8,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
optionRow: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
optionTextSelected: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
404
screens/create-ads/ui/CreateAdsScreens.tsx
Normal file
404
screens/create-ads/ui/CreateAdsScreens.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { AxiosError } from 'axios';
|
||||
import { router, useFocusEffect } from 'expo-router';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
Linking,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import PAYME from '@/assets/images/Payme_NEW.png';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { price_calculation } from '../lib/api';
|
||||
import { CreateAdsResponse } from '../lib/types';
|
||||
import StepFour from './StepFour';
|
||||
import StepOne from './StepOne';
|
||||
import StepThree from './StepThree';
|
||||
import StepTwo from './StepTwo';
|
||||
|
||||
type MediaFile = {
|
||||
uri: string;
|
||||
type: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
interface formDataType {
|
||||
title: string;
|
||||
description: string;
|
||||
phone: string;
|
||||
media: MediaFile[];
|
||||
category: any[];
|
||||
country: string;
|
||||
region: string;
|
||||
district: string;
|
||||
company: any[];
|
||||
}
|
||||
|
||||
const getMimeType = (uri: string) => {
|
||||
const ext = uri.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
case 'heic':
|
||||
return 'image/heic';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
};
|
||||
|
||||
export default function CreateAdsScreens() {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [paymentType, setPaymentType] = useState<'PAYME' | 'REFFERAL'>('PAYME');
|
||||
const [ads, setAds] = useState<CreateAdsResponse | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
const stepOneRef = useRef<{ validate: () => boolean } | null>(null);
|
||||
const stepTwoRef = useRef<{ validate: () => boolean } | null>(null);
|
||||
const stepThreeRef = useRef<{ validate: () => boolean } | null>(null);
|
||||
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const renderBackdrop = useCallback(
|
||||
(props: any) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const [formData, setFormData] = useState<formDataType>({
|
||||
title: '',
|
||||
description: '',
|
||||
phone: '',
|
||||
media: [],
|
||||
category: [],
|
||||
country: '',
|
||||
region: '',
|
||||
district: 'all',
|
||||
company: [],
|
||||
});
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
return () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
phone: '',
|
||||
media: [],
|
||||
category: [],
|
||||
country: '',
|
||||
region: '',
|
||||
district: 'all',
|
||||
company: [],
|
||||
});
|
||||
setCurrentStep(1);
|
||||
};
|
||||
}, [])
|
||||
);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['price-calculation', formData],
|
||||
queryFn: () =>
|
||||
price_calculation.calculation({
|
||||
country: formData.country,
|
||||
district: formData.district,
|
||||
region: formData.region,
|
||||
types: formData.category.map((c: any) => c.id).join(','),
|
||||
letters: formData.company.map((c: any) => c.latter).join(','),
|
||||
}),
|
||||
enabled: formData.company.length > 0,
|
||||
});
|
||||
|
||||
const updateForm = (key: string, value: any) => setFormData((p) => ({ ...p, [key]: value }));
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (body: FormData) => price_calculation.ad(body),
|
||||
onSuccess: (res) => {
|
||||
setAds(res.data);
|
||||
setCurrentStep(4);
|
||||
},
|
||||
onError: (err: AxiosError) => {
|
||||
Alert.alert('Xatolik', err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
const form = new FormData();
|
||||
|
||||
form.append('title', formData.title);
|
||||
form.append('description', formData.description);
|
||||
|
||||
formData.media.forEach((file, index) => {
|
||||
form.append(`files[${index}]`, {
|
||||
uri: file.uri,
|
||||
type: getMimeType(file.uri),
|
||||
name: file.uri.split('/').pop(),
|
||||
} as any);
|
||||
});
|
||||
|
||||
formData.category.forEach((e, index) => {
|
||||
form.append(`types[${index}]`, e.id);
|
||||
});
|
||||
|
||||
if (data) {
|
||||
form.append('total_price', data.data.total_price.toString());
|
||||
}
|
||||
|
||||
form.append('phone_number', `998${formData.phone}`);
|
||||
|
||||
const letters = formData.company.map((c: any) => c.latter).join(',');
|
||||
|
||||
form.append('company_selections[0]continent', 'Asia');
|
||||
form.append('company_selections[0]country', formData.country);
|
||||
form.append('company_selections[0]region', formData.region);
|
||||
form.append('company_selections[0]district', formData.district);
|
||||
form.append('company_selections[0]letters', letters);
|
||||
form.append('company_selections[0]total_companies', data?.data.user_count.toString() || '0');
|
||||
form.append('company_selections[0]ad_price', data?.data.one_person_price.toString() || '0');
|
||||
form.append('company_selections[0]total_price', data?.data.total_price.toString() || '0');
|
||||
|
||||
mutate(form);
|
||||
};
|
||||
|
||||
const handlePresentModalPress = useCallback(() => {
|
||||
bottomSheetModalRef.current?.present();
|
||||
}, []);
|
||||
|
||||
const { mutate: payment } = useMutation({
|
||||
mutationFn: (body: { return_url: string; adId: number; paymentType: 'payme' | 'referral' }) =>
|
||||
price_calculation.payment(body),
|
||||
onSuccess: async (res, variables) => {
|
||||
if (variables.paymentType === 'payme') {
|
||||
await Linking.openURL(res.data.url);
|
||||
router.push('/(dashboard)/announcements');
|
||||
} else {
|
||||
router.push('/(dashboard)/announcements');
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
Alert.alert('Xatolik yuz berdi', err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const sendPayment = (type: 'payme' | 'referral') => {
|
||||
if (ads) {
|
||||
payment({
|
||||
adId: ads.data.id,
|
||||
paymentType: type,
|
||||
return_url: 'https://infotarget.uz/en/main/dashboard',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
style={[styles.safeArea, isDark ? styles.darkBg : styles.lightBg]}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<Text style={[styles.title, isDark ? styles.darkText : styles.lightText]}>
|
||||
{currentStep === 1
|
||||
? t("E'lon ma'lumotlari")
|
||||
: currentStep === 2
|
||||
? t('Sohalar')
|
||||
: currentStep === 3
|
||||
? t('Manzil')
|
||||
: t("To'lov")}
|
||||
</Text>
|
||||
|
||||
{currentStep === 1 && (
|
||||
<StepOne ref={stepOneRef} formData={formData} updateForm={updateForm} />
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<StepTwo ref={stepTwoRef} formData={formData} updateForm={updateForm} />
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<StepThree
|
||||
ref={stepThreeRef}
|
||||
formData={formData}
|
||||
updateForm={updateForm}
|
||||
data={data?.data}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 4 && <StepFour data={ads} setPayment={setPaymentType} />}
|
||||
</ScrollView>
|
||||
|
||||
{/* FOOTER */}
|
||||
<View style={styles.footer}>
|
||||
{currentStep > 1 && currentStep !== 4 && (
|
||||
<TouchableOpacity
|
||||
style={[styles.back, isDark ? styles.darkBack : styles.lightBack]}
|
||||
onPress={() => setCurrentStep((s) => s - 1)}
|
||||
>
|
||||
<Text style={[styles.btnText, isDark ? styles.darkBtnText : styles.lightBtnText]}>
|
||||
{t('Orqaga')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.next}
|
||||
disabled={isPending}
|
||||
onPress={() => {
|
||||
let isValid = true;
|
||||
|
||||
if (currentStep === 1) isValid = stepOneRef.current?.validate() ?? false;
|
||||
if (currentStep === 2) isValid = stepTwoRef.current?.validate() ?? false;
|
||||
if (currentStep === 3) isValid = stepThreeRef.current?.validate() ?? false;
|
||||
|
||||
if (!isValid) return;
|
||||
|
||||
if (currentStep < 3) setCurrentStep((s) => s + 1);
|
||||
if (currentStep === 3) handleSubmit();
|
||||
if (currentStep === 4) handlePresentModalPress();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.btnText}>
|
||||
{currentStep === 3 ? t('Yaratish') : currentStep === 4 ? t("To'lash") : t('Keyingisi')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* PAYMENT BOTTOM SHEET */}
|
||||
<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}
|
||||
>
|
||||
<View style={{ padding: 20 }}>
|
||||
<Text style={[styles.sheetTitle, isDark ? styles.darkText : styles.lightText]}>
|
||||
{t("To'lov turini tanlang")}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.paymentItem,
|
||||
isDark ? styles.darkPaymentItem : styles.lightPaymentItem,
|
||||
{ flexDirection: 'row', justifyContent: 'flex-start', alignItems: 'center' },
|
||||
]}
|
||||
onPress={() => sendPayment('payme')}
|
||||
>
|
||||
<Image source={PAYME} style={{ width: 80, height: 80 }} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.paymentItem,
|
||||
isDark ? styles.darkPaymentItem : styles.lightPaymentItem,
|
||||
]}
|
||||
onPress={() => sendPayment('referral')}
|
||||
>
|
||||
<Text style={[styles.paymentText, isDark ? styles.darkText : styles.lightText]}>
|
||||
{t('Referal orqali')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: { flex: 1 },
|
||||
darkBg: {
|
||||
backgroundColor: '#0f172a',
|
||||
},
|
||||
lightBg: {
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
container: { padding: 20, paddingBottom: 140 },
|
||||
title: { fontSize: 22, fontWeight: '800', marginBottom: 20 },
|
||||
darkText: {
|
||||
color: '#f1f5f9',
|
||||
},
|
||||
lightText: {
|
||||
color: '#0f172a',
|
||||
},
|
||||
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 80,
|
||||
left: 20,
|
||||
right: 20,
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
back: {
|
||||
flex: 1,
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
darkBack: {
|
||||
backgroundColor: '#1e293b',
|
||||
},
|
||||
lightBack: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
sheetContent: { flex: 1 },
|
||||
sheetContentContainer: { paddingBottom: 40 },
|
||||
next: {
|
||||
flex: 2,
|
||||
height: 56,
|
||||
backgroundColor: '#3b82f6',
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnText: { color: '#ffffff', fontWeight: '700', fontSize: 16 },
|
||||
darkBtnText: {
|
||||
color: '#f1f5f9',
|
||||
},
|
||||
lightBtnText: {
|
||||
color: '#0f172a',
|
||||
},
|
||||
|
||||
sheetTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 16,
|
||||
},
|
||||
paymentItem: {
|
||||
height: 56,
|
||||
borderRadius: 14,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
darkPaymentItem: {
|
||||
backgroundColor: '#1e293b',
|
||||
},
|
||||
lightPaymentItem: {
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
paymentText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
73
screens/create-ads/ui/StepFour.tsx
Normal file
73
screens/create-ads/ui/StepFour.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import React, { Dispatch, forwardRef, SetStateAction } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { CreateAdsResponse } from '../lib/types';
|
||||
|
||||
type StepFourProps = {
|
||||
setPayment: Dispatch<SetStateAction<'PAYME' | 'REFFERAL'>>;
|
||||
data: CreateAdsResponse | null;
|
||||
};
|
||||
|
||||
const StepFour = forwardRef(({ data }: StepFourProps) => {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const theme = {
|
||||
background: isDark ? '#0f172a' : '#ffffff',
|
||||
cardBg: isDark ? '#1e293b' : '#f8fafc',
|
||||
cardBorder: isDark ? '#334155' : '#e2e8f0',
|
||||
text: isDark ? '#f8fafc' : '#0f172a',
|
||||
textSecondary: isDark ? '#cbd5e1' : '#64748b',
|
||||
totalPrice: isDark ? '#f87171' : '#ef4444',
|
||||
};
|
||||
|
||||
const totalPrice = data?.data.total_price || 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { backgroundColor: theme.background }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Text style={[styles.sectionTitle, { color: theme.text }]}>
|
||||
{t("To'lov uchun ma'lumotlar")}
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={[styles.card, { backgroundColor: theme.cardBg, borderColor: theme.cardBorder }]}
|
||||
>
|
||||
<Text style={[styles.label, { color: theme.text }]}>
|
||||
{t("E'lon nomi")}: <Text style={styles.value}>{data?.data.title}</Text>
|
||||
</Text>
|
||||
<Text style={[styles.label, { color: theme.text }]}>
|
||||
{t("E'lon tavsifi")}: <Text style={styles.value}>{data?.data.description}</Text>
|
||||
</Text>
|
||||
<Text style={[styles.label, styles.total, { color: theme.totalPrice }]}>
|
||||
{t('Umumiy narx')}:{' '}
|
||||
<Text style={styles.value}>
|
||||
{totalPrice} {t("so'm")}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default StepFour;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flexGrow: 1 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '700', marginVertical: 12 },
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
borderWidth: 1,
|
||||
gap: 8,
|
||||
},
|
||||
label: { fontSize: 15, fontWeight: '600' },
|
||||
value: { fontWeight: '800' },
|
||||
total: { marginTop: 8, fontSize: 16 },
|
||||
});
|
||||
265
screens/create-ads/ui/StepOne.tsx
Normal file
265
screens/create-ads/ui/StepOne.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import { formatPhone, normalizeDigits } from '@/constants/formatPhone';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { Camera, Play, X } from 'lucide-react-native';
|
||||
import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Image, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
type MediaType = { uri: string; type: 'image' | 'video' };
|
||||
type StepProps = { formData: any; updateForm: (key: string, value: any) => void };
|
||||
|
||||
type Errors = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
phone?: string;
|
||||
media?: string;
|
||||
};
|
||||
|
||||
const MAX_MEDIA = 10;
|
||||
|
||||
const StepOne = forwardRef(({ formData, updateForm }: StepProps, ref) => {
|
||||
const [phone, setPhone] = useState(formData.phone || '');
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const validate = () => {
|
||||
const e: Errors = {};
|
||||
|
||||
if (!formData.title || formData.title.trim().length < 5)
|
||||
e.title = "Sarlavha kamida 5 ta belgidan iborat bo'lishi kerak";
|
||||
|
||||
if (!formData.description || formData.description.trim().length < 10)
|
||||
e.description = "Tavsif kamida 10 ta belgidan iborat bo'lishi kerak";
|
||||
|
||||
if (!formData.phone || formData.phone.length !== 9)
|
||||
e.phone = "Telefon raqam to'liq kiritilmadi";
|
||||
|
||||
if (!formData.media || formData.media.length === 0)
|
||||
e.media = 'Kamida bitta rasm yoki video yuklang';
|
||||
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({ validate }));
|
||||
|
||||
const pickMedia = async () => {
|
||||
if (formData.media.length >= MAX_MEDIA) return;
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||
allowsMultipleSelection: true,
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
const assets = result.assets
|
||||
.slice(0, MAX_MEDIA - formData.media.length)
|
||||
.map((a) => ({ uri: a.uri, type: a.type as 'image' | 'video' }));
|
||||
|
||||
updateForm('media', [...formData.media, ...assets]);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhone = useCallback(
|
||||
(text: string) => {
|
||||
const n = normalizeDigits(text);
|
||||
setPhone(n);
|
||||
updateForm('phone', n);
|
||||
},
|
||||
[updateForm]
|
||||
);
|
||||
|
||||
const removeMedia = (i: number) =>
|
||||
updateForm(
|
||||
'media',
|
||||
formData.media.filter((_: any, idx: number) => idx !== i)
|
||||
);
|
||||
|
||||
const theme = {
|
||||
background: isDark ? '#0f172a' : '#ffffff',
|
||||
inputBg: isDark ? '#1e293b' : '#f1f5f9',
|
||||
inputBorder: isDark ? '#334155' : '#e2e8f0',
|
||||
text: isDark ? '#f8fafc' : '#0f172a',
|
||||
textSecondary: isDark ? '#cbd5e1' : '#475569',
|
||||
placeholder: isDark ? '#94a3b8' : '#94a3b8',
|
||||
error: '#ef4444',
|
||||
primary: '#2563eb',
|
||||
divider: isDark ? '#475569' : '#cbd5e1',
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.stepContainer}>
|
||||
{/* Sarlavha */}
|
||||
<Text style={[styles.label, { color: theme.text }]}>{t('Sarlavha')}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.inputBox,
|
||||
{ backgroundColor: theme.inputBg, borderColor: theme.inputBorder },
|
||||
]}
|
||||
>
|
||||
<TextInput
|
||||
style={[styles.input, { color: theme.text }]}
|
||||
placeholder={t("E'lon sarlavhasi")}
|
||||
placeholderTextColor={theme.placeholder}
|
||||
value={formData.title}
|
||||
onChangeText={(t) => updateForm('title', t)}
|
||||
/>
|
||||
</View>
|
||||
{errors.title && (
|
||||
<Text style={[styles.error, { color: theme.error }]}>{t(errors.title)}</Text>
|
||||
)}
|
||||
|
||||
{/* Tavsif */}
|
||||
<Text style={[styles.label, { color: theme.text }]}>{t('Tavsif')}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.inputBox,
|
||||
styles.textArea,
|
||||
{ backgroundColor: theme.inputBg, borderColor: theme.inputBorder },
|
||||
]}
|
||||
>
|
||||
<TextInput
|
||||
style={[styles.input, { color: theme.text }]}
|
||||
placeholder={t('Batafsil yozing...')}
|
||||
placeholderTextColor={theme.placeholder}
|
||||
multiline
|
||||
value={formData.description}
|
||||
onChangeText={(t) => updateForm('description', t)}
|
||||
/>
|
||||
</View>
|
||||
{errors.description && (
|
||||
<Text style={[styles.error, { color: theme.error }]}>{t(errors.description)}</Text>
|
||||
)}
|
||||
|
||||
{/* Telefon */}
|
||||
<Text style={[styles.label, { color: theme.text }]}>{t('Telefon raqami')}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.inputBox,
|
||||
{ backgroundColor: theme.inputBg, borderColor: theme.inputBorder },
|
||||
]}
|
||||
>
|
||||
<View style={styles.prefixContainer}>
|
||||
<Text style={[styles.prefix, { color: theme.text }, focused && styles.prefixFocused]}>
|
||||
+998
|
||||
</Text>
|
||||
<View style={[styles.divider, { backgroundColor: theme.divider }]} />
|
||||
</View>
|
||||
<TextInput
|
||||
style={[styles.input, { color: theme.text }]}
|
||||
value={formatPhone(phone)}
|
||||
onChangeText={handlePhone}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
keyboardType="phone-pad"
|
||||
placeholder="90 123 45 67"
|
||||
maxLength={12}
|
||||
placeholderTextColor={theme.placeholder}
|
||||
/>
|
||||
</View>
|
||||
{errors.phone && (
|
||||
<Text style={[styles.error, { color: theme.error }]}>{t(errors.phone)}</Text>
|
||||
)}
|
||||
|
||||
{/* Media */}
|
||||
<Text style={[styles.label, { color: theme.text }]}>
|
||||
{t('Media')} ({formData.media.length}/{MAX_MEDIA})
|
||||
</Text>
|
||||
<View style={styles.media}>
|
||||
<TouchableOpacity
|
||||
style={[styles.upload, { borderColor: theme.primary }]}
|
||||
onPress={pickMedia}
|
||||
>
|
||||
<Camera size={28} color={theme.primary} />
|
||||
<Text style={[styles.uploadText, { color: theme.primary }]}>{t('Yuklash')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{formData.media.map((m: MediaType, i: number) => (
|
||||
<View key={i} style={styles.preview}>
|
||||
<Image source={{ uri: m.uri }} style={styles.image} />
|
||||
{m.type === 'video' && (
|
||||
<View style={styles.play}>
|
||||
<Play size={14} color="#fff" fill="#fff" />
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={[styles.remove, { backgroundColor: theme.error }]}
|
||||
onPress={() => removeMedia(i)}
|
||||
>
|
||||
<X size={12} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{errors.media && (
|
||||
<Text style={[styles.error, { color: theme.error }]}>{t(errors.media)}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export default StepOne;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
stepContainer: { gap: 10 },
|
||||
label: { fontWeight: '700' },
|
||||
error: { fontSize: 13, marginLeft: 6 },
|
||||
inputBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
height: 56,
|
||||
},
|
||||
textArea: { height: 120, alignItems: 'flex-start' },
|
||||
input: { flex: 1, fontSize: 16 },
|
||||
media: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
|
||||
upload: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 16,
|
||||
borderWidth: 2,
|
||||
borderStyle: 'dashed',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
uploadText: { fontSize: 11, marginTop: 4 },
|
||||
preview: { width: 100, height: 100 },
|
||||
image: { width: '100%', height: '100%', borderRadius: 16 },
|
||||
play: {
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
left: '40%',
|
||||
backgroundColor: 'rgba(0,0,0,.5)',
|
||||
padding: 6,
|
||||
borderRadius: 20,
|
||||
},
|
||||
remove: {
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
right: -6,
|
||||
padding: 4,
|
||||
borderRadius: 10,
|
||||
},
|
||||
prefixContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
prefix: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
prefixFocused: {},
|
||||
divider: {
|
||||
width: 1.5,
|
||||
height: 24,
|
||||
marginLeft: 12,
|
||||
},
|
||||
});
|
||||
289
screens/create-ads/ui/StepThree.tsx
Normal file
289
screens/create-ads/ui/StepThree.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import { products_api } from '@/screens/home/lib/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dimensions,
|
||||
FlatList,
|
||||
ListRenderItemInfo,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { PriceCalculationRes } from '../lib/types';
|
||||
import CategorySelectorBottomSheet from './CategorySelectorBottomSheet';
|
||||
|
||||
type StepProps = {
|
||||
formData: any;
|
||||
data: PriceCalculationRes | undefined;
|
||||
updateForm: (key: string, value: any) => void;
|
||||
};
|
||||
|
||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||
const GAP = 8;
|
||||
const NUM_COLUMNS = 6;
|
||||
const ITEM_SIZE = (SCREEN_WIDTH - GAP * (NUM_COLUMNS + 1)) / NUM_COLUMNS;
|
||||
|
||||
const StepThree = forwardRef(({ formData, updateForm, data }: StepProps, ref) => {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const theme = {
|
||||
background: isDark ? '#0f172a' : '#ffffff',
|
||||
cardBg: isDark ? '#1e293b' : '#f8fafc',
|
||||
cardBorder: isDark ? '#334155' : '#e2e8f0',
|
||||
text: isDark ? '#f8fafc' : '#0f172a',
|
||||
textSecondary: isDark ? '#cbd5e1' : '#64748b',
|
||||
primary: '#2563eb',
|
||||
error: '#ef4444',
|
||||
priceText: isDark ? '#dc2626' : '#ef4444',
|
||||
companyBg: isDark ? '#1e293b' : '#f1f5f9',
|
||||
companyBorder: isDark ? '#334155' : '#cbd5e1',
|
||||
};
|
||||
|
||||
const { data: statesData } = useQuery({
|
||||
queryKey: ['country-detail'],
|
||||
queryFn: async () => products_api.getStates(),
|
||||
select: (res) => res.data.data || [],
|
||||
});
|
||||
|
||||
const [showCountry, setShowCountry] = useState(false);
|
||||
const [showRegion, setShowRegion] = useState(false);
|
||||
const [showDistrict, setShowDistrict] = useState(false);
|
||||
const [regions, setRegions] = useState<any[]>([]);
|
||||
const [districts, setDistricts] = useState<any[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const corporations = Array.from({ length: 26 }).map((_, i) => ({
|
||||
id: i + 1,
|
||||
latter: String.fromCharCode(65 + i),
|
||||
}));
|
||||
|
||||
const onCompanyPress = (item: { id: number; latter: string }) => {
|
||||
const selected = formData.company || [];
|
||||
const exists = selected.some((c: any) => c.id === item.id);
|
||||
|
||||
if (exists) {
|
||||
updateForm(
|
||||
'company',
|
||||
selected.filter((c: any) => c.id !== item.id)
|
||||
);
|
||||
} else {
|
||||
updateForm('company', [...selected, item]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const country = statesData?.find((c) => c.code === formData.country);
|
||||
setRegions(country?.region || []);
|
||||
if (!country?.region.some((r) => r.code === formData.region)) {
|
||||
updateForm('region', '');
|
||||
updateForm('district', '');
|
||||
setDistricts([]);
|
||||
}
|
||||
}, [formData.country, statesData]);
|
||||
|
||||
useEffect(() => {
|
||||
const country = statesData?.find((c) => c.code === formData.country);
|
||||
const region = country?.region.find((r) => r.code === formData.region);
|
||||
setDistricts(region?.districts || []);
|
||||
if (!region?.districts.some((d) => d.code === formData.district)) {
|
||||
updateForm('district', '');
|
||||
}
|
||||
}, [formData.region, formData.country, statesData]);
|
||||
|
||||
const getLabel = (arr: { name: string; code: string }[], val: string) =>
|
||||
arr.find((item) => item.code === val)?.name || t('— Tanlang —');
|
||||
|
||||
const renderCompanyItem = ({ item }: ListRenderItemInfo<{ id: number; latter: string }>) => {
|
||||
const isSelected = formData.company?.some((c: any) => c.id === item.id);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.companyItem,
|
||||
{
|
||||
backgroundColor: isSelected ? theme.primary : theme.companyBg,
|
||||
borderColor: isSelected ? theme.primary : theme.companyBorder,
|
||||
},
|
||||
]}
|
||||
onPress={() => onCompanyPress(item)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.companyText,
|
||||
{ color: isSelected ? '#fff' : theme.text },
|
||||
isSelected && styles.companyTextActive,
|
||||
]}
|
||||
>
|
||||
{item.latter}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
if (!formData.country) {
|
||||
setError('Iltimos, davlat tanlang');
|
||||
return false;
|
||||
}
|
||||
if (!formData.region) {
|
||||
setError('Iltimos, viloyat tanlang');
|
||||
return false;
|
||||
}
|
||||
if (!formData.district) {
|
||||
setError('Iltimos, tuman/shahar tanlang');
|
||||
return false;
|
||||
}
|
||||
setError(null);
|
||||
return true;
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
validate,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { backgroundColor: theme.background }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{error && <Text style={[styles.error, { color: theme.error }]}>{t(error)}</Text>}
|
||||
|
||||
<Text style={[styles.label, { color: theme.text }]}>{t('Davlat')}</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.pickerButton,
|
||||
{ backgroundColor: theme.cardBg, borderColor: theme.cardBorder },
|
||||
]}
|
||||
onPress={() => setShowCountry(true)}
|
||||
>
|
||||
<Text style={[styles.pickerText, { color: theme.text }]}>
|
||||
{statesData &&
|
||||
getLabel(
|
||||
statesData.map((c) => ({ name: c.name, code: c.code })),
|
||||
formData.country
|
||||
)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={[styles.label, { color: theme.text }]}>{t('Viloyat')}</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.pickerButton,
|
||||
{ backgroundColor: theme.cardBg, borderColor: theme.cardBorder },
|
||||
]}
|
||||
onPress={() => setShowRegion(true)}
|
||||
>
|
||||
<Text style={[styles.pickerText, { color: theme.text }]}>
|
||||
{getLabel(
|
||||
regions.map((r) => ({ name: r.name, code: r.code })),
|
||||
formData.region
|
||||
)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={[styles.label, { color: theme.text }]}>{t('Tuman / Shahar')}</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.pickerButton,
|
||||
{ backgroundColor: theme.cardBg, borderColor: theme.cardBorder },
|
||||
]}
|
||||
onPress={() => setShowDistrict(true)}
|
||||
>
|
||||
<Text style={[styles.pickerText, { color: theme.text }]}>
|
||||
{getLabel(
|
||||
districts.map((d) => ({ name: d.name, code: d.code })),
|
||||
formData.district
|
||||
)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={[styles.sectionTitle, { color: theme.text }]}>
|
||||
{t('Reklama joylashtirish kompaniyasi')}
|
||||
</Text>
|
||||
<FlatList
|
||||
data={corporations}
|
||||
renderItem={renderCompanyItem}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
numColumns={6}
|
||||
columnWrapperStyle={{ gap: 2, marginBottom: GAP, justifyContent: 'flex-start' }}
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[styles.priceCard, { backgroundColor: theme.cardBg, borderColor: theme.cardBorder }]}
|
||||
>
|
||||
<Text style={[styles.priceLine, { color: theme.text }]}>
|
||||
{t('Jami kampaniyalar soni')}: {data ? data.user_count : '0'}
|
||||
</Text>
|
||||
<Text style={[styles.priceLine, { color: theme.text }]}>
|
||||
{t('Reklama narxi')}: {data ? data.one_person_price : '0'}
|
||||
</Text>
|
||||
<Text style={[styles.totalPrice, { color: theme.priceText }]}>
|
||||
{t('Umumiy narx')}: {data ? data.total_price : '0'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<CategorySelectorBottomSheet
|
||||
isOpen={showCountry}
|
||||
onClose={() => setShowCountry(false)}
|
||||
selectedValue={formData.country}
|
||||
data={statesData ? statesData.map((c) => ({ label: c.name, value: c.code })) : []}
|
||||
onSelect={(v) => updateForm('country', v)}
|
||||
/>
|
||||
<CategorySelectorBottomSheet
|
||||
isOpen={showRegion}
|
||||
onClose={() => setShowRegion(false)}
|
||||
selectedValue={formData.region}
|
||||
data={regions.map((r) => ({ label: r.name, value: r.code }))}
|
||||
onSelect={(v) => updateForm('region', v)}
|
||||
/>
|
||||
<CategorySelectorBottomSheet
|
||||
isOpen={showDistrict}
|
||||
onClose={() => setShowDistrict(false)}
|
||||
selectedValue={formData.district}
|
||||
data={districts.map((d) => ({ label: d.name, value: d.code }))}
|
||||
onSelect={(v) => updateForm('district', v)}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
});
|
||||
|
||||
export default StepThree;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flexGrow: 1 },
|
||||
label: { fontSize: 14, fontWeight: '700', marginBottom: 6, marginTop: 10 },
|
||||
pickerButton: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
pickerText: { fontSize: 16 },
|
||||
sectionTitle: { fontSize: 16, fontWeight: '700', marginVertical: 12 },
|
||||
companyItem: {
|
||||
width: 55,
|
||||
height: 55,
|
||||
borderRadius: ITEM_SIZE / 2,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
companyText: { fontSize: 14 },
|
||||
companyTextActive: { fontWeight: '600' },
|
||||
priceCard: {
|
||||
marginTop: 24,
|
||||
padding: 20,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
gap: 8,
|
||||
},
|
||||
priceLine: { fontSize: 15 },
|
||||
totalPrice: { fontSize: 18, fontWeight: '700', marginTop: 6 },
|
||||
error: { fontWeight: '600', marginBottom: 10 },
|
||||
});
|
||||
143
screens/create-ads/ui/StepTwo.tsx
Normal file
143
screens/create-ads/ui/StepTwo.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import CategorySelection from '@/components/ui/IndustrySelection';
|
||||
import { XIcon } from 'lucide-react-native';
|
||||
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FlatList, StyleSheet, Text, ToastAndroid, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
type StepProps = {
|
||||
formData: any;
|
||||
updateForm: (key: string, value: any) => void;
|
||||
};
|
||||
|
||||
const StepTwo = forwardRef(({ formData, updateForm }: StepProps, ref) => {
|
||||
const [selectedCategories, setSelectedCategories] = useState<any[]>(formData.category || []);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const theme = {
|
||||
text: isDark ? '#f8fafc' : '#0f172a',
|
||||
tabBg: isDark ? '#1e293b' : '#f1f5f9',
|
||||
tabText: isDark ? '#ffffff' : '#1e293b',
|
||||
deleteBg: isDark ? '#394e73' : '#cbd5e1',
|
||||
deleteIcon: isDark ? '#f8fafc' : '#475569',
|
||||
error: '#ef4444',
|
||||
shadow: isDark ? '#000' : '#64748b',
|
||||
};
|
||||
|
||||
// FormData-ni yangilash
|
||||
useEffect(() => {
|
||||
updateForm('category', selectedCategories);
|
||||
if (selectedCategories.length > 0) setError(null);
|
||||
}, [selectedCategories]);
|
||||
|
||||
// Validatsiya
|
||||
const validate = () => {
|
||||
if (selectedCategories.length === 0) {
|
||||
setError('Iltimos, kompaniyalarni tanlang');
|
||||
ToastAndroid.show(t('Iltimos, kompaniyalarni tanlang'), ToastAndroid.TOP);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
validate,
|
||||
}));
|
||||
|
||||
// O'chirish funksiyasi
|
||||
const removeCategory = (id: string | number) => {
|
||||
setSelectedCategories((prev) => prev.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
// SearchTabs uchun render funksiyasi
|
||||
const renderTab = ({ item }: { item: any }) => (
|
||||
<View style={[styles.tabWrapper, { backgroundColor: theme.tabBg, shadowColor: theme.shadow }]}>
|
||||
<Text
|
||||
style={[styles.tabText, { color: theme.tabText }]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => removeCategory(item.id)}
|
||||
style={[styles.deleteTab, { backgroundColor: theme.deleteBg }]}
|
||||
>
|
||||
<XIcon size={15} color={theme.deleteIcon} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.container]}>
|
||||
{/* Tanlangan kategoriya tablari */}
|
||||
{selectedCategories.length > 0 && (
|
||||
<>
|
||||
<Text style={[styles.label, { color: theme.text }]}>{t('Tanlangan sohalar')}:</Text>
|
||||
<FlatList
|
||||
data={selectedCategories}
|
||||
renderItem={renderTab}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.tabsContainer}
|
||||
ItemSeparatorComponent={() => <View style={{ width: 8 }} />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Kategoriya tanlash */}
|
||||
<Text style={[styles.label, { color: theme.text }]}>{t("Sohalar ro'yxati")}:</Text>
|
||||
<CategorySelection
|
||||
selectedCategories={selectedCategories}
|
||||
setSelectedCategories={setSelectedCategories}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export default StepTwo;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flexGrow: 1 },
|
||||
label: {
|
||||
fontSize: 16,
|
||||
marginBottom: 5,
|
||||
fontWeight: '600',
|
||||
},
|
||||
error: {
|
||||
marginTop: 10,
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
},
|
||||
tabsContainer: {
|
||||
marginBottom: 16,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
tabWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 20,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 3,
|
||||
elevation: 3,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginRight: 6,
|
||||
maxWidth: 200,
|
||||
flexShrink: 1,
|
||||
},
|
||||
deleteTab: {
|
||||
padding: 4,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
73
screens/home/lib/api.ts
Normal file
73
screens/home/lib/api.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import httpClient from '@/api/httpClient';
|
||||
import { API_URLS } from '@/api/URLs';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import {
|
||||
businessAboutDetailRes,
|
||||
businessAboutRes,
|
||||
Categories,
|
||||
CompanyBody,
|
||||
CountryBody,
|
||||
ProductBody,
|
||||
States,
|
||||
} from './types';
|
||||
|
||||
export const products_api = {
|
||||
async getProducts(params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
}): Promise<AxiosResponse<ProductBody>> {
|
||||
const res = await httpClient.get(API_URLS.Get_Products, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async getCompany(params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
}): Promise<AxiosResponse<CompanyBody>> {
|
||||
const res = await httpClient.get(API_URLS.Get_Company, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async getCountry(params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
}): Promise<AxiosResponse<CountryBody>> {
|
||||
const res = await httpClient.get(API_URLS.Get_Countries, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async getStates(): Promise<AxiosResponse<States>> {
|
||||
const res = await httpClient.get(API_URLS.Get_States);
|
||||
return res;
|
||||
},
|
||||
|
||||
async getCategorys(params?: { parent: number }): Promise<AxiosResponse<Categories>> {
|
||||
const res = await httpClient.get(API_URLS.Get_Categories, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async getCategoryChild(id: number): Promise<AxiosResponse<Categories>> {
|
||||
const res = await httpClient.get(API_URLS.Get_Categories_Child(id));
|
||||
return res;
|
||||
},
|
||||
|
||||
async businessAbout(params: {
|
||||
country?: string;
|
||||
district: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
region?: string;
|
||||
types?: number;
|
||||
}): Promise<AxiosResponse<businessAboutRes>> {
|
||||
const res = await httpClient.get(API_URLS.Business_About, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async businessAboutDetail(id: number): Promise<AxiosResponse<businessAboutDetailRes>> {
|
||||
const res = await httpClient.get(API_URLS.Business_About_Detail(id));
|
||||
return res;
|
||||
},
|
||||
};
|
||||
150
screens/home/lib/types.ts
Normal file
150
screens/home/lib/types.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
export interface ProductBody {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: null | string;
|
||||
next: null | string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: ProductResponse[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProductResponse {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
company: string;
|
||||
files: {
|
||||
id: number;
|
||||
file: string;
|
||||
}[];
|
||||
category: { id: number; name: string; icon: string }[];
|
||||
}
|
||||
|
||||
export interface CompanyBody {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: null | string;
|
||||
next: null | string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: CompanyResponse[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface CompanyResponse {
|
||||
id: number;
|
||||
company_name: string;
|
||||
country_name: string;
|
||||
region_name: string;
|
||||
district_name: string;
|
||||
product_service_company: {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
company: string;
|
||||
files: [
|
||||
{
|
||||
id: number;
|
||||
file: string;
|
||||
},
|
||||
];
|
||||
category: [];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface CountryBody {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: null | string;
|
||||
next: null | string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: CountryResponse[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface CountryResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
companies: {
|
||||
id: number;
|
||||
company_name: string;
|
||||
service_count: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface States {
|
||||
status: boolean;
|
||||
data: {
|
||||
id: number;
|
||||
name: string;
|
||||
region: {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
districts: { id: number; name: string; code: string }[];
|
||||
}[];
|
||||
code: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface Categories {
|
||||
status: boolean;
|
||||
data: {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
external_id: null | string;
|
||||
level: number;
|
||||
is_leaf: boolean;
|
||||
icon_name: null | string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface businessAboutRes {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: null | string;
|
||||
next: null | string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: { id: number; company_name: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface businessAboutDetailRes {
|
||||
status: boolean;
|
||||
data: businessAboutDetailResData;
|
||||
}
|
||||
|
||||
export interface businessAboutDetailResData {
|
||||
company_name: string;
|
||||
director_full_name: string;
|
||||
company_image: null | string;
|
||||
phone: string;
|
||||
product_service_company: {
|
||||
title: string;
|
||||
description: string;
|
||||
category: { id: number; name: string; icon_name: string }[];
|
||||
files: {
|
||||
file: string;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
399
screens/home/ui/FilterScreen.tsx
Normal file
399
screens/home/ui/FilterScreen.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Briefcase,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
MapPin,
|
||||
Package,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react-native';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
// --- TURLAR ---
|
||||
type TabKey = 'products' | 'companies' | 'countries';
|
||||
type FilterStep = 'filter' | 'items' | 'detail';
|
||||
|
||||
const FAKE_ITEMS = [
|
||||
{ id: 1, company_name: 'Artel Electronics', industry: 'Maishiy texnika', country: 'UZ' },
|
||||
{ id: 2, company_name: 'Toshkent City Mall', industry: 'Savdo', country: 'UZ' },
|
||||
{ id: 3, company_name: 'Turkish Airlines', industry: 'Aviatsiya', country: 'TR' },
|
||||
{ id: 4, company_name: 'Siemens AG', industry: 'Muhandislik', country: 'DE' },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
const [filterVisible, setFilterVisible] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('products');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
if (filterVisible) {
|
||||
return <FilterUI back={() => setFilterVisible(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
{/* Search Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.searchContainer}>
|
||||
<Search size={20} color="#94a3b8" style={styles.searchIcon} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder={`${activeTab === 'products' ? 'Mahsulot' : activeTab === 'companies' ? 'Kompaniya' : 'Davlat'} bo'yicha...`}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
placeholderTextColor="#94a3b8"
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.filterButton} onPress={() => setFilterVisible(true)}>
|
||||
<SlidersHorizontal size={22} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Tabs */}
|
||||
<View style={styles.tabWrapper}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.tabList}
|
||||
>
|
||||
{[
|
||||
{
|
||||
key: 'products',
|
||||
label: 'Mahsulotlar',
|
||||
icon: <Package size={18} color={activeTab === 'products' ? '#fff' : '#64748b'} />,
|
||||
},
|
||||
{
|
||||
key: 'companies',
|
||||
label: 'Kompaniyalar',
|
||||
icon: <Building2 size={18} color={activeTab === 'companies' ? '#fff' : '#64748b'} />,
|
||||
},
|
||||
{
|
||||
key: 'countries',
|
||||
label: 'Davlatlar',
|
||||
icon: <Globe size={18} color={activeTab === 'countries' ? '#fff' : '#64748b'} />,
|
||||
},
|
||||
].map((tab) => (
|
||||
<TouchableOpacity
|
||||
key={tab.key}
|
||||
onPress={() => setActiveTab(tab.key as TabKey)}
|
||||
style={[styles.tabItem, activeTab === tab.key && styles.activeTabItem]}
|
||||
>
|
||||
{tab.icon}
|
||||
<Text style={[styles.tabText, activeTab === tab.key && styles.activeTabText]}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Main List */}
|
||||
<FlatList
|
||||
data={FAKE_ITEMS}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity style={styles.card}>
|
||||
<View style={styles.cardInfo}>
|
||||
<Text style={styles.cardTitle}>{item.company_name}</Text>
|
||||
<Text style={styles.cardSub}>{item.industry}</Text>
|
||||
</View>
|
||||
<ChevronRight size={20} color="#cbd5e1" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// --- FILTER UI KOMPONENTI ---
|
||||
function FilterUI({ back }: { back: () => void }) {
|
||||
const [step, setStep] = useState<FilterStep>('filter');
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
const handleBack = () => {
|
||||
if (step === 'items') setStep('filter');
|
||||
else if (step === 'detail') setStep('items');
|
||||
else back();
|
||||
};
|
||||
|
||||
const renderFilter = () => (
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MapPin size={20} color="#3b82f6" />
|
||||
<Text style={styles.sectionLabel}>Hududni tanlang</Text>
|
||||
</View>
|
||||
<View style={styles.selectionCard}>
|
||||
<TouchableOpacity style={styles.selectRow}>
|
||||
<View>
|
||||
<Text style={styles.selectLabel}>Davlat</Text>
|
||||
<Text style={styles.selectValue}>O'zbekiston</Text>
|
||||
</View>
|
||||
<ChevronRight size={20} color="#cbd5e1" />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity style={styles.selectRow}>
|
||||
<View>
|
||||
<Text style={styles.selectLabel}>Viloyat</Text>
|
||||
<Text style={styles.selectValue}>Barchasi</Text>
|
||||
</View>
|
||||
<ChevronRight size={20} color="#cbd5e1" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Briefcase size={20} color="#3b82f6" />
|
||||
<Text style={styles.sectionLabel}>Sanoat yo'nalishi</Text>
|
||||
</View>
|
||||
<View style={styles.industryGrid}>
|
||||
{['Texnologiya', 'Tibbiyot', 'Ta’lim', 'Qurilish'].map((item) => (
|
||||
<TouchableOpacity key={item} style={styles.industryTag}>
|
||||
<Text style={styles.industryTagText}>{item}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity onPress={() => setStep('items')} style={styles.applyButton}>
|
||||
<Text style={styles.applyButtonText}>Natijalarni ko'rish ({FAKE_ITEMS.length})</Text>
|
||||
<CheckCircle2 size={20} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderItems = () => (
|
||||
<FlatList
|
||||
data={FAKE_ITEMS}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.resultCard}
|
||||
onPress={() => {
|
||||
setSelectedId(item.id);
|
||||
setStep('detail');
|
||||
}}
|
||||
>
|
||||
<View style={styles.iconCircle}>
|
||||
<Building2 size={22} color="#3b82f6" />
|
||||
</View>
|
||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||
<Text style={styles.companyName}>{item.company_name}</Text>
|
||||
<Text style={styles.industryText}>{item.industry}</Text>
|
||||
</View>
|
||||
<ChevronRight size={20} color="#cbd5e1" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderDetail = () => {
|
||||
const item = FAKE_ITEMS.find((i) => i.id === selectedId);
|
||||
return (
|
||||
<View style={styles.detailCard}>
|
||||
<Text style={styles.detailTitle}>{item?.company_name}</Text>
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{item?.industry}</Text>
|
||||
</View>
|
||||
<Text style={styles.detailDesc}>
|
||||
Bu yerda web-versiyadagi batafsil ma'lumotlar chiqadi.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.navHeader}>
|
||||
<TouchableOpacity onPress={handleBack} style={styles.backButton}>
|
||||
<ArrowLeft size={24} color="#1e293b" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>
|
||||
{step === 'filter' ? 'Filtrlash' : step === 'items' ? 'Natijalar' : 'Batafsil'}
|
||||
</Text>
|
||||
<View style={{ width: 40 }} />
|
||||
</View>
|
||||
{step === 'filter' && renderFilter()}
|
||||
{step === 'items' && renderItems()}
|
||||
{step === 'detail' && renderDetail()}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// --- STILLAR ---
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f8fafc' },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
searchContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f1f5f9',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
height: 48,
|
||||
},
|
||||
searchIcon: { marginRight: 8 },
|
||||
searchInput: { flex: 1, fontSize: 16, color: '#1e293b' },
|
||||
filterButton: {
|
||||
backgroundColor: '#3b82f6',
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
elevation: 4,
|
||||
},
|
||||
tabWrapper: {
|
||||
backgroundColor: '#fff',
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f1f5f9',
|
||||
},
|
||||
tabList: { paddingHorizontal: 16, gap: 10 },
|
||||
tabItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#f1f5f9',
|
||||
gap: 8,
|
||||
marginRight: 10,
|
||||
},
|
||||
activeTabItem: { backgroundColor: '#3b82f6' },
|
||||
tabText: { fontSize: 14, fontWeight: '600', color: '#64748b' },
|
||||
activeTabText: { color: '#fff' },
|
||||
listContent: { padding: 16 },
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#f1f5f9',
|
||||
},
|
||||
cardInfo: { flex: 1 },
|
||||
cardTitle: { fontSize: 16, fontWeight: '700', color: '#1e293b' },
|
||||
cardSub: { fontSize: 13, color: '#94a3b8' },
|
||||
navHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#fff',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f1f5f9',
|
||||
},
|
||||
backButton: { padding: 8, borderRadius: 12, backgroundColor: '#f1f5f9' },
|
||||
headerTitle: { fontSize: 18, fontWeight: '700', color: '#1e293b' },
|
||||
scrollContent: { padding: 16, paddingBottom: 100 },
|
||||
section: { marginBottom: 24 },
|
||||
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 12 },
|
||||
sectionLabel: { fontSize: 15, fontWeight: '600', color: '#64748b' },
|
||||
selectionCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
selectRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 14,
|
||||
},
|
||||
selectLabel: { fontSize: 12, color: '#94a3b8' },
|
||||
selectValue: { fontSize: 15, fontWeight: '600', color: '#1e293b' },
|
||||
divider: { height: 1, backgroundColor: '#f1f5f9' },
|
||||
industryGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
|
||||
industryTag: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
industryTagText: { fontSize: 14, color: '#475569' },
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
padding: 20,
|
||||
backgroundColor: '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f1f5f9',
|
||||
},
|
||||
applyButton: {
|
||||
height: 56,
|
||||
backgroundColor: '#3b82f6',
|
||||
borderRadius: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
applyButtonText: { color: '#fff', fontSize: 16, fontWeight: '700' },
|
||||
resultCard: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
marginBottom: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#f1f5f9',
|
||||
},
|
||||
iconCircle: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#eff6ff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
companyName: { fontSize: 16, fontWeight: '700' },
|
||||
industryText: { fontSize: 13, color: '#64748b' },
|
||||
detailCard: { margin: 16, padding: 20, backgroundColor: '#fff', borderRadius: 20 },
|
||||
detailTitle: { fontSize: 22, fontWeight: '800' },
|
||||
badge: {
|
||||
backgroundColor: '#eff6ff',
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
marginVertical: 12,
|
||||
},
|
||||
badgeText: { color: '#3b82f6', fontWeight: '600' },
|
||||
detailDesc: { lineHeight: 22, color: '#475569' },
|
||||
});
|
||||
52
screens/home/ui/FilteredItems.tsx
Normal file
52
screens/home/ui/FilteredItems.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ArrowLeft, ChevronRight } from 'lucide-react-native';
|
||||
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export function FilteredItems({ data, back }: any) {
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<View>
|
||||
<TouchableOpacity onPress={back}>
|
||||
<ArrowLeft size={24} color="#1e293b" />
|
||||
</TouchableOpacity>
|
||||
<Text>Natijalar ({data.length})</Text>
|
||||
<View style={{ width: 40 }} />
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={data}
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity style={styles.resultCard}>
|
||||
<View style={styles.resultInfo}>
|
||||
<Text style={styles.companyName}>{item.company_name}</Text>
|
||||
<Text style={styles.industryText}>{item.industry || 'Sanoat turi'}</Text>
|
||||
</View>
|
||||
<View style={styles.goIcon}>
|
||||
<ChevronRight size={18} color="#3b82f6" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// Qo'shimcha stillar resultCard uchun
|
||||
const styles = StyleSheet.create({
|
||||
resultCard: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
marginBottom: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#f1f5f9',
|
||||
},
|
||||
resultInfo: { flex: 1 },
|
||||
companyName: { fontSize: 16, fontWeight: '700', color: '#1e293b' },
|
||||
industryText: { fontSize: 13, color: '#64748b', marginTop: 4 },
|
||||
goIcon: { backgroundColor: '#eff6ff', padding: 8, borderRadius: 10 },
|
||||
});
|
||||
272
screens/home/ui/HomeScreen.tsx
Normal file
272
screens/home/ui/HomeScreen.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
// pages/home/ui/HomeScreen.tsx
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import CompanyList from '@/components/ui/CompanyList';
|
||||
import CountriesList from '@/components/ui/CountriesList';
|
||||
import FilteredItems from '@/components/ui/FilteredItems';
|
||||
import FilterUI from '@/components/ui/FilterUI';
|
||||
import ProductList from '@/components/ui/ProductList';
|
||||
import SearchTabs from '@/components/ui/SearchTabs';
|
||||
import { useTabSearch } from '@/hooks/useSearch';
|
||||
import { TabKey } from '@/types';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Stack } from 'expo-router';
|
||||
import { Filter, Search } from 'lucide-react-native';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { RefreshControl } from 'react-native-gesture-handler';
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#3b82f6" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { isDark } = useTheme();
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('products');
|
||||
const [step, setStep] = useState<'filter' | 'items'>('filter');
|
||||
const [query, setQuery] = useState('');
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [filtered, setFiltered] = useState<{ id: number; company_name: string }[]>([]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { isLoading, error } = useTabSearch(activeTab, query);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
if (activeTab === 'products') {
|
||||
await queryClient.invalidateQueries({ queryKey: ['products-list'] });
|
||||
}
|
||||
await queryClient.refetchQueries();
|
||||
} catch (err) {
|
||||
console.error('Refresh error:', err);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [queryClient, activeTab]);
|
||||
|
||||
const placeholderText = useMemo(() => {
|
||||
switch (activeTab) {
|
||||
case 'products':
|
||||
return 'Mahsulot qidirish...';
|
||||
case 'companies':
|
||||
return 'Korxona qidirish...';
|
||||
case 'countries':
|
||||
return 'Davlat qidirish...';
|
||||
default:
|
||||
return 'Qidiruv...';
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const RenderedView = useMemo(() => {
|
||||
switch (activeTab) {
|
||||
case 'products':
|
||||
return <ProductList query={query} />;
|
||||
case 'companies':
|
||||
return <CompanyList query={query} />;
|
||||
case 'countries':
|
||||
return <CountriesList search={query} />;
|
||||
}
|
||||
}, [activeTab, query]);
|
||||
|
||||
if (showFilter && step === 'filter') {
|
||||
return (
|
||||
<FilterUI back={() => setShowFilter(false)} setStep={setStep} setFiltered={setFiltered} />
|
||||
);
|
||||
}
|
||||
|
||||
if (showFilter && step === 'items') {
|
||||
return (
|
||||
<FilteredItems
|
||||
data={filtered}
|
||||
back={() => {
|
||||
setShowFilter(false);
|
||||
setStep('filter');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[isDark ? styles.darkBg : styles.lightBg]}
|
||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 60 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={['#3b82f6']}
|
||||
tintColor="#3b82f6"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* Qidiruv va filter */}
|
||||
<View style={styles.searchSection}>
|
||||
<View
|
||||
style={[
|
||||
styles.searchInputContainer,
|
||||
isDark ? styles.darkSearchInput : styles.lightSearchInput,
|
||||
]}
|
||||
>
|
||||
<Search size={20} color={isDark ? '#64748b' : '#94a3b8'} style={styles.searchIcon} />
|
||||
<TextInput
|
||||
style={[styles.searchInput, isDark ? styles.darkInputText : styles.lightInputText]}
|
||||
placeholder={t(placeholderText)}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholderTextColor={isDark ? '#64748b' : '#94a3b8'}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.filterButton}
|
||||
onPress={() => setShowFilter(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Filter size={20} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<SearchTabs value={activeTab} onChange={setActiveTab} />
|
||||
|
||||
{error && (
|
||||
<View style={[styles.errorContainer, isDark ? styles.darkError : styles.lightError]}>
|
||||
<Text style={styles.errorText}>{t("Ma'lumot yuklashda xatolik")}</Text>
|
||||
<TouchableOpacity onPress={onRefresh} style={styles.retryButton}>
|
||||
<Text style={styles.retryButtonText}>{t('Qayta urinish')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isLoading && !refreshing && <Loading />}
|
||||
|
||||
{!isLoading && RenderedView}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
darkBg: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0f172a',
|
||||
},
|
||||
lightBg: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
maxWidth: 768,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
gap: 20,
|
||||
},
|
||||
searchSection: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
searchInputContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1.5,
|
||||
paddingHorizontal: 16,
|
||||
shadowColor: '#3b82f6',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
darkSearchInput: {
|
||||
backgroundColor: '#1e293b',
|
||||
borderColor: '#334155',
|
||||
},
|
||||
lightSearchInput: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
searchIcon: {
|
||||
marginRight: 10,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
darkInputText: {
|
||||
color: '#f1f5f9',
|
||||
},
|
||||
lightInputText: {
|
||||
color: '#0f172a',
|
||||
},
|
||||
filterButton: {
|
||||
backgroundColor: '#3b82f6',
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#3b82f6',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
loadingContainer: {
|
||||
padding: 60,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
errorContainer: {
|
||||
padding: 32,
|
||||
alignItems: 'center',
|
||||
borderRadius: 16,
|
||||
},
|
||||
darkError: {
|
||||
backgroundColor: '#1e293b',
|
||||
},
|
||||
lightError: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#fee2e2',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#f87171',
|
||||
marginBottom: 12,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: '#3b82f6',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
80
screens/profile/lib/ProfileDataContext.tsx
Normal file
80
screens/profile/lib/ProfileDataContext.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import createContextHook from '@nkzw/create-context-hook';
|
||||
import { useState } from 'react';
|
||||
import { Announcement, Bonus, Employee, ProductService } from './type';
|
||||
|
||||
export const [ProfileDataProvider, useProfileData] = createContextHook(() => {
|
||||
const [employees, setEmployees] = useState<Employee[]>([
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Aziz',
|
||||
lastName: 'Rahimov',
|
||||
phoneNumber: '+998901234567',
|
||||
addedAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
const [announcements] = useState<Announcement[]>([
|
||||
{
|
||||
id: '1',
|
||||
name: "Yangi loyiha bo'yicha hamkorlik",
|
||||
companyTypes: ['IT', 'Qurilish'],
|
||||
totalAmount: 5000000,
|
||||
paymentStatus: 'paid',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
const [bonuses] = useState<Bonus[]>([
|
||||
{
|
||||
id: '1',
|
||||
title: 'Yillik bonus',
|
||||
description: "Yil davomida ko'rsatilgan yuqori natijalarga oid bonus",
|
||||
percentage: 15,
|
||||
bonusAmount: 3000000,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
const [productServices, setProductServices] = useState<ProductService[]>([]);
|
||||
|
||||
const addEmployee = (employee: Omit<Employee, 'id' | 'addedAt'>) => {
|
||||
setEmployees((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...employee,
|
||||
id: Date.now().toString(),
|
||||
addedAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeEmployee = (id: string) => {
|
||||
setEmployees((prev) => prev.filter((e) => e.id !== id));
|
||||
};
|
||||
|
||||
const updateEmployee = (id: string, data: Partial<Employee>) => {
|
||||
setEmployees((prev) => prev.map((e) => (e.id === id ? { ...e, ...data } : e)));
|
||||
};
|
||||
|
||||
const addProductService = (service: Omit<ProductService, 'id' | 'createdAt'>) => {
|
||||
setProductServices((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...service,
|
||||
id: Date.now().toString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return {
|
||||
employees,
|
||||
addEmployee,
|
||||
removeEmployee,
|
||||
updateEmployee,
|
||||
announcements,
|
||||
bonuses,
|
||||
productServices,
|
||||
addProductService,
|
||||
};
|
||||
});
|
||||
108
screens/profile/lib/api.ts
Normal file
108
screens/profile/lib/api.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import httpClient from '@/api/httpClient';
|
||||
import { API_URLS } from '@/api/URLs';
|
||||
import { ProductBody, ProductResponse } from '@/screens/home/lib/types';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import {
|
||||
ExployeesResponse,
|
||||
MyAdsData,
|
||||
MyAdsDataRes,
|
||||
MyBonusesData,
|
||||
UserInfoResponseData,
|
||||
} from './type';
|
||||
|
||||
export const user_api = {
|
||||
async getMe(): Promise<AxiosResponse<UserInfoResponseData>> {
|
||||
const res = await httpClient.get(API_URLS.Get_Me);
|
||||
return res;
|
||||
},
|
||||
|
||||
async updateMe(body: {
|
||||
first_name: string;
|
||||
industries: {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
external_id: null | number;
|
||||
level: number;
|
||||
is_leaf: boolean;
|
||||
icon_name: null | string;
|
||||
parent: {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
};
|
||||
}[];
|
||||
person_type: 'employee' | 'legal_entity' | 'ytt' | 'band';
|
||||
phone: string;
|
||||
activate_types: number[];
|
||||
}) {
|
||||
const res = await httpClient.patch(API_URLS.User_Update, body);
|
||||
return res;
|
||||
},
|
||||
|
||||
async employess(params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
}): Promise<AxiosResponse<ExployeesResponse>> {
|
||||
const res = await httpClient.get(API_URLS.Employee_List, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async create_employee(body: { first_name: string; last_name: string; phone: string }) {
|
||||
const res = await httpClient.post(API_URLS.Employee_List, body);
|
||||
return res;
|
||||
},
|
||||
|
||||
async my_ads(params: { page: number; page_size: number }): Promise<AxiosResponse<MyAdsData>> {
|
||||
const res = await httpClient.get(API_URLS.My_Ads, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async my_ads_detail(id: number): Promise<AxiosResponse<{ status: boolean; data: MyAdsDataRes }>> {
|
||||
const res = await httpClient.get(API_URLS.My_Ads_Detail(id));
|
||||
return res;
|
||||
},
|
||||
|
||||
async my_bonuses(params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
}): Promise<AxiosResponse<MyBonusesData>> {
|
||||
const res = await httpClient.get(API_URLS.My_Bonuses, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async my_sevices(params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
my: boolean;
|
||||
}): Promise<AxiosResponse<ProductBody>> {
|
||||
const res = await httpClient.get(API_URLS.Get_Products, { params });
|
||||
return res;
|
||||
},
|
||||
|
||||
async add_service(body: FormData) {
|
||||
const res = await httpClient.post(API_URLS.Get_Products, body, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return res;
|
||||
},
|
||||
|
||||
async update_service({ body, id }: { body: FormData; id: number }) {
|
||||
const res = await httpClient.patch(API_URLS.Detail_Products(id), body, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return res;
|
||||
},
|
||||
|
||||
async delete_service(id: number) {
|
||||
const res = await httpClient.delete(API_URLS.Delete_Products(id));
|
||||
return res;
|
||||
},
|
||||
|
||||
async detail_service(
|
||||
id: number
|
||||
): Promise<AxiosResponse<{ status: boolean; data: ProductResponse }>> {
|
||||
const res = await httpClient.get(API_URLS.Detail_Products(id));
|
||||
return res;
|
||||
},
|
||||
};
|
||||
168
screens/profile/lib/type.ts
Normal file
168
screens/profile/lib/type.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export interface Employee {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phoneNumber: string;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export interface Announcement {
|
||||
id: string;
|
||||
name: string;
|
||||
companyTypes: string[];
|
||||
totalAmount: number;
|
||||
paymentStatus: 'paid' | 'pending' | 'failed';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Bonus {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
percentage: number;
|
||||
bonusAmount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ProductService {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
mediaUrl: string;
|
||||
mediaType: 'image' | 'video';
|
||||
categories: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UserInfoResponseData {
|
||||
status: boolean;
|
||||
data: {
|
||||
id: number;
|
||||
activate_types: {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
external_id: null | number;
|
||||
level: number;
|
||||
is_leaf: boolean;
|
||||
icon_name: null | string;
|
||||
parent: {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
};
|
||||
}[];
|
||||
|
||||
last_login: null | string;
|
||||
is_superuser: boolean;
|
||||
is_staff: boolean;
|
||||
is_active: boolean;
|
||||
date_joined: string;
|
||||
first_name: string;
|
||||
last_name: null | string;
|
||||
email: null | string;
|
||||
phone: string;
|
||||
username: string;
|
||||
validated_at: string;
|
||||
company_name: string;
|
||||
stir: string;
|
||||
director_full_name: string;
|
||||
referral: null | string;
|
||||
referral_amount: number;
|
||||
referral_share: number;
|
||||
telegram_id: null | string;
|
||||
can_create_referral: boolean;
|
||||
role: string;
|
||||
person_type: 'employee' | 'legal_entity' | 'ytt' | 'band';
|
||||
company_image: null | string;
|
||||
address: null | string;
|
||||
district: number;
|
||||
parent: null | string;
|
||||
user_tg_ids: number[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExployeesResponse {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: null | string;
|
||||
next: null | string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: ExployeesDataResponse[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExployeesDataResponse {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface MyAdsData {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: null | string;
|
||||
next: null | string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: MyAdsDataRes[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface MyAdsDataRes {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
total_view_count: number;
|
||||
files: {
|
||||
id: number;
|
||||
file: string;
|
||||
}[];
|
||||
|
||||
status: 'paid' | 'pending' | 'verified' | 'canceled';
|
||||
types: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
|
||||
letters: string[];
|
||||
total_price: number;
|
||||
phone_number: string;
|
||||
payments_type: 'OTHER' | 'PAYME' | 'REFERRAL';
|
||||
created_at: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface MyBonusesData {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: null | string;
|
||||
next: null | string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: MyBonusesDataRes[];
|
||||
};
|
||||
}
|
||||
export interface MyBonusesDataRes {
|
||||
id: number;
|
||||
ad: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
amount: number;
|
||||
percent: number;
|
||||
created_at: string;
|
||||
}
|
||||
219
screens/profile/ui/AddEmployee.tsx
Normal file
219
screens/profile/ui/AddEmployee.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import { formatPhone, normalizeDigits } from '@/constants/formatPhone';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { ArrowLeft } from 'lucide-react-native';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
ToastAndroid,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { user_api } from '../lib/api';
|
||||
|
||||
export default function AddEmployee() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const theme = {
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
inputBg: isDark ? '#1e293b' : '#f1f5f9',
|
||||
inputBorder: isDark ? '#1e293b' : '#e2e8f0',
|
||||
text: isDark ? '#f8fafc' : '#0f172a',
|
||||
textSecondary: isDark ? '#64748b' : '#94a3b8',
|
||||
primary: '#3b82f6',
|
||||
placeholder: isDark ? '#94a3b8' : '#94a3b8',
|
||||
divider: isDark ? '#fff' : '#cbd5e1',
|
||||
};
|
||||
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [phoneNumber, setPhoneNumber] = useState('');
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (body: { first_name: string; last_name: string; phone: string }) =>
|
||||
user_api.create_employee(body),
|
||||
onSuccess: () => {
|
||||
router.push('/profile/employees');
|
||||
queryClient.refetchQueries({ queryKey: ['employees-list'] });
|
||||
},
|
||||
onError: (err: AxiosError) => {
|
||||
const errMessage = (err.response?.data as { data: { phone: string[] } }).data.phone[0];
|
||||
Alert.alert(t('Xatolik yuz berdi'), errMessage || t('Xatolik yuz berdi'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
setPhoneNumber(normalizeDigits(text));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!firstName.trim() || !lastName.trim() || !phoneNumber.trim()) {
|
||||
ToastAndroid.show(t("Barcha maydonlarni to'ldiring"), ToastAndroid.SHORT);
|
||||
return;
|
||||
}
|
||||
|
||||
mutate({
|
||||
first_name: firstName.trim(),
|
||||
last_name: lastName.trim(),
|
||||
phone: phoneNumber.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<View style={styles.topHeader}>
|
||||
<Pressable onPress={() => router.push('/profile/employees')}>
|
||||
<ArrowLeft color={theme.text} />
|
||||
</Pressable>
|
||||
<Text style={[styles.headerTitle, { color: theme.text }]}>{t("Yangi xodim qo'shish")}</Text>
|
||||
<Pressable onPress={handleSave} disabled={isPending}>
|
||||
{isPending ? (
|
||||
<ActivityIndicator size={'small'} />
|
||||
) : (
|
||||
<Text style={[styles.saveButton, { color: theme.primary }]}>{t('Saqlash')}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.form}>
|
||||
<View style={styles.field}>
|
||||
<Text style={[styles.label, { color: theme.textSecondary }]}>{t('Ism')}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.inputBg, color: theme.text }]}
|
||||
value={firstName}
|
||||
onChangeText={setFirstName}
|
||||
placeholder={t('Ism kiriting')}
|
||||
placeholderTextColor={theme.placeholder}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={[styles.label, { color: theme.textSecondary }]}>{t('Familiya')}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.inputBg, color: theme.text }]}
|
||||
value={lastName}
|
||||
onChangeText={setLastName}
|
||||
placeholder={t('Familiya kiriting')}
|
||||
placeholderTextColor={theme.placeholder}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.inputContainer,
|
||||
{ backgroundColor: theme.inputBg, borderColor: theme.inputBorder },
|
||||
focused && styles.inputFocused,
|
||||
]}
|
||||
>
|
||||
<View style={styles.prefixContainer}>
|
||||
<Text style={[styles.prefix, { color: theme.text }, focused && styles.prefixFocused]}>
|
||||
+998
|
||||
</Text>
|
||||
<View style={[styles.divider, { backgroundColor: theme.divider }]} />
|
||||
</View>
|
||||
|
||||
<TextInput
|
||||
value={formatPhone(phoneNumber)}
|
||||
onChangeText={handleChange}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
keyboardType="phone-pad"
|
||||
placeholder="90 123 45 67"
|
||||
placeholderTextColor={theme.placeholder}
|
||||
style={[styles.input, { color: theme.text }]}
|
||||
maxLength={12}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
saveButton: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
form: {
|
||||
padding: 16,
|
||||
gap: 20,
|
||||
},
|
||||
field: {
|
||||
gap: 12,
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
input: {
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 14,
|
||||
borderWidth: 2,
|
||||
paddingHorizontal: 16,
|
||||
height: 56,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
inputFocused: {
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
prefixContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
prefix: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
prefixFocused: {},
|
||||
divider: {
|
||||
width: 1.5,
|
||||
height: 24,
|
||||
marginLeft: 12,
|
||||
},
|
||||
topHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerTitle: { fontSize: 18, fontWeight: '700' },
|
||||
});
|
||||
197
screens/profile/ui/AddService.tsx
Normal file
197
screens/profile/ui/AddService.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import StepTwo from '@/screens/create-ads/ui/StepTwo';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { ArrowLeft } from 'lucide-react-native';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { user_api } from '../lib/api';
|
||||
import StepOneServices from './StepOneService';
|
||||
|
||||
type MediaFile = {
|
||||
uri: string;
|
||||
type: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
interface formDataType {
|
||||
title: string;
|
||||
description: string;
|
||||
media: MediaFile[];
|
||||
category: any[];
|
||||
}
|
||||
|
||||
const getMimeType = (uri: string) => {
|
||||
const ext = uri.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
case 'heic':
|
||||
return 'image/heic';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
};
|
||||
|
||||
export default function AddService() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const stepOneRef = useRef<any>(null);
|
||||
const stepTwoRef = useRef<any>(null);
|
||||
|
||||
const [formData, setFormData] = useState<formDataType>({
|
||||
title: '',
|
||||
description: '',
|
||||
media: [],
|
||||
category: [],
|
||||
});
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (body: FormData) => user_api.add_service(body),
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ['my_services'] });
|
||||
router.back();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
if (err?.response?.data?.data?.description[0]) {
|
||||
Alert.alert(t('Xatolik yuz berdi'), err?.response?.data?.data?.description[0]);
|
||||
} else if (err?.response?.data?.data?.files[0]) {
|
||||
Alert.alert(t('Xatolik yuz berdi'), err?.response?.data?.data?.title[0]);
|
||||
} else if (err?.response?.data?.data?.title[0]) {
|
||||
Alert.alert(t('Xatolik yuz berdi'), err?.response?.data?.data?.title[0]);
|
||||
} else {
|
||||
Alert.alert(t('Xatolik yuz berdi'), err?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const updateForm = (key: string, value: any) =>
|
||||
setFormData((prev: any) => ({ ...prev, [key]: value }));
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === 1) {
|
||||
const valid = stepOneRef.current?.validate();
|
||||
if (!valid) return;
|
||||
setStep(2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step === 2) setStep(1);
|
||||
else router.back();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const valid = stepTwoRef.current?.validate();
|
||||
if (!valid) return;
|
||||
const form = new FormData();
|
||||
form.append('title', formData.title);
|
||||
form.append('description', formData.description);
|
||||
formData.media.forEach((file) => {
|
||||
form.append(`files`, {
|
||||
uri: file.uri,
|
||||
type: getMimeType(file.uri),
|
||||
name: file.uri.split('/').pop(),
|
||||
} as any);
|
||||
});
|
||||
formData.category.forEach((e) => {
|
||||
form.append(`category`, e.id);
|
||||
});
|
||||
|
||||
mutate(form);
|
||||
};
|
||||
|
||||
const removeMedia = (i: number) =>
|
||||
updateForm(
|
||||
'media',
|
||||
formData.media.filter((_: any, idx: number) => idx !== i)
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: isDark ? '#0f172a' : '#f8fafc' }}>
|
||||
<View style={[styles.header, { backgroundColor: isDark ? '#0f172a' : '#ffffff' }]}>
|
||||
<View
|
||||
style={{ flexDirection: 'row', gap: 10, alignContent: 'center', alignItems: 'center' }}
|
||||
>
|
||||
<Pressable onPress={handleBack}>
|
||||
<ArrowLeft color={isDark ? '#fff' : '#0f172a'} />
|
||||
</Pressable>
|
||||
<Text style={[styles.headerTitle, { color: isDark ? '#fff' : '#0f172a' }]}>
|
||||
{step === 1 ? t('Yangi xizmat (1/2)') : t('Yangi xizmat (2/2)')}
|
||||
</Text>
|
||||
</View>
|
||||
{step === 2 ? (
|
||||
<Pressable
|
||||
onPress={handleSave}
|
||||
style={({ pressed }) => [
|
||||
{
|
||||
opacity: pressed || isPending ? 0.6 : 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
]}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<ActivityIndicator size={'small'} />
|
||||
) : (
|
||||
<Text style={styles.saveButton}>{t('Saqlash')}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable onPress={handleNext}>
|
||||
<Text style={styles.saveButton}>{t('Keyingi')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
{step === 1 && (
|
||||
<StepOneServices
|
||||
ref={stepOneRef}
|
||||
formData={formData}
|
||||
updateForm={updateForm}
|
||||
removeMedia={removeMedia}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && <StepTwo ref={stepTwoRef} formData={formData} updateForm={updateForm} />}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
headerTitle: { fontSize: 18, fontWeight: '700' },
|
||||
saveButton: { color: '#3b82f6', fontSize: 16, fontWeight: '600' },
|
||||
container: { padding: 16, paddingBottom: 10 },
|
||||
});
|
||||
368
screens/profile/ui/AnnouncementsTab.tsx
Normal file
368
screens/profile/ui/AnnouncementsTab.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet';
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import { ResizeMode, Video } from 'expo-av';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { ArrowLeft, EyeIcon, Megaphone, Plus } from 'lucide-react-native';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
FlatList,
|
||||
Image,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { user_api } from '../lib/api';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
export function AnnouncementsTab() {
|
||||
const router = useRouter();
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const theme = {
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
cardBg: isDark ? '#1e293b' : '#ffffff',
|
||||
text: isDark ? '#ffffff' : '#0f172a',
|
||||
textSecondary: isDark ? '#94a3b8' : '#64748b',
|
||||
textTertiary: isDark ? '#fff6' : '#64748b',
|
||||
primary: '#3b82f6',
|
||||
sheetBg: isDark ? '#0f172a' : '#ffffff',
|
||||
indicator: isDark ? '#94a3b8' : '#cbd5e1',
|
||||
statusBadge: '#2563eb',
|
||||
typeColor: '#38bdf8',
|
||||
priceColor: '#10b981',
|
||||
error: '#ef4444',
|
||||
};
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [selectedAnnouncement, setSelectedAnnouncement] = useState<any>(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, fetchNextPage, hasNextPage, refetch } = useInfiniteQuery({
|
||||
queryKey: ['my_ads'],
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
const res = await user_api.my_ads({
|
||||
page: pageParam,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const d = res?.data?.data;
|
||||
return {
|
||||
results: d?.results ?? [],
|
||||
current_page: d?.current_page ?? 1,
|
||||
total_pages: d?.total_pages ?? 1,
|
||||
};
|
||||
},
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.current_page < lastPage.total_pages ? lastPage.current_page + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
});
|
||||
|
||||
const allAds = data?.pages.flatMap((p) => p.results) ?? [];
|
||||
|
||||
const {
|
||||
data: detail,
|
||||
isLoading: loadingDetail,
|
||||
isError: detailError,
|
||||
} = useQuery({
|
||||
queryKey: ['my_ads_id', selectedAnnouncement?.id],
|
||||
queryFn: () => user_api.my_ads_detail(selectedAnnouncement.id),
|
||||
select: (res) => res.data.data,
|
||||
enabled: !!selectedAnnouncement && sheetOpen,
|
||||
});
|
||||
|
||||
const openSheet = (item: any) => {
|
||||
setSelectedAnnouncement(item);
|
||||
setSheetOpen(true);
|
||||
requestAnimationFrame(() => bottomSheetRef.current?.present());
|
||||
};
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await refetch();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: any) => (
|
||||
<BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} opacity={0.4} />
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
case 'verified':
|
||||
return '#10b981';
|
||||
case 'pending':
|
||||
return '#f59e0b';
|
||||
case 'canceled':
|
||||
return '#ef4444';
|
||||
default:
|
||||
return '#94a3b8';
|
||||
}
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: 'Kutilmoqda',
|
||||
paid: "To'langan",
|
||||
verified: 'Tasdiqlangan',
|
||||
canceled: 'Bekor qilingan',
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number) => new Intl.NumberFormat('uz-UZ').format(amount) + " so'm";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<View style={styles.topHeader}>
|
||||
<Pressable onPress={() => router.push('/profile')}>
|
||||
<ArrowLeft color={theme.text} />
|
||||
</Pressable>
|
||||
<Text style={[styles.headerTitle, { color: theme.text }]}>{t("E'lonlar")}</Text>
|
||||
<Pressable onPress={() => router.push('/(dashboard)/create-announcements')}>
|
||||
<Plus color={theme.primary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ActivityIndicator size={'large'} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<View style={[styles.center, { backgroundColor: theme.background }]}>
|
||||
<Text style={[styles.error, { color: theme.error }]}>{t('Xatolik yuz berdi')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<View style={styles.topHeader}>
|
||||
<Pressable onPress={() => router.push('/profile')}>
|
||||
<ArrowLeft color={theme.text} />
|
||||
</Pressable>
|
||||
<Text style={[styles.headerTitle, { color: theme.text }]}>{t("E'lonlar")}</Text>
|
||||
<Pressable onPress={() => router.push('/(dashboard)/create-announcements')}>
|
||||
<Plus color={theme.primary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={allAds}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.primary} />
|
||||
}
|
||||
onEndReached={() => hasNextPage && fetchNextPage()}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable
|
||||
style={[styles.card, { backgroundColor: theme.cardBg }]}
|
||||
onPress={() => openSheet(item)}
|
||||
>
|
||||
{item.files?.[0]?.file && (
|
||||
<Image source={{ uri: item.files[0].file }} style={styles.cardImage} />
|
||||
)}
|
||||
|
||||
<View style={styles.cardHeader}>
|
||||
<Megaphone size={18} color={theme.primary} />
|
||||
<Text style={{ color: getStatusColor(item.status), fontWeight: '600' }}>
|
||||
{t(statusLabel[item.status])}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.title, { color: theme.text }]}>{item.title}</Text>
|
||||
|
||||
<Text numberOfLines={2} style={[styles.desc, { color: theme.textTertiary }]}>
|
||||
{item.description}
|
||||
</Text>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
<EyeIcon size={20} color={theme.textSecondary} />
|
||||
<Text style={[styles.metaText, { color: theme.textSecondary }]}>
|
||||
{item.total_view_count}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.date, { color: theme.textSecondary }]}>
|
||||
{new Date(item.created_at).toLocaleDateString('uz-UZ')}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
snapPoints={['70%', '95%']}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={{ backgroundColor: theme.sheetBg }}
|
||||
handleIndicatorStyle={{ backgroundColor: theme.indicator }}
|
||||
onDismiss={() => {
|
||||
setSheetOpen(false);
|
||||
setSelectedAnnouncement(null);
|
||||
}}
|
||||
>
|
||||
<BottomSheetScrollView contentContainerStyle={styles.sheet}>
|
||||
{loadingDetail && <ActivityIndicator size={'large'} />}
|
||||
{detailError && (
|
||||
<Text style={[styles.error, { color: theme.error }]}>{t('Xatolik yuz berdi')}</Text>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
<>
|
||||
{detail.files?.length > 0 && (
|
||||
<ScrollView horizontal pagingEnabled showsHorizontalScrollIndicator={false}>
|
||||
{detail.files.map((file: any) => {
|
||||
const isVideo = file.file.endsWith('.mp4');
|
||||
return (
|
||||
<View key={file.id} style={styles.mediaContainer}>
|
||||
{isVideo ? (
|
||||
<Video
|
||||
source={{ uri: file.file }}
|
||||
style={styles.media}
|
||||
resizeMode={ResizeMode.CONTAIN}
|
||||
useNativeControls
|
||||
/>
|
||||
) : (
|
||||
<Image source={{ uri: file.file }} style={styles.media} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<Text style={[styles.sheetTitle, { color: theme.text }]}>{detail.title}</Text>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<Ionicons name="eye-outline" size={16} color={theme.textSecondary} />
|
||||
<Text style={[styles.metaText, { color: theme.textSecondary }]}>
|
||||
{detail.total_view_count} {t("ko'rildi")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.statusBadge, { backgroundColor: theme.statusBadge }]}>
|
||||
<Text style={styles.statusText}>{statusLabel[detail.status]}</Text>
|
||||
</View>
|
||||
<View style={styles.infoRowColumn}>
|
||||
<Text style={[styles.label, { color: theme.textSecondary }]}>
|
||||
{t('Kategoriyalar')}:
|
||||
</Text>
|
||||
{detail.types?.map((type: any) => (
|
||||
<Text key={type.id} style={[styles.typeItem, { color: theme.typeColor }]}>
|
||||
{type.name}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={[styles.label, { color: theme.textSecondary }]}>
|
||||
{t('Tanlangan kompaniyalar')}:
|
||||
</Text>
|
||||
<Text style={[styles.value, { color: theme.text }]}>
|
||||
{detail.letters?.join(', ')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={[styles.label, { color: theme.textSecondary }]}>{t('Narxi')}:</Text>
|
||||
<Text style={[styles.price, { color: theme.priceColor }]}>
|
||||
{formatAmount(detail.total_price)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={[styles.label, { color: theme.textSecondary }]}>{t('Tavsif')}:</Text>
|
||||
<Text style={[styles.desc, { color: theme.textTertiary }]}>
|
||||
{detail.description}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
|
||||
topHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerTitle: { fontSize: 18, fontWeight: '700' },
|
||||
|
||||
list: { padding: 16, gap: 12 },
|
||||
card: { borderRadius: 16, padding: 16, gap: 8 },
|
||||
cardImage: { width: '100%', height: 160, borderRadius: 12 },
|
||||
|
||||
cardHeader: { flexDirection: 'row', justifyContent: 'space-between' },
|
||||
title: { fontSize: 16, fontWeight: '700' },
|
||||
desc: { lineHeight: 20 },
|
||||
|
||||
footer: { flexDirection: 'row', justifyContent: 'space-between' },
|
||||
metaText: {},
|
||||
date: {},
|
||||
|
||||
sheet: { padding: 20, gap: 12 },
|
||||
sheetTitle: { fontSize: 18, fontWeight: '700' },
|
||||
|
||||
mediaContainer: { width: width - 40, height: 200, marginRight: 12 },
|
||||
media: { width: '100%', height: '100%', borderRadius: 12 },
|
||||
|
||||
metaRow: { flexDirection: 'row', gap: 6, alignItems: 'center' },
|
||||
|
||||
statusBadge: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
},
|
||||
statusText: { color: '#fff', fontWeight: '600' },
|
||||
|
||||
infoRow: { flexDirection: 'column', gap: 6 },
|
||||
infoRowColumn: {
|
||||
marginTop: 8,
|
||||
gap: 4,
|
||||
},
|
||||
|
||||
typeItem: {
|
||||
fontSize: 14,
|
||||
},
|
||||
|
||||
label: {},
|
||||
value: { flex: 1 },
|
||||
price: { fontWeight: '700' },
|
||||
|
||||
loading: {},
|
||||
error: {},
|
||||
});
|
||||
281
screens/profile/ui/BonusesScreen.tsx
Normal file
281
screens/profile/ui/BonusesScreen.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import { useGlobalRefresh } from '@/components/ui/RefreshContext';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { router } from 'expo-router';
|
||||
import { ArrowLeft, Award, Percent } from 'lucide-react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { RefreshControl } from 'react-native-gesture-handler';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { user_api } from '../lib/api';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export default function BonusesScreen() {
|
||||
const { onRefresh, refreshing } = useGlobalRefresh();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const formatAmount = (amount: number) => {
|
||||
return new Intl.NumberFormat('uz-UZ').format(amount) + " so'm";
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: ['my_bonuses'],
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
const res = await user_api.my_bonuses({
|
||||
page: pageParam,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const d = res?.data?.data;
|
||||
return {
|
||||
results: d?.results ?? [],
|
||||
current_page: d?.current_page ?? 1,
|
||||
total_pages: d?.total_pages ?? 1,
|
||||
};
|
||||
},
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.current_page < lastPage.total_pages ? lastPage.current_page + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
});
|
||||
|
||||
const allBonuses = data?.pages.flatMap((p) => p.results) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={[styles.center, { backgroundColor: isDark ? '#0f172a' : '#f8fafc' }]}>
|
||||
<ActivityIndicator size={'large'} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<View style={[styles.center, { backgroundColor: isDark ? '#0f172a' : '#f8fafc' }]}>
|
||||
<Text style={styles.error}>{t('Xatolik yuz berdi')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: isDark ? '#0f172a' : '#f8fafc' }]}>
|
||||
<View style={[styles.topHeader, { backgroundColor: isDark ? '#0f172a' : '#ffffff' }]}>
|
||||
<Pressable onPress={() => router.push('/profile')}>
|
||||
<ArrowLeft color={isDark ? '#fff' : '#0f172a'} />
|
||||
</Pressable>
|
||||
<Text style={[styles.headerTitle, { color: isDark ? '#fff' : '#0f172a' }]}>
|
||||
{t('Bonuslar')}
|
||||
</Text>
|
||||
</View>
|
||||
<FlatList
|
||||
data={allBonuses}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
onEndReached={() => hasNextPage && fetchNextPage()}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={['#2563eb']}
|
||||
tintColor="#2563eb"
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
<View
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
backgroundColor: isDark ? '#1e293b' : '#ffffff',
|
||||
borderColor: isDark ? '#fbbf2420' : '#e2e8f0',
|
||||
shadowColor: isDark ? '#000' : '#0f172a',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: isDark ? 0.3 : 0.08,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View
|
||||
style={[
|
||||
styles.iconContainer,
|
||||
{ backgroundColor: isDark ? '#3b82f614' : '#dbeafe' },
|
||||
]}
|
||||
>
|
||||
<Award size={28} color="#3b82f6" />
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.percentageBadge,
|
||||
{ backgroundColor: isDark ? '#10b98120' : '#d1fae5' },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.percentageText, { color: isDark ? '#10b981' : '#059669' }]}>
|
||||
{item.percent}
|
||||
</Text>
|
||||
<Percent size={16} color={isDark ? '#10b981' : '#059669'} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.title, { color: isDark ? '#f8fafc' : '#0f172a' }]}>
|
||||
{item.ad.title}
|
||||
</Text>
|
||||
<Text style={[styles.description, { color: isDark ? '#94a3b8' : '#64748b' }]}>
|
||||
{item.ad.description}
|
||||
</Text>
|
||||
|
||||
<View style={[styles.footer, { borderTopColor: isDark ? '#334155' : '#e2e8f0' }]}>
|
||||
<View style={styles.amountContainer}>
|
||||
<Text style={[styles.amountLabel, { color: isDark ? '#64748b' : '#94a3b8' }]}>
|
||||
{t('Bonus miqdori')}
|
||||
</Text>
|
||||
<Text style={styles.amount}>{formatAmount(item.amount)}</Text>
|
||||
</View>
|
||||
<View style={styles.dateContainer}>
|
||||
<Text style={[styles.dateLabel, { color: isDark ? '#64748b' : '#94a3b8' }]}>
|
||||
{t('Yaratilgan sana')}
|
||||
</Text>
|
||||
<Text style={[styles.date, { color: isDark ? '#94a3b8' : '#64748b' }]}>
|
||||
{new Date(item.created_at).toLocaleDateString('uz-UZ')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Award size={64} color={isDark ? '#334155' : '#cbd5e1'} />
|
||||
<Text style={[styles.emptyText, { color: isDark ? '#64748b' : '#94a3b8' }]}>
|
||||
{t("Hozircha bonuslar yo'q")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
gap: 16,
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
percentageBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 20,
|
||||
},
|
||||
percentageText: {
|
||||
fontSize: 17,
|
||||
fontWeight: '700' as const,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700' as const,
|
||||
lineHeight: 26,
|
||||
},
|
||||
description: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
amountContainer: {
|
||||
gap: 6,
|
||||
},
|
||||
amountLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500' as const,
|
||||
},
|
||||
amount: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700' as const,
|
||||
color: '#3b82f6',
|
||||
},
|
||||
dateContainer: {
|
||||
gap: 6,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
dateLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500' as const,
|
||||
},
|
||||
date: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 80,
|
||||
gap: 16,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
topHeader: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
flex: 1,
|
||||
},
|
||||
themeToggle: {
|
||||
padding: 8,
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loading: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
error: {
|
||||
color: '#ef4444',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
164
screens/profile/ui/BonusesTab.tsx
Normal file
164
screens/profile/ui/BonusesTab.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Award, Percent } from 'lucide-react-native';
|
||||
import { FlatList, StyleSheet, Text, View } from 'react-native';
|
||||
import { useProfileData } from '../lib/ProfileDataContext';
|
||||
|
||||
export function BonusesTab() {
|
||||
const { bonuses } = useProfileData();
|
||||
|
||||
const formatAmount = (amount: number) => {
|
||||
return new Intl.NumberFormat('uz-UZ').format(amount) + " so'm";
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={bonuses}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Award size={24} color="#fbbf24" />
|
||||
</View>
|
||||
<View style={styles.percentageBadge}>
|
||||
<Percent size={14} color="#10b981" />
|
||||
<Text style={styles.percentageText}>{item.percentage}%</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>{item.title}</Text>
|
||||
<Text style={styles.description}>{item.description}</Text>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.amountContainer}>
|
||||
<Text style={styles.amountLabel}>Bonus miqdori</Text>
|
||||
<Text style={styles.amount}>{formatAmount(item.bonusAmount)}</Text>
|
||||
</View>
|
||||
<View style={styles.dateContainer}>
|
||||
<Text style={styles.dateLabel}>Yaratilgan sana</Text>
|
||||
<Text style={styles.date}>
|
||||
{new Date(item.createdAt).toLocaleDateString('uz-UZ')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Award size={48} color="#475569" />
|
||||
<Text style={styles.emptyText}>Hozircha bonuslar yo'q</Text>
|
||||
<Text style={styles.emptySubtext}>Faoliyat ko'rsating va bonuslar qo'lga kiriting</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#1e293b',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
gap: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#fbbf2420',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: '#fbbf2420',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
percentageBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#10b98120',
|
||||
},
|
||||
percentageText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700' as const,
|
||||
color: '#10b981',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700' as const,
|
||||
color: '#f8fafc',
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
color: '#94a3b8',
|
||||
lineHeight: 20,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: '#334155',
|
||||
marginVertical: 4,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
amountContainer: {
|
||||
gap: 6,
|
||||
},
|
||||
amountLabel: {
|
||||
fontSize: 13,
|
||||
color: '#64748b',
|
||||
fontWeight: '500' as const,
|
||||
},
|
||||
amount: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700' as const,
|
||||
color: '#fbbf24',
|
||||
},
|
||||
dateContainer: {
|
||||
gap: 6,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
dateLabel: {
|
||||
fontSize: 13,
|
||||
color: '#64748b',
|
||||
fontWeight: '500' as const,
|
||||
},
|
||||
date: {
|
||||
fontSize: 14,
|
||||
color: '#94a3b8',
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 80,
|
||||
gap: 12,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600' as const,
|
||||
color: '#64748b',
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: '#475569',
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
});
|
||||
200
screens/profile/ui/EditServices.tsx
Normal file
200
screens/profile/ui/EditServices.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import StepTwo from '@/screens/create-ads/ui/StepTwo';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { ArrowLeft, Loader } from 'lucide-react-native';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { user_api } from '../lib/api';
|
||||
import StepOneServices from './StepOneService';
|
||||
|
||||
type MediaFile = { id?: number; uri: string; type: 'image' | 'video'; name?: string };
|
||||
|
||||
interface formDataType {
|
||||
title: string;
|
||||
description: string;
|
||||
media: MediaFile[];
|
||||
category: any[];
|
||||
}
|
||||
|
||||
const getMimeType = (uri: string) => {
|
||||
const ext = uri.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
case 'heic':
|
||||
return 'image/heic';
|
||||
case 'mp4':
|
||||
return 'video/mp4';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
};
|
||||
|
||||
export default function EditService() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { isDark } = useTheme();
|
||||
const stepOneRef = useRef<any>(null);
|
||||
const stepTwoRef = useRef<any>(null);
|
||||
const [step, setStep] = useState(1);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [formData, setFormData] = useState<formDataType>({
|
||||
title: '',
|
||||
description: '',
|
||||
media: [],
|
||||
category: [],
|
||||
});
|
||||
|
||||
const [removedFileIds, setRemovedFileIds] = useState<number[]>([]);
|
||||
|
||||
const updateForm = (key: string, value: any) =>
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const { data: product, isLoading } = useQuery({
|
||||
queryKey: ['service_detail', id],
|
||||
queryFn: () => user_api.detail_service(Number(id)),
|
||||
select(data) {
|
||||
return data.data.data;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
setFormData({
|
||||
title: product.title,
|
||||
description: product.description,
|
||||
media: product.files.map((f: any) => ({
|
||||
id: f.id,
|
||||
uri: f.file,
|
||||
type: f.type,
|
||||
name: f.name || '',
|
||||
})),
|
||||
category: product.category,
|
||||
});
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (body: FormData) => user_api.update_service({ body, id: Number(id) }),
|
||||
onSuccess: (res) => {
|
||||
console.log(res);
|
||||
queryClient.invalidateQueries({ queryKey: ['my_services'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['service_detail'] });
|
||||
router.back();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
console.log(err);
|
||||
Alert.alert(t('Xatolik yuz berdi'), err?.message || t('Yangilashda xato yuz berdi'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === 1) {
|
||||
const valid = stepOneRef.current?.validate();
|
||||
if (!valid) return;
|
||||
setStep(2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step === 2) setStep(1);
|
||||
else router.back();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const valid = stepTwoRef.current?.validate();
|
||||
if (!valid) return;
|
||||
|
||||
const form = new FormData();
|
||||
form.append('title', formData.title);
|
||||
form.append('description', formData.description);
|
||||
|
||||
formData.media.forEach((file, i) => {
|
||||
if (!file.id) {
|
||||
form.append(`files`, {
|
||||
uri: file.uri,
|
||||
type: getMimeType(file.uri),
|
||||
name: file.uri.split('/').pop(),
|
||||
} as any);
|
||||
}
|
||||
});
|
||||
|
||||
removedFileIds.forEach((id) => form.append(`delete_files`, id.toString()));
|
||||
formData.category.forEach((cat) => form.append(`category`, cat.id));
|
||||
|
||||
mutate(form);
|
||||
};
|
||||
|
||||
const removeMedia = (index: number) => {
|
||||
const file = formData.media[index];
|
||||
if (file.id) setRemovedFileIds((prev) => [...prev, file.id!]);
|
||||
updateForm(
|
||||
'media',
|
||||
formData.media.filter((_, i) => i !== index)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: isDark ? '#0f172a' : '#f8fafc' }}>
|
||||
<View style={[styles.header, { backgroundColor: isDark ? '#0f172a' : '#ffffff' }]}>
|
||||
<View style={{ flexDirection: 'row', gap: 10, alignItems: 'center' }}>
|
||||
<Pressable onPress={handleBack}>
|
||||
<ArrowLeft color={isDark ? '#fff' : '#0f172a'} />
|
||||
</Pressable>
|
||||
<Text style={[styles.headerTitle, { color: isDark ? '#fff' : '#0f172a' }]}>
|
||||
{step === 1 ? t('Xizmatni tahrirlash (1/2)') : t('Xizmatni tahrirlash (2/2)')}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={step === 2 ? handleSave : handleNext}
|
||||
style={({ pressed }) => ({ opacity: pressed || isPending ? 0.6 : 1 })}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader color="#3b82f6" size={20} />
|
||||
) : (
|
||||
<Text style={styles.saveButton}>{step === 2 ? t('Saqlash') : t('Keyingi')}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
{step === 1 && (
|
||||
<StepOneServices
|
||||
ref={stepOneRef}
|
||||
formData={formData}
|
||||
updateForm={updateForm}
|
||||
removeMedia={removeMedia}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && <StepTwo ref={stepTwoRef} formData={formData} updateForm={updateForm} />}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
headerTitle: { fontSize: 18, fontWeight: '700' },
|
||||
saveButton: { color: '#3b82f6', fontSize: 16, fontWeight: '600' },
|
||||
container: { padding: 16, paddingBottom: 10 },
|
||||
});
|
||||
199
screens/profile/ui/EmployeesTab.tsx
Normal file
199
screens/profile/ui/EmployeesTab.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import { formatNumber } from '@/constants/formatPhone';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { ArrowLeft, Plus, User } from 'lucide-react-native';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { user_api } from '../lib/api';
|
||||
import { ExployeesDataResponse } from '../lib/type';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function EmployeesTab() {
|
||||
const router = useRouter();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const theme = {
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
cardBg: isDark ? '#1e293b' : '#ffffff',
|
||||
text: isDark ? '#f8fafc' : '#0f172a',
|
||||
textSecondary: isDark ? '#94a3b8' : '#64748b',
|
||||
iconBg: isDark ? '#1e40af15' : '#dbeafe',
|
||||
primary: '#3b82f6',
|
||||
emptyIcon: isDark ? '#334155' : '#cbd5e1',
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ['employees_list'],
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
const response = await user_api.employess({
|
||||
page: pageParam,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage && lastPage.current_page && lastPage.total_pages
|
||||
? lastPage.current_page < lastPage.total_pages
|
||||
? lastPage.current_page + 1
|
||||
: undefined
|
||||
: undefined,
|
||||
initialPageParam: 1,
|
||||
});
|
||||
|
||||
const allEmployees = data?.pages.flatMap((p) => p.results) ?? [];
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: ExployeesDataResponse }) => (
|
||||
<View style={[styles.card, { backgroundColor: theme.cardBg }]}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: theme.iconBg }]}>
|
||||
<User size={24} color={theme.primary} />
|
||||
</View>
|
||||
<View style={styles.infoContainer}>
|
||||
<Text style={[styles.name, { color: theme.text }]}>
|
||||
{item.first_name} {item.last_name}
|
||||
</Text>
|
||||
<Text style={[styles.phone, { color: theme.textSecondary }]}>
|
||||
{formatNumber(item.phone)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
),
|
||||
[theme]
|
||||
);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await refetch();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<View style={styles.topHeader}>
|
||||
<Pressable onPress={() => router.push('/profile')}>
|
||||
<ArrowLeft color={theme.text} />
|
||||
</Pressable>
|
||||
<Text style={[styles.headerTitle, { color: theme.text }]}>{t('Xodimlar')}</Text>
|
||||
<Pressable onPress={() => router.push('/profile/employees/add')}>
|
||||
<Plus color={theme.primary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ActivityIndicator size={'large'} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<Text style={{ color: 'red', marginTop: 50, textAlign: 'center' }}>
|
||||
{t('Xatolik yuz berdi')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<View style={styles.topHeader}>
|
||||
<Pressable onPress={() => router.push('/profile')}>
|
||||
<ArrowLeft color={theme.text} />
|
||||
</Pressable>
|
||||
<Text style={[styles.headerTitle, { color: theme.text }]}>{t('Xodimlar')}</Text>
|
||||
<Pressable onPress={() => router.push('/profile/employees/add')}>
|
||||
<Plus color={theme.primary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={allEmployees}
|
||||
keyExtractor={(item) => item.phone}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.list}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.primary} />
|
||||
}
|
||||
ListFooterComponent={isFetchingNextPage ? <ActivityIndicator size={'large'} /> : null}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<User size={64} color={theme.emptyIcon} />
|
||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
|
||||
{t("Hozircha xodimlar yo'q")}
|
||||
</Text>
|
||||
<Pressable
|
||||
style={[styles.emptyButton, { backgroundColor: theme.primary }]}
|
||||
onPress={() => router.push('/profile/employees/add')}
|
||||
>
|
||||
<Plus size={20} color="#fff" />
|
||||
<Text style={styles.emptyButtonText}>{t("Xodim qo'shish")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
addButton: { padding: 8 },
|
||||
list: { padding: 16, gap: 12 },
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
infoContainer: { flex: 1, gap: 4 },
|
||||
name: { fontSize: 17, fontWeight: '700' },
|
||||
phone: { fontSize: 15, fontWeight: '500' },
|
||||
emptyContainer: { alignItems: 'center', justifyContent: 'center', paddingVertical: 80, gap: 16 },
|
||||
emptyText: { fontSize: 17, fontWeight: '600' },
|
||||
emptyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
emptyButtonText: { fontSize: 16, fontWeight: '600', color: '#fff' },
|
||||
topHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerTitle: { fontSize: 18, fontWeight: '700' },
|
||||
});
|
||||
302
screens/profile/ui/MyServices.tsx
Normal file
302
screens/profile/ui/MyServices.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import { useGlobalRefresh } from '@/components/ui/RefreshContext';
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { ArrowLeft, Edit2, Package, Plus, Trash2 } from 'lucide-react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { RefreshControl } from 'react-native-gesture-handler';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { user_api } from '../lib/api';
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
export default function MyServicesScreen() {
|
||||
const router = useRouter();
|
||||
const { onRefresh, refreshing } = useGlobalRefresh();
|
||||
const queryClient = useQueryClient();
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
/* ================= QUERY ================= */
|
||||
const { data, isLoading, isError, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: ['my_services'],
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
const res = await user_api.my_sevices({
|
||||
page: pageParam,
|
||||
my: true,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const d = res?.data?.data;
|
||||
return {
|
||||
results: d?.results ?? [],
|
||||
current_page: d?.current_page ?? 1,
|
||||
total_pages: d?.total_pages ?? 1,
|
||||
};
|
||||
},
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.current_page < lastPage.total_pages ? lastPage.current_page + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
});
|
||||
|
||||
const allServices = data?.pages.flatMap((p) => p.results) ?? [];
|
||||
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: (id: number) => user_api.delete_service(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['my_services'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
Alert.alert(t("Xizmatni o'chirish"), t("Rostdan ham bu xizmatni o'chirmoqchimisiz?"), [
|
||||
{ text: t('Bekor qilish'), style: 'cancel' },
|
||||
{
|
||||
text: t("O'chirish"),
|
||||
style: 'destructive',
|
||||
onPress: () => mutate(id),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: isDark ? '#0f172a' : '#f8fafc' }]}>
|
||||
<View style={[styles.topHeader, { backgroundColor: isDark ? '#0f172a' : '#ffffff' }]}>
|
||||
<Pressable onPress={() => router.push('/profile')}>
|
||||
<ArrowLeft color={isDark ? '#fff' : '#0f172a'} />
|
||||
</Pressable>
|
||||
<Text style={[styles.headerTitle, { color: isDark ? '#fff' : '#0f172a' }]}>
|
||||
{t('Xizmatlar')}
|
||||
</Text>
|
||||
<Pressable onPress={() => router.push('/profile/products/add')}>
|
||||
<Plus color="#3b82f6" />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ActivityIndicator size={'large'} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<View style={[styles.center, { backgroundColor: isDark ? '#0f172a' : '#f8fafc' }]}>
|
||||
<Text style={styles.error}>{t('Xatolik yuz berdi')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: isDark ? '#0f172a' : '#f8fafc' }]}>
|
||||
{/* HEADER */}
|
||||
<View style={[styles.topHeader, { backgroundColor: isDark ? '#0f172a' : '#ffffff' }]}>
|
||||
<Pressable onPress={() => router.push('/profile')}>
|
||||
<ArrowLeft color={isDark ? '#fff' : '#0f172a'} />
|
||||
</Pressable>
|
||||
<Text style={[styles.headerTitle, { color: isDark ? '#fff' : '#0f172a' }]}>
|
||||
{t('Xizmatlar')}
|
||||
</Text>
|
||||
<Pressable onPress={() => router.push('/profile/products/add')}>
|
||||
<Plus color="#3b82f6" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* LIST */}
|
||||
<FlatList
|
||||
data={allServices}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
contentContainerStyle={styles.list}
|
||||
onEndReached={() => hasNextPage && fetchNextPage()}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={['#2563eb']}
|
||||
tintColor="#2563eb"
|
||||
/>
|
||||
}
|
||||
renderItem={({ item }) => {
|
||||
const fileUrl = item.files?.length > 0 ? item.files[0]?.file : null;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
backgroundColor: isDark ? '#1e293b' : '#ffffff',
|
||||
shadowColor: isDark ? '#000' : '#0f172a',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: isDark ? 0.3 : 0.08,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
]}
|
||||
onPress={() =>
|
||||
router.push({ pathname: `/profile/products/edit/[id]`, params: { id: item.id } })
|
||||
}
|
||||
>
|
||||
{/* MEDIA */}
|
||||
<View
|
||||
style={[styles.mediaContainer, { backgroundColor: isDark ? '#334155' : '#e2e8f0' }]}
|
||||
>
|
||||
{fileUrl ? (
|
||||
<ExpoImage
|
||||
source={{ uri: fileUrl }}
|
||||
style={styles.media}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.videoPlaceholder}>
|
||||
<Text style={{ color: isDark ? '#64748b' : '#94a3b8' }}>{t("Media yo'q")}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* CONTENT */}
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={styles.actionButton}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push({
|
||||
pathname: `/profile/products/edit/[id]`,
|
||||
params: { id: item.id },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Edit2 size={18} color="#3b82f6" />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.actionButton}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(item.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={18} color="#ef4444" />
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.contentContainer}>
|
||||
<Text style={[styles.title, { color: isDark ? '#f8fafc' : '#0f172a' }]}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.description, { color: isDark ? '#94a3b8' : '#64748b' }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.description}
|
||||
</Text>
|
||||
|
||||
<View style={styles.categoriesContainer}>
|
||||
{item.category.map((category, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.categoryChip,
|
||||
{ backgroundColor: isDark ? '#334155' : '#e2e8f0' },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.categoryText, { color: isDark ? '#94a3b8' : '#64748b' }]}
|
||||
>
|
||||
{category.name}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Package size={64} color={isDark ? '#334155' : '#cbd5e1'} />
|
||||
<Text style={[styles.emptyText, { color: isDark ? '#64748b' : '#94a3b8' }]}>
|
||||
{t("Hozircha xizmatlar yo'q")}
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.emptyButton}
|
||||
onPress={() => router.push('/profile/products/add')}
|
||||
>
|
||||
<Plus size={20} color="#ffffff" />
|
||||
<Text style={styles.emptyButtonText}>{t("Xizmat qo'shish")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================= STYLES ================= */
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
topHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
headerTitle: { fontSize: 18, fontWeight: '700', flex: 1, marginLeft: 10 },
|
||||
list: { padding: 16, gap: 16 },
|
||||
card: { borderRadius: 20, overflow: 'hidden' },
|
||||
mediaContainer: { width: '100%', height: 200 },
|
||||
media: { width: '100%', height: '100%' },
|
||||
videoPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
contentContainer: { padding: 20, gap: 12 },
|
||||
title: { fontSize: 18, fontWeight: '700' as const },
|
||||
description: { fontSize: 15, lineHeight: 22 },
|
||||
categoriesContainer: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
categoryChip: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
},
|
||||
categoryText: { fontSize: 13, fontWeight: '500' as const },
|
||||
emptyContainer: { alignItems: 'center', justifyContent: 'center', paddingVertical: 80, gap: 16 },
|
||||
emptyText: { fontSize: 17, fontWeight: '600' as const },
|
||||
emptyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
backgroundColor: '#3b82f6',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
emptyButtonText: { fontSize: 16, fontWeight: '600' as const, color: '#ffffff' },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
loading: { fontSize: 16, fontWeight: '500' },
|
||||
error: { color: '#ef4444', fontSize: 16, fontWeight: '500' },
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
position: 'absolute',
|
||||
right: 5,
|
||||
top: 5,
|
||||
gap: 8,
|
||||
},
|
||||
actionButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#ffffff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
460
screens/profile/ui/PersonalInfoTab.tsx
Normal file
460
screens/profile/ui/PersonalInfoTab.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
import { formatPhone, normalizeDigits } from '@/constants/formatPhone';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Edit2, Plus } from 'lucide-react-native';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Modal, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { user_api } from '../lib/api';
|
||||
import { useProfileData } from '../lib/ProfileDataContext';
|
||||
import { UserInfoResponseData } from '../lib/type';
|
||||
|
||||
export function PersonalInfoTab() {
|
||||
const { personalInfo, updatePersonalInfo } = useProfileData();
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [addFieldModalVisible, setAddFieldModalVisible] = useState(false);
|
||||
const [newField, setNewField] = useState('');
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [editData, setEditData] = useState<UserInfoResponseData | undefined>(undefined);
|
||||
const [phone, setPhone] = useState('');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// GET ME
|
||||
const {
|
||||
data: me,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ['get_me'],
|
||||
queryFn: () => user_api.getMe(),
|
||||
select: (res) => {
|
||||
setEditData(res.data.data);
|
||||
setPhone(res.data.data.phone || '');
|
||||
return res;
|
||||
},
|
||||
});
|
||||
|
||||
// UPDATE ME mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (body: {
|
||||
first_name: string;
|
||||
industries: {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
external_id: null | number;
|
||||
level: number;
|
||||
is_leaf: boolean;
|
||||
icon_name: null | string;
|
||||
parent: {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
};
|
||||
}[];
|
||||
phone: string;
|
||||
person_type: 'employee' | 'legal_entity' | 'ytt' | 'band';
|
||||
}) => user_api.updateMe(body),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['get_me'] });
|
||||
setEditModalVisible(false);
|
||||
Alert.alert('Muvaffaqiyat', 'Ma’lumotlar yangilandi');
|
||||
},
|
||||
onError: () => {
|
||||
Alert.alert('Xatolik', 'Ma’lumotlarni yangilashda xatolik yuz berdi');
|
||||
},
|
||||
});
|
||||
|
||||
// const handleSave = () => {
|
||||
// if (!editData) return;
|
||||
|
||||
// // Backendga yuboriladigan data
|
||||
// const payload: {
|
||||
// first_name: string;
|
||||
// industries: {
|
||||
// id: number;
|
||||
// name: string;
|
||||
// code: string;
|
||||
// external_id: null | number;
|
||||
// level: number;
|
||||
// is_leaf: boolean;
|
||||
// icon_name: null | string;
|
||||
// parent: {
|
||||
// id: number;
|
||||
// name: string;
|
||||
// code: string;
|
||||
// };
|
||||
// }[];
|
||||
// person_type: 'employee' | 'legal_entity' | 'ytt' | 'band';
|
||||
// phone: string;
|
||||
// } = {
|
||||
// first_name: editData.director_full_name,
|
||||
// phone: normalizeDigits(phone),
|
||||
// industries:
|
||||
// };
|
||||
|
||||
// updateMutation.mutate();
|
||||
// };
|
||||
|
||||
const handlePhone = (text: string) => {
|
||||
const n = normalizeDigits(text);
|
||||
setPhone(n);
|
||||
};
|
||||
|
||||
const handleAddField = () => {
|
||||
if (newField.trim()) {
|
||||
updatePersonalInfo({
|
||||
activityFields: [...personalInfo.activityFields, newField.trim()],
|
||||
});
|
||||
setNewField('');
|
||||
setAddFieldModalVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveField = (field: string) => {
|
||||
Alert.alert('Soha olib tashlash', `"${field}" sohasini olib tashlashni xohlaysizmi?`, [
|
||||
{ text: 'Bekor qilish', style: 'cancel' },
|
||||
{
|
||||
text: 'Olib tashlash',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
updatePersonalInfo({
|
||||
activityFields: personalInfo.activityFields.filter((f) => f !== field),
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (me?.data.data) {
|
||||
setEditData(me.data.data);
|
||||
setPhone(me.data.data.phone || '');
|
||||
}
|
||||
}, [me]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Personal Info Card */}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle}>Shaxsiy ma'lumotlar</Text>
|
||||
<Pressable onPress={() => setEditModalVisible(true)} style={styles.editButton}>
|
||||
<Edit2 size={18} color="#3b82f6" />
|
||||
<Text style={styles.editButtonText}>Tahrirlash</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.label}>Ism</Text>
|
||||
<Text style={styles.value}>{me?.data.data.director_full_name}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.label}>Telefon raqam</Text>
|
||||
<Text style={styles.value}>+{me?.data.data.phone}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.label}>Foydalanuvchi turi</Text>
|
||||
<Text style={styles.value}>
|
||||
{me?.data.data.person_type === 'employee'
|
||||
? 'Xodim'
|
||||
: me?.data.data.person_type === 'legal_entity'
|
||||
? 'Yuridik shaxs'
|
||||
: me?.data.data.person_type === 'ytt'
|
||||
? 'Yakka tartibdagi tadbirkor'
|
||||
: "O'zini o'zi band qilgan shaxs"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Activity Fields Card */}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle}>Faoliyat sohalari</Text>
|
||||
<Pressable onPress={() => setAddFieldModalVisible(true)} style={styles.addButton}>
|
||||
<Plus size={18} color="#10b981" />
|
||||
<Text style={styles.addButtonText}>Qo'shish</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.fieldsContainer}>
|
||||
{me?.data.data.activate_types.map((field) => (
|
||||
<View key={field.id} style={styles.fieldChip}>
|
||||
<Text style={styles.fieldText}>{field.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal
|
||||
visible={editModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setEditModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<Text style={styles.modalTitle}>Ma'lumotlarni tahrirlash</Text>
|
||||
|
||||
<Text style={styles.inputLabel}>Ism</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={editData?.director_full_name}
|
||||
onChangeText={(text) =>
|
||||
setEditData((prev) => prev && { ...prev, director_full_name: text })
|
||||
}
|
||||
placeholderTextColor="#64748b"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Telefon</Text>
|
||||
<View style={styles.inputBox}>
|
||||
<View style={styles.prefixContainer}>
|
||||
<Text style={[styles.prefix, focused && styles.prefixFocused]}>+998</Text>
|
||||
<View style={styles.divider} />
|
||||
</View>
|
||||
<TextInput
|
||||
style={{ ...styles.input, borderWidth: 0 }}
|
||||
value={formatPhone(phone)}
|
||||
onChangeText={handlePhone}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
keyboardType="phone-pad"
|
||||
placeholder="90 123 45 67"
|
||||
maxLength={12}
|
||||
placeholderTextColor="#94a3b8"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.modalButtons}>
|
||||
<Pressable
|
||||
onPress={() => setEditModalVisible(false)}
|
||||
style={[styles.modalButton, styles.cancelButton]}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Bekor qilish</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.modalButton, styles.saveButton]}>
|
||||
<Text style={styles.saveButtonText}>Saqlash</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* Add Field Modal */}
|
||||
<Modal
|
||||
visible={addFieldModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setAddFieldModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<Text style={styles.modalTitle}>Faoliyat sohasini qo'shish</Text>
|
||||
|
||||
<Text style={styles.inputLabel}>Soha nomi</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={newField}
|
||||
onChangeText={setNewField}
|
||||
placeholder="Masalan: IT"
|
||||
placeholderTextColor="#64748b"
|
||||
/>
|
||||
|
||||
<View style={styles.modalButtons}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setNewField('');
|
||||
setAddFieldModalVisible(false);
|
||||
}}
|
||||
style={[styles.modalButton, styles.cancelButton]}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Bekor qilish</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={handleAddField} style={[styles.modalButton, styles.saveButton]}>
|
||||
<Text style={styles.saveButtonText}>Qo'shish</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
divider: {
|
||||
width: 1.5,
|
||||
height: 24,
|
||||
backgroundColor: '#fff',
|
||||
marginLeft: 12,
|
||||
},
|
||||
prefix: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
prefixFocused: {
|
||||
color: '#fff',
|
||||
},
|
||||
prefixContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
inputBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#1e293b',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#334155',
|
||||
paddingHorizontal: 16,
|
||||
height: 56,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#1e293b',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
gap: 16,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700' as const,
|
||||
color: '#f8fafc',
|
||||
},
|
||||
editButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#1e40af20',
|
||||
},
|
||||
editButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600' as const,
|
||||
color: '#3b82f6',
|
||||
},
|
||||
addButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#10b98120',
|
||||
},
|
||||
addButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600' as const,
|
||||
color: '#10b981',
|
||||
},
|
||||
infoRow: {
|
||||
gap: 6,
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
color: '#94a3b8',
|
||||
fontWeight: '500' as const,
|
||||
},
|
||||
value: {
|
||||
fontSize: 16,
|
||||
color: '#f8fafc',
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
fieldsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
fieldChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#334155',
|
||||
},
|
||||
fieldText: {
|
||||
fontSize: 14,
|
||||
color: '#f8fafc',
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: '#00000090',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
modalContent: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
backgroundColor: '#1e293b',
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
gap: 16,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700' as const,
|
||||
color: '#f8fafc',
|
||||
marginBottom: 8,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600' as const,
|
||||
color: '#94a3b8',
|
||||
marginBottom: -8,
|
||||
},
|
||||
input: {
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
fontSize: 16,
|
||||
color: '#f8fafc',
|
||||
borderWidth: 1,
|
||||
borderColor: '#334155',
|
||||
},
|
||||
modalButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
modalButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: '#334155',
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: '#3b82f6',
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600' as const,
|
||||
color: '#f8fafc',
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600' as const,
|
||||
color: '#ffffff',
|
||||
},
|
||||
});
|
||||
550
screens/profile/ui/ProductServicesTab.tsx
Normal file
550
screens/profile/ui/ProductServicesTab.tsx
Normal file
@@ -0,0 +1,550 @@
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { FileVideo, Image as ImageIcon, Plus } from 'lucide-react-native';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
FlatList,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useProfileData } from '../lib/ProfileDataContext';
|
||||
|
||||
const CATEGORIES = [
|
||||
'Qurilish',
|
||||
'Savdo',
|
||||
'IT',
|
||||
'Dizayn',
|
||||
'Transport',
|
||||
"Ta'lim",
|
||||
"Sog'liqni saqlash",
|
||||
'Restoran',
|
||||
'Turizm',
|
||||
'Sport',
|
||||
];
|
||||
|
||||
export function ProductServicesTab() {
|
||||
const { productServices, addProductService } = useProfileData();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [mediaUrl, setMediaUrl] = useState('');
|
||||
const [mediaType, setMediaType] = useState<'image' | 'video'>('image');
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setMediaUrl('');
|
||||
setMediaType('image');
|
||||
setSelectedCategories([]);
|
||||
setStep(1);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (!title.trim() || !description.trim() || !mediaUrl.trim()) {
|
||||
Alert.alert('Xato', "Barcha maydonlarni to'ldiring");
|
||||
return;
|
||||
}
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
if (selectedCategories.length === 0) {
|
||||
Alert.alert('Xato', 'Kamida bitta kategoriya tanlang');
|
||||
return;
|
||||
}
|
||||
|
||||
addProductService({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
mediaUrl: mediaUrl.trim(),
|
||||
mediaType,
|
||||
categories: selectedCategories,
|
||||
});
|
||||
|
||||
resetForm();
|
||||
setModalVisible(false);
|
||||
Alert.alert('Muvaffaqiyat', "Xizmat muvaffaqiyatli qo'shildi");
|
||||
};
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setSelectedCategories((prev) =>
|
||||
prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable onPress={() => setModalVisible(true)} style={styles.addButton}>
|
||||
<Plus size={20} color="#ffffff" />
|
||||
<Text style={styles.addButtonText}>Xizmat qo'shish</Text>
|
||||
</Pressable>
|
||||
|
||||
<FlatList
|
||||
data={productServices}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.mediaContainer}>
|
||||
{item.mediaType === 'image' ? (
|
||||
<ExpoImage
|
||||
source={{ uri: item.mediaUrl }}
|
||||
style={styles.media}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.videoPlaceholder}>
|
||||
<FileVideo size={40} color="#64748b" />
|
||||
<Text style={styles.videoText}>Video</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<Text style={styles.title}>{item.title}</Text>
|
||||
<Text style={styles.description} numberOfLines={2}>
|
||||
{item.description}
|
||||
</Text>
|
||||
|
||||
<View style={styles.categoriesContainer}>
|
||||
{item.categories.map((category, index) => (
|
||||
<View key={index} style={styles.categoryChip}>
|
||||
<Text style={styles.categoryText}>{category}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<ImageIcon size={48} color="#475569" />
|
||||
<Text style={styles.emptyText}>Hozircha xizmatlar yo'q</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Yangi xizmat qo'shish uchun yuqoridagi tugmani bosing
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => {
|
||||
resetForm();
|
||||
setModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{step === 1 ? "Xizmat ma'lumotlari" : 'Kategoriyalarni tanlang'}
|
||||
</Text>
|
||||
<View style={styles.stepIndicator}>
|
||||
<View style={[styles.stepDot, step >= 1 && styles.stepDotActive]} />
|
||||
<View style={[styles.stepLine, step >= 2 && styles.stepLineActive]} />
|
||||
<View style={[styles.stepDot, step >= 2 && styles.stepDotActive]} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{step === 1 ? (
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.inputLabel}>Nomi</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Xizmat nomi"
|
||||
placeholderTextColor="#64748b"
|
||||
/>
|
||||
|
||||
<Text style={styles.inputLabel}>Tavsif</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Xizmat haqida batafsil ma'lumot"
|
||||
placeholderTextColor="#64748b"
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
|
||||
<Text style={styles.inputLabel}>Media turi</Text>
|
||||
<View style={styles.mediaTypeContainer}>
|
||||
<Pressable
|
||||
onPress={() => setMediaType('image')}
|
||||
style={[
|
||||
styles.mediaTypeButton,
|
||||
mediaType === 'image' && styles.mediaTypeButtonActive,
|
||||
]}
|
||||
>
|
||||
<ImageIcon size={20} color={mediaType === 'image' ? '#3b82f6' : '#64748b'} />
|
||||
<Text
|
||||
style={[
|
||||
styles.mediaTypeText,
|
||||
mediaType === 'image' && styles.mediaTypeTextActive,
|
||||
]}
|
||||
>
|
||||
Rasm
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setMediaType('video')}
|
||||
style={[
|
||||
styles.mediaTypeButton,
|
||||
mediaType === 'video' && styles.mediaTypeButtonActive,
|
||||
]}
|
||||
>
|
||||
<FileVideo size={20} color={mediaType === 'video' ? '#3b82f6' : '#64748b'} />
|
||||
<Text
|
||||
style={[
|
||||
styles.mediaTypeText,
|
||||
mediaType === 'video' && styles.mediaTypeTextActive,
|
||||
]}
|
||||
>
|
||||
Video
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={styles.inputLabel}>Media URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={mediaUrl}
|
||||
onChangeText={setMediaUrl}
|
||||
placeholder="https://..."
|
||||
placeholderTextColor="#64748b"
|
||||
keyboardType="url"
|
||||
/>
|
||||
|
||||
<View style={styles.modalButtons}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
resetForm();
|
||||
setModalVisible(false);
|
||||
}}
|
||||
style={[styles.modalButton, styles.cancelButton]}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Bekor qilish</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={handleNext} style={[styles.modalButton, styles.nextButton]}>
|
||||
<Text style={styles.nextButtonText}>Keyingisi</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.categoriesGrid}>
|
||||
{CATEGORIES.map((category) => (
|
||||
<Pressable
|
||||
key={category}
|
||||
onPress={() => toggleCategory(category)}
|
||||
style={[
|
||||
styles.categoryOption,
|
||||
selectedCategories.includes(category) && styles.categoryOptionSelected,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.categoryOptionText,
|
||||
selectedCategories.includes(category) &&
|
||||
styles.categoryOptionTextSelected,
|
||||
]}
|
||||
>
|
||||
{category}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.modalButtons}>
|
||||
<Pressable
|
||||
onPress={() => setStep(1)}
|
||||
style={[styles.modalButton, styles.backButton]}
|
||||
>
|
||||
<Text style={styles.backButtonText}>Ortga</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={handleAdd} style={[styles.modalButton, styles.saveButton]}>
|
||||
<Text style={styles.saveButtonText}>Qo'shish</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
addButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
backgroundColor: '#3b82f6',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
addButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700' as const,
|
||||
color: '#ffffff',
|
||||
},
|
||||
list: {
|
||||
gap: 16,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#1e293b',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
mediaContainer: {
|
||||
width: '100%',
|
||||
height: 200,
|
||||
backgroundColor: '#334155',
|
||||
},
|
||||
media: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
videoPlaceholder: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
videoText: {
|
||||
fontSize: 14,
|
||||
color: '#64748b',
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
contentContainer: {
|
||||
padding: 16,
|
||||
gap: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700' as const,
|
||||
color: '#f8fafc',
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
color: '#94a3b8',
|
||||
lineHeight: 20,
|
||||
},
|
||||
categoriesContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
categoryChip: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#334155',
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 12,
|
||||
color: '#94a3b8',
|
||||
fontWeight: '500' as const,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 80,
|
||||
gap: 12,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600' as const,
|
||||
color: '#64748b',
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: '#475569',
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: '#00000090',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
maxHeight: '90%',
|
||||
backgroundColor: '#1e293b',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
padding: 24,
|
||||
gap: 20,
|
||||
},
|
||||
modalHeader: {
|
||||
gap: 12,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700' as const,
|
||||
color: '#f8fafc',
|
||||
},
|
||||
stepIndicator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
stepDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: '#334155',
|
||||
},
|
||||
stepDotActive: {
|
||||
backgroundColor: '#3b82f6',
|
||||
},
|
||||
stepLine: {
|
||||
flex: 1,
|
||||
height: 2,
|
||||
backgroundColor: '#334155',
|
||||
},
|
||||
stepLineActive: {
|
||||
backgroundColor: '#3b82f6',
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600' as const,
|
||||
color: '#94a3b8',
|
||||
marginBottom: 8,
|
||||
marginTop: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#0f172a',
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
fontSize: 16,
|
||||
color: '#f8fafc',
|
||||
borderWidth: 1,
|
||||
borderColor: '#334155',
|
||||
},
|
||||
textArea: {
|
||||
height: 100,
|
||||
paddingTop: 14,
|
||||
},
|
||||
mediaTypeContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
mediaTypeButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#0f172a',
|
||||
borderWidth: 1,
|
||||
borderColor: '#334155',
|
||||
},
|
||||
mediaTypeButtonActive: {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#1e40af20',
|
||||
},
|
||||
mediaTypeText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600' as const,
|
||||
color: '#64748b',
|
||||
},
|
||||
mediaTypeTextActive: {
|
||||
color: '#3b82f6',
|
||||
},
|
||||
categoriesGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
categoryOption: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#0f172a',
|
||||
borderWidth: 1,
|
||||
borderColor: '#334155',
|
||||
},
|
||||
categoryOptionSelected: {
|
||||
backgroundColor: '#1e40af20',
|
||||
borderColor: '#3b82f6',
|
||||
},
|
||||
categoryOptionText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600' as const,
|
||||
color: '#94a3b8',
|
||||
},
|
||||
categoryOptionTextSelected: {
|
||||
color: '#3b82f6',
|
||||
},
|
||||
modalButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
modalButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: '#334155',
|
||||
},
|
||||
backButton: {
|
||||
backgroundColor: '#334155',
|
||||
},
|
||||
nextButton: {
|
||||
backgroundColor: '#3b82f6',
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: '#10b981',
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600' as const,
|
||||
color: '#f8fafc',
|
||||
},
|
||||
backButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600' as const,
|
||||
color: '#f8fafc',
|
||||
},
|
||||
nextButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600' as const,
|
||||
color: '#ffffff',
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600' as const,
|
||||
color: '#ffffff',
|
||||
},
|
||||
});
|
||||
182
screens/profile/ui/ProfileScreen.tsx
Normal file
182
screens/profile/ui/ProfileScreen.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import { useGlobalRefresh } from '@/components/ui/RefreshContext';
|
||||
import { useRouter } from 'expo-router';
|
||||
import {
|
||||
Award,
|
||||
ChevronRight,
|
||||
Megaphone,
|
||||
Package,
|
||||
Settings,
|
||||
User,
|
||||
Users,
|
||||
} from 'lucide-react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { RefreshControl } from 'react-native-gesture-handler';
|
||||
|
||||
export default function Profile() {
|
||||
const router = useRouter();
|
||||
const { onRefresh, refreshing } = useGlobalRefresh();
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Shaxsiy',
|
||||
items: [
|
||||
{ icon: User, label: "Shaxsiy ma'lumotlar", route: '/profile/personal-info' },
|
||||
{ icon: Users, label: 'Xodimlar', route: '/profile/employees' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Faoliyat',
|
||||
items: [
|
||||
{ icon: Megaphone, label: "E'lonlar", route: '/profile/my-ads' },
|
||||
{ icon: Award, label: 'Bonuslar', route: '/profile/bonuses' },
|
||||
{ icon: Package, label: 'Xizmatlar', route: '/profile/products' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Sozlamalar',
|
||||
items: [{ icon: Settings, label: 'Sozlamalar', route: '/profile/settings' }],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[styles.content, isDark ? styles.darkBg : styles.lightBg]}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={['#3b82f6']}
|
||||
tintColor="#3b82f6"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{sections.map((section, index) => (
|
||||
<View key={index} style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, isDark ? styles.darkSubText : styles.lightSubText]}>
|
||||
{t(section.title)}
|
||||
</Text>
|
||||
|
||||
<View style={styles.sectionContent}>
|
||||
{section.items.map((item, idx) => (
|
||||
<TouchableOpacity
|
||||
key={idx}
|
||||
style={[styles.card, isDark ? styles.darkCard : styles.lightCard]}
|
||||
onPress={() => router.push(item.route as any)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View
|
||||
style={[styles.iconContainer, isDark ? styles.darkIconBg : styles.lightIconBg]}
|
||||
>
|
||||
<item.icon size={24} color="#3b82f6" />
|
||||
</View>
|
||||
|
||||
<Text style={[styles.cardLabel, isDark ? styles.darkText : styles.lightText]}>
|
||||
{t(item.label)}
|
||||
</Text>
|
||||
|
||||
<ChevronRight size={20} color={isDark ? '#64748b' : '#94a3b8'} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: {
|
||||
flex: 1,
|
||||
marginBottom: 50,
|
||||
},
|
||||
|
||||
darkBg: {
|
||||
backgroundColor: '#0f172a',
|
||||
},
|
||||
lightBg: {
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
|
||||
section: {
|
||||
marginBottom: 4,
|
||||
paddingTop: 16,
|
||||
},
|
||||
|
||||
sectionTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 12,
|
||||
},
|
||||
|
||||
darkSubText: {
|
||||
color: '#64748b',
|
||||
},
|
||||
lightSubText: {
|
||||
color: '#64748b',
|
||||
},
|
||||
|
||||
sectionContent: {
|
||||
paddingHorizontal: 16,
|
||||
gap: 10,
|
||||
},
|
||||
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
gap: 14,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
|
||||
darkCard: {
|
||||
backgroundColor: '#1e293b',
|
||||
borderWidth: 1,
|
||||
borderColor: '#334155',
|
||||
},
|
||||
lightCard: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#f1f5f9',
|
||||
},
|
||||
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
darkIconBg: {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.15)',
|
||||
},
|
||||
lightIconBg: {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
|
||||
cardLabel: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
|
||||
darkText: {
|
||||
color: '#f1f5f9',
|
||||
},
|
||||
lightText: {
|
||||
color: '#0f172a',
|
||||
},
|
||||
});
|
||||
211
screens/profile/ui/StepOneService.tsx
Normal file
211
screens/profile/ui/StepOneService.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { Camera, Play, X } from 'lucide-react-native';
|
||||
import React, { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Image, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
type MediaType = { uri: string; type: 'image' | 'video' };
|
||||
type StepProps = {
|
||||
formData: any;
|
||||
updateForm: (key: string, value: any) => void;
|
||||
removeMedia: (index: number) => void;
|
||||
};
|
||||
|
||||
type Errors = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
media?: string;
|
||||
};
|
||||
|
||||
const MAX_MEDIA = 10;
|
||||
|
||||
const StepOneServices = forwardRef(({ formData, updateForm, removeMedia }: StepProps, ref) => {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
|
||||
const validate = () => {
|
||||
const e: Errors = {};
|
||||
|
||||
if (!formData.title || formData.title.trim().length < 5)
|
||||
e.title = t("Sarlavha kamida 5 ta belgidan iborat bo'lishi kerak");
|
||||
|
||||
if (!formData.description || formData.description.trim().length < 10)
|
||||
e.description = t("Tavsif kamida 10 ta belgidan iborat bo'lishi kerak");
|
||||
|
||||
if (!formData.media || formData.media.length === 0)
|
||||
e.media = t('Kamida bitta rasm yoki video yuklang');
|
||||
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({ validate }));
|
||||
|
||||
const pickMedia = async () => {
|
||||
if (formData.media.length >= MAX_MEDIA) return;
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||
allowsMultipleSelection: true,
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
const assets = result.assets
|
||||
.slice(0, MAX_MEDIA - formData.media.length)
|
||||
.map((a) => ({ uri: a.uri, type: a.type as 'image' | 'video' }));
|
||||
|
||||
updateForm('media', [...formData.media, ...assets]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.stepContainer}>
|
||||
{/* Sarlavha */}
|
||||
<Text style={[styles.label, { color: isDark ? '#f8fafc' : '#0f172a' }]}>{t('Sarlavha')}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.inputBox,
|
||||
{
|
||||
backgroundColor: isDark ? '#1e293b' : '#ffffff',
|
||||
borderColor: isDark ? '#334155' : '#e2e8f0',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextInput
|
||||
style={[styles.input, { color: isDark ? '#fff' : '#0f172a' }]}
|
||||
placeholder={t('Xizmat sarlavhasi')}
|
||||
placeholderTextColor={isDark ? '#94a3b8' : '#94a3b8'}
|
||||
value={formData.title}
|
||||
onChangeText={(t) => updateForm('title', t)}
|
||||
/>
|
||||
</View>
|
||||
{errors.title && <Text style={styles.error}>{errors.title}</Text>}
|
||||
|
||||
{/* Tavsif */}
|
||||
<Text style={[styles.label, { color: isDark ? '#f8fafc' : '#0f172a' }]}>{t('Tavsif')}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.inputBox,
|
||||
styles.textArea,
|
||||
{
|
||||
backgroundColor: isDark ? '#1e293b' : '#ffffff',
|
||||
borderColor: isDark ? '#334155' : '#e2e8f0',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextInput
|
||||
style={[styles.input, { color: isDark ? '#fff' : '#0f172a' }]}
|
||||
placeholder={t('Batafsil yozing...')}
|
||||
placeholderTextColor={isDark ? '#94a3b8' : '#94a3b8'}
|
||||
multiline
|
||||
value={formData.description}
|
||||
onChangeText={(t) => updateForm('description', t)}
|
||||
/>
|
||||
</View>
|
||||
{errors.description && <Text style={styles.error}>{errors.description}</Text>}
|
||||
|
||||
{/* Media */}
|
||||
<Text style={[styles.label, { color: isDark ? '#f8fafc' : '#0f172a' }]}>
|
||||
{t('Media')} ({formData.media.length}/{MAX_MEDIA})
|
||||
</Text>
|
||||
<View style={styles.media}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.upload,
|
||||
{
|
||||
borderColor: '#2563eb',
|
||||
backgroundColor: isDark ? '#1e293b' : '#f8fafc',
|
||||
},
|
||||
]}
|
||||
onPress={pickMedia}
|
||||
>
|
||||
<Camera size={28} color="#2563eb" />
|
||||
<Text style={styles.uploadText}>{t('Yuklash')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{formData.media.map((m: MediaType, i: number) => (
|
||||
<View key={i} style={styles.preview}>
|
||||
<Image source={{ uri: m.uri }} style={styles.image} />
|
||||
{m.type === 'video' && (
|
||||
<View style={styles.play}>
|
||||
<Play size={14} color="#fff" fill="#fff" />
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity style={styles.remove} onPress={() => removeMedia(i)}>
|
||||
<X size={12} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{errors.media && <Text style={styles.error}>{errors.media}</Text>}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export default StepOneServices;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
stepContainer: { gap: 10 },
|
||||
label: { fontWeight: '700', fontSize: 15 },
|
||||
error: { color: '#ef4444', fontSize: 13, marginLeft: 6 },
|
||||
inputBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
height: 56,
|
||||
},
|
||||
textArea: { height: 120, alignItems: 'flex-start', paddingTop: 16 },
|
||||
input: { flex: 1, fontSize: 16 },
|
||||
media: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
|
||||
upload: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 16,
|
||||
borderWidth: 2,
|
||||
borderStyle: 'dashed',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
uploadText: { color: '#2563eb', fontSize: 11, marginTop: 4, fontWeight: '600' },
|
||||
preview: { width: 100, height: 100 },
|
||||
image: { width: '100%', height: '100%', borderRadius: 16 },
|
||||
play: {
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
left: '40%',
|
||||
backgroundColor: 'rgba(0,0,0,.5)',
|
||||
padding: 6,
|
||||
borderRadius: 20,
|
||||
},
|
||||
remove: {
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
right: -6,
|
||||
backgroundColor: '#ef4444',
|
||||
padding: 4,
|
||||
borderRadius: 10,
|
||||
},
|
||||
prefixContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
prefix: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
prefixFocused: {
|
||||
color: '#fff',
|
||||
},
|
||||
divider: {
|
||||
width: 1.5,
|
||||
height: 24,
|
||||
marginLeft: 12,
|
||||
},
|
||||
});
|
||||
432
screens/welcome/styles/welcomeStyle.ts
Normal file
432
screens/welcome/styles/welcomeStyle.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FAFBFF',
|
||||
},
|
||||
|
||||
// Header Styles
|
||||
header: {
|
||||
backgroundColor: '#fff',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F0F2F8',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
logoBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
logoCircle: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 21,
|
||||
backgroundColor: '#6366F1',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#6366F1',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
logoText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
brandText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#1F2937',
|
||||
},
|
||||
headerRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
zIndex: 100,
|
||||
gap: 12,
|
||||
},
|
||||
|
||||
// Language Select
|
||||
langContainer: {
|
||||
position: 'relative',
|
||||
zIndex: 1000,
|
||||
height: 40,
|
||||
},
|
||||
langTrigger: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
position: 'relative',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#F9FAFB',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
},
|
||||
langIcon: {
|
||||
fontSize: 16,
|
||||
},
|
||||
langText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
},
|
||||
chevron: {
|
||||
fontSize: 10,
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
langMenu: {
|
||||
position: 'absolute',
|
||||
top: 48,
|
||||
right: 0,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
minWidth: 160,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 20, // oshirdik
|
||||
zIndex: 1000, // iOS uchun
|
||||
},
|
||||
langItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
zIndex: 50,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F3F4F6',
|
||||
},
|
||||
langItemActive: {
|
||||
backgroundColor: '#EEF2FF',
|
||||
},
|
||||
langItemText: {
|
||||
fontSize: 14,
|
||||
color: '#374151',
|
||||
},
|
||||
langItemTextActive: {
|
||||
color: '#6366F1',
|
||||
fontWeight: '600',
|
||||
},
|
||||
checkmark: {
|
||||
color: '#6366F1',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
|
||||
// Menu Button
|
||||
menuBtn: {
|
||||
padding: 8,
|
||||
},
|
||||
hamburger: {
|
||||
width: 24,
|
||||
height: 20,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
line: {
|
||||
width: 24,
|
||||
height: 2,
|
||||
backgroundColor: '#374151',
|
||||
borderRadius: 2,
|
||||
},
|
||||
lineRotate1: {
|
||||
transform: [{ rotate: '45deg' }, { translateY: 9 }],
|
||||
},
|
||||
lineHide: {
|
||||
opacity: 0,
|
||||
},
|
||||
lineRotate2: {
|
||||
transform: [{ rotate: '-45deg' }, { translateY: -9 }],
|
||||
},
|
||||
|
||||
// Mobile Menu
|
||||
mobileMenu: {
|
||||
backgroundColor: '#fff',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E7EB',
|
||||
paddingVertical: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
menuItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
marginHorizontal: 12,
|
||||
marginVertical: 4,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#F9FAFB',
|
||||
},
|
||||
menuItemPrimary: {
|
||||
backgroundColor: '#6366F1',
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
},
|
||||
menuItemTextPrimary: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
menuArrow: {
|
||||
fontSize: 18,
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
menuArrowPrimary: {
|
||||
fontSize: 18,
|
||||
color: '#fff',
|
||||
},
|
||||
|
||||
// Scroll Content
|
||||
scrollContent: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
|
||||
// Hero Section
|
||||
hero: {
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 20,
|
||||
paddingBottom: 32,
|
||||
alignItems: 'center',
|
||||
},
|
||||
badge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#EEF2FF',
|
||||
borderWidth: 1,
|
||||
borderColor: '#DDD6FE',
|
||||
marginBottom: 24,
|
||||
},
|
||||
badgeDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#6366F1',
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
color: '#6366F1',
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#1F2937',
|
||||
textAlign: 'center',
|
||||
lineHeight: 40,
|
||||
},
|
||||
titleGradient: {
|
||||
color: '#6366F1',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#6B7280',
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
},
|
||||
mainBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
backgroundColor: '#6366F1',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 32,
|
||||
borderRadius: 16,
|
||||
width: 'auto',
|
||||
shadowColor: '#6366F1',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 16,
|
||||
elevation: 8,
|
||||
marginTop: 20,
|
||||
},
|
||||
mainBtnText: {
|
||||
color: '#fff',
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
},
|
||||
btnArrow: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnArrowText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
|
||||
// Features
|
||||
features: {
|
||||
paddingHorizontal: 20,
|
||||
gap: 16,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 24,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: '#F0F2F8',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 12,
|
||||
elevation: 3,
|
||||
},
|
||||
cardIcon: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#EEF2FF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
cardIconText: {
|
||||
fontSize: 28,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#1F2937',
|
||||
marginBottom: 8,
|
||||
},
|
||||
cardDesc: {
|
||||
fontSize: 15,
|
||||
color: '#6B7280',
|
||||
lineHeight: 22,
|
||||
},
|
||||
|
||||
// Stats
|
||||
stats: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 20,
|
||||
marginTop: 32,
|
||||
padding: 24,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: '#F0F2F8',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 12,
|
||||
elevation: 3,
|
||||
},
|
||||
stat: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
statNumber: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
color: '#6366F1',
|
||||
marginBottom: 4,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
fontWeight: '500',
|
||||
},
|
||||
statDivider: {
|
||||
width: 1,
|
||||
height: 40,
|
||||
backgroundColor: '#E5E7EB',
|
||||
},
|
||||
|
||||
// Footer
|
||||
footer: {
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
backgroundColor: '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#F0F2F8',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
height: 'auto',
|
||||
},
|
||||
footerBtnOutline: {
|
||||
flex: 1,
|
||||
minHeight: 50,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
footerBtnOutlineText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
},
|
||||
footerBtnPrimary: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#6366F1',
|
||||
shadowColor: '#6366F1',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 5,
|
||||
},
|
||||
footerBtnPrimaryText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
footerBtnArrow: {
|
||||
fontSize: 18,
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
56
screens/welcome/ui/FeatureCard.tsx
Normal file
56
screens/welcome/ui/FeatureCard.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useEffect } from 'react';
|
||||
import { Animated, View } from 'react-native';
|
||||
import { styles } from '../styles/welcomeStyle';
|
||||
|
||||
export default function FeatureCard({
|
||||
icon,
|
||||
title,
|
||||
desc,
|
||||
delay,
|
||||
bgColor,
|
||||
}: {
|
||||
icon: any;
|
||||
title: string;
|
||||
bgColor: string;
|
||||
desc: string;
|
||||
delay: number;
|
||||
}) {
|
||||
const fadeAnim = new Animated.Value(0);
|
||||
const slideAnim = new Animated.Value(30);
|
||||
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
delay: delay,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 600,
|
||||
delay: delay,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [{ translateY: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={{ ...styles.cardIcon, backgroundColor: bgColor, borderRadius: 12 }}>
|
||||
<ThemedText style={styles.cardIconText}>{icon}</ThemedText>
|
||||
</View>
|
||||
<ThemedText style={styles.cardTitle}>{title}</ThemedText>
|
||||
<ThemedText style={styles.cardDesc}>{desc}</ThemedText>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
190
screens/welcome/ui/LanguageSelect.tsx
Normal file
190
screens/welcome/ui/LanguageSelect.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import Feather from '@expo/vector-icons/Feather';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Animated, Modal, TouchableOpacity, View } from 'react-native';
|
||||
import { styles } from '../styles/welcomeStyle';
|
||||
|
||||
export default function LanguageSelect() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [current, setCurrent] = useState('uz');
|
||||
const [menuPos, setMenuPos] = useState({ x: 0, y: 0, width: 0, height: 0 });
|
||||
const triggerRef = useRef<View>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLang = async () => {
|
||||
const lang = await getLang();
|
||||
if (lang) setCurrent(lang);
|
||||
};
|
||||
fetchLang();
|
||||
}, []);
|
||||
|
||||
const backdropAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = useRef(new Animated.Value(-20)).current;
|
||||
const scaleAnim = useRef(new Animated.Value(0.9)).current;
|
||||
|
||||
const languages = [
|
||||
{ key: 'uz', label: "🇺🇿 O'zbek" },
|
||||
{ key: 'ru', label: '🇷🇺 Русский' },
|
||||
{ key: 'en', label: '🇺🇸 English' },
|
||||
];
|
||||
|
||||
const openMenu = () => {
|
||||
triggerRef.current?.measureInWindow((x, y, width, height) => {
|
||||
setMenuPos({
|
||||
x,
|
||||
y: y + height + 60,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
setVisible(true);
|
||||
});
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
Animated.parallel([
|
||||
Animated.timing(backdropAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: -20,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 0.9,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
setVisible(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
backdropAnim.setValue(0);
|
||||
fadeAnim.setValue(0);
|
||||
slideAnim.setValue(-20);
|
||||
scaleAnim.setValue(0.9);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(backdropAnim, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(slideAnim, {
|
||||
toValue: 0,
|
||||
tension: 100,
|
||||
friction: 8,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
tension: 100,
|
||||
friction: 8,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const selectLanguage = async (lang: string) => {
|
||||
closeMenu();
|
||||
setCurrent(lang);
|
||||
await i18n.changeLanguage(lang);
|
||||
await saveLang(lang);
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.langContainer} ref={triggerRef}>
|
||||
<TouchableOpacity style={styles.langTrigger} onPress={openMenu} activeOpacity={0.7}>
|
||||
<Feather name="globe" size={18} color="black" />
|
||||
<ThemedText style={{ ...styles.langText }}>{current.toUpperCase()}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
transparent
|
||||
visible={visible}
|
||||
animationType="none"
|
||||
statusBarTranslucent
|
||||
onRequestClose={closeMenu}
|
||||
>
|
||||
<TouchableOpacity style={{ flex: 1 }} activeOpacity={1} onPress={closeMenu}>
|
||||
<Animated.View
|
||||
style={{
|
||||
...StyleSheet.absoluteFillObject,
|
||||
opacity: backdropAnim,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: menuPos.x - menuPos.width - 20,
|
||||
top: menuPos.y,
|
||||
width: 160,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 16,
|
||||
elevation: 12,
|
||||
overflow: 'hidden',
|
||||
opacity: fadeAnim,
|
||||
transform: [{ translateY: slideAnim }, { scale: scaleAnim }],
|
||||
}}
|
||||
>
|
||||
{languages.map((l, index) => (
|
||||
<TouchableOpacity
|
||||
key={l.key}
|
||||
onPress={() => selectLanguage(l.key)}
|
||||
activeOpacity={0.7}
|
||||
style={{
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: current === l.key ? '#EEF2FF' : 'transparent',
|
||||
borderBottomWidth: index < languages.length - 1 ? 1 : 0,
|
||||
borderBottomColor: '#F3F4F6',
|
||||
}}
|
||||
>
|
||||
<ThemedText
|
||||
style={[styles.langItemText, current === l.key && styles.langItemTextActive]}
|
||||
>
|
||||
{l.label}
|
||||
</ThemedText>
|
||||
{current === l.key && <MaterialIcons name="done" size={20} color="#6366F1" />}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// StyleSheet.absoluteFillObject uchun import qo'shing:
|
||||
import { getLang, saveLang } from '@/hooks/storage.native';
|
||||
import i18n from '@/i18n/i18n';
|
||||
import { StyleSheet } from 'react-native';
|
||||
218
screens/welcome/ui/Welcome.tsx
Normal file
218
screens/welcome/ui/Welcome.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import AntDesign from '@expo/vector-icons/AntDesign';
|
||||
import Feather from '@expo/vector-icons/Feather';
|
||||
import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Animated, RefreshControl, ScrollView, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { styles } from '../styles/welcomeStyle';
|
||||
import FeatureCard from './FeatureCard';
|
||||
import WelcomeHeader from './WelcomeHeader';
|
||||
|
||||
export default function WelcomePage() {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const fadeAnim = new Animated.Value(0);
|
||||
const router = useRouter();
|
||||
const slideAnim = new Animated.Value(50);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const menuHeightAnim = useRef(new Animated.Value(0)).current;
|
||||
const menuOpacityAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}, []);
|
||||
|
||||
// Menu animatsiyasi
|
||||
useEffect(() => {
|
||||
if (menuOpen) {
|
||||
Animated.parallel([
|
||||
Animated.spring(menuHeightAnim, {
|
||||
toValue: 1,
|
||||
tension: 80,
|
||||
friction: 10,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(menuOpacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(menuHeightAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(menuOpacityAnim, {
|
||||
toValue: 0,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [menuOpen]);
|
||||
|
||||
const handleNavigation = (screen: 'announcements' | '(tabs)/login') => {
|
||||
setMenuOpen(false);
|
||||
router.push(`/${screen}`);
|
||||
};
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setRefreshing(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<WelcomeHeader onMenuPress={() => setMenuOpen(!menuOpen)} menuOpen={menuOpen} />
|
||||
|
||||
{menuOpen && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.mobileMenu,
|
||||
{
|
||||
opacity: menuOpacityAnim,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity style={styles.menuItem} activeOpacity={0.7}>
|
||||
<ThemedText style={styles.menuItemText}>{t('mainPage.sign_in')}</ThemedText>
|
||||
<AntDesign name="right" size={15} color="black" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={[styles.menuItem]} activeOpacity={0.7}>
|
||||
<ThemedText style={styles.menuItemText}>{t('mainPage.sign_up')}</ThemedText>
|
||||
<AntDesign name="right" size={15} color="black" />
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor="#155dfc"
|
||||
colors={['#155dfc']}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View style={[styles.hero]}>
|
||||
<View style={styles.badge}>
|
||||
<ThemedText style={styles.badgeText}> {t('mainPage.safe')}</ThemedText>
|
||||
</View>
|
||||
|
||||
<ThemedText style={styles.title}>{t('mainPage.welcome')}</ThemedText>
|
||||
|
||||
<ThemedText style={styles.subtitle}>{t('mainPage.welcome_desc')}</ThemedText>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.mainBtn}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => handleNavigation('announcements')}
|
||||
>
|
||||
<ThemedText style={styles.mainBtnText}> {t('common.enrol')}</ThemedText>
|
||||
<View style={styles.btnArrow}>
|
||||
<AntDesign name="right" size={14} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.features}>
|
||||
<FeatureCard
|
||||
bgColor="#dbeafe"
|
||||
icon={<MaterialCommunityIcons name="shield-outline" size={24} color="#155dfc" />}
|
||||
title={t('mainPage.card1Title')}
|
||||
desc={t('mainPage.card1Desc')}
|
||||
delay={200}
|
||||
/>
|
||||
<FeatureCard
|
||||
bgColor="#dbeafe"
|
||||
icon={<Feather name="zap" size={24} color="#155dfc" />}
|
||||
title={t('mainPage.card2Title')}
|
||||
desc={t('mainPage.card2Desc')}
|
||||
delay={300}
|
||||
/>
|
||||
<FeatureCard
|
||||
bgColor="#f3e8ff"
|
||||
icon={<Feather name="users" size={24} color="#9810fa" />}
|
||||
title={t('mainPage.card3Title')}
|
||||
desc={t('mainPage.card3Desc')}
|
||||
delay={400}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
...styles.footerBtnOutline,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 12,
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText style={styles.footerBtnOutlineText}>{t('mainPage.enter')}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={() => handleNavigation('(tabs)/login')} activeOpacity={0.9}>
|
||||
<LinearGradient
|
||||
colors={['#3b82f6', '#6366f1']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 12,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<ThemedText
|
||||
style={{
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginRight: 8,
|
||||
}}
|
||||
>
|
||||
{t('mainPage.start')}
|
||||
</ThemedText>
|
||||
<AntDesign name="right" size={14} color="white" />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
109
screens/welcome/ui/WelcomeHeader.tsx
Normal file
109
screens/welcome/ui/WelcomeHeader.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import AntDesign from '@expo/vector-icons/AntDesign';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Animated, TouchableOpacity, View } from 'react-native';
|
||||
import { styles } from '../styles/welcomeStyle';
|
||||
import LanguageSelect from './LanguageSelect';
|
||||
|
||||
export default function WelcomeHeader({
|
||||
onMenuPress,
|
||||
menuOpen,
|
||||
}: {
|
||||
onMenuPress: any;
|
||||
menuOpen: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Icon rotation animatsiyasi
|
||||
const rotateAnim = useRef(new Animated.Value(0)).current;
|
||||
const scaleAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (menuOpen) {
|
||||
Animated.parallel([
|
||||
Animated.spring(rotateAnim, {
|
||||
toValue: 1,
|
||||
tension: 100,
|
||||
friction: 8,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.sequence([
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 0.8,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
tension: 100,
|
||||
friction: 5,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.spring(rotateAnim, {
|
||||
toValue: 0,
|
||||
tension: 100,
|
||||
friction: 8,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.sequence([
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 0.8,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
tension: 100,
|
||||
friction: 5,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
]).start();
|
||||
}
|
||||
}, [menuOpen]);
|
||||
|
||||
// Rotation interpolation
|
||||
const rotation = rotateAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '90deg'],
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerContent}>
|
||||
<View style={styles.logoBox}>
|
||||
<View style={styles.logoCircle}>
|
||||
<ThemedText style={styles.logoText}>IT</ThemedText>
|
||||
</View>
|
||||
<ThemedText style={styles.brandText}>{t('common.target')}</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerRight}>
|
||||
<LanguageSelect />
|
||||
|
||||
<TouchableOpacity onPress={onMenuPress} style={styles.menuBtn} activeOpacity={0.7}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.hamburger,
|
||||
{
|
||||
transform: [{ rotate: rotation }, { scale: scaleAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{menuOpen ? (
|
||||
<AntDesign name="close" size={22} color="#374151" />
|
||||
) : (
|
||||
<AntDesign name="menu" size={22} color="#374151" />
|
||||
)}
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user