fitst commit

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

View File

@@ -0,0 +1,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;
},
};

View 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;
};
}

View 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',
},
});

View 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 },
});

View 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 },
});

View 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>
);
}

View 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,
},
});

View 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,
},
});

View 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',
},
});

View 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;

View 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;

View 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;
},
};

View 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;

View 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,
},
});

View 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',
},
});

View 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,
},
});

View 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 'Ozbek';
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,
};
}

View 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;

View 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;

View 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: 'Yonalishni 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,
},
});

View 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 notogri')}</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',
},
});

View 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)',
},
});

View 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;
},
};

View 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;
};
}

View 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',
},
});

View 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',
},
});

View 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 },
});

View 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,
},
});

View 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 },
});

View 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
View 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
View 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;
}[];
}[];
}

View 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', 'Talim', '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' },
});

View 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 },
});

View 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,
},
});

View 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
View 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
View 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;
}

View 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' },
});

View 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 },
});

View 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: {},
});

View 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',
},
});

View 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,
},
});

View 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 },
});

View 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' },
});

View 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',
},
});

View 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', 'Malumotlar yangilandi');
},
onError: () => {
Alert.alert('Xatolik', 'Malumotlarni 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',
},
});

View 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',
},
});

View 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',
},
});

View 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,
},
});

View 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',
},
});

View 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>
);
}

View 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';

View 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>
);
}

View 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>
);
}