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