Screens: - LoginScreen / RegisterScreen (with mandatory consents checkboxes) - MapScreen — MAIN screen after login (Google Maps on Android, Apple Maps on iOS) * shows nearby users within radius * visibility toggle (hide/show myself on map) * radius selector 1/5/10/25/50 km * GPS auto-update with permission flow - ChatListScreen / ChatScreen with WebSocket realtime * long-press message: edit/delete * 'ред.' marker for edited messages * mark as read - ProfileScreen — bio, city, gender, drinks, activities, purposes State: Zustand (auth, map stores) HTTP: axios + JWT interceptor + token refresh WS: socket.io-client Storage: AsyncStorage for tokens Maps: react-native-maps Permissions: react-native-permissions
180 lines
7.3 KiB
TypeScript
180 lines
7.3 KiB
TypeScript
import React, { useState } from 'react';
|
||
import {
|
||
View, Text, TextInput, TouchableOpacity, ScrollView, Switch, StyleSheet, Alert,
|
||
} from 'react-native';
|
||
import { useNavigation } from '@react-navigation/native';
|
||
import { useAuth } from '@/store/auth';
|
||
import { Colors, Spacing, FontSizes, Radius } from '@/theme/colors';
|
||
import { Config } from '@/config';
|
||
import type { Gender } from '@/types/api';
|
||
|
||
export default function RegisterScreen() {
|
||
const nav = useNavigation<any>();
|
||
const { register, loading, error } = useAuth();
|
||
|
||
const [name, setName] = useState('');
|
||
const [email, setEmail] = useState('');
|
||
const [phone, setPhone] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [birthdate, setBirthdate] = useState('');
|
||
const [gender, setGender] = useState<Gender | ''>('');
|
||
const [city, setCity] = useState('');
|
||
|
||
const [cAdult, setCAdult] = useState(false);
|
||
const [cTerms, setCTerms] = useState(false);
|
||
const [cPrivacy, setCPrivacy] = useState(false);
|
||
const [cDisclaimer, setCDisclaimer] = useState(false);
|
||
|
||
const allConsents = cAdult && cTerms && cPrivacy && cDisclaimer;
|
||
const canSubmit =
|
||
name.trim().length > 0 &&
|
||
(email.trim() || phone.trim()) &&
|
||
password.length >= Config.MIN_PASSWORD_LENGTH &&
|
||
allConsents &&
|
||
!loading;
|
||
|
||
const onSubmit = async () => {
|
||
if (!allConsents) {
|
||
Alert.alert('Согласия', 'Подтвердите все 4 пункта: 18+, соглашение, политика, отказ');
|
||
return;
|
||
}
|
||
try {
|
||
await register({
|
||
email: email.trim() || undefined,
|
||
phone: phone.trim() || undefined,
|
||
password,
|
||
name: name.trim(),
|
||
birthdate: birthdate.trim() || undefined,
|
||
gender: gender || undefined,
|
||
city: city.trim() || undefined,
|
||
consents: {
|
||
adult: cAdult,
|
||
terms: cTerms,
|
||
privacy: cPrivacy,
|
||
disclaimer: cDisclaimer,
|
||
},
|
||
});
|
||
} catch (e: any) {
|
||
Alert.alert('Ошибка', e?.response?.data?.error ?? 'не удалось зарегистрироваться');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<ScrollView style={styles.root} contentContainerStyle={styles.content}>
|
||
<Text style={styles.title}>Регистрация</Text>
|
||
<Text style={styles.subtitle}>BuhApp — для совершеннолетних</Text>
|
||
|
||
<Field label="Имя" value={name} onChange={setName} placeholder="Как тебя зовут" />
|
||
<Field label="Email" value={email} onChange={setEmail} placeholder="you@example.com" keyboardType="email-address" autoCapitalize="none" />
|
||
<Field label="Телефон (опционально)" value={phone} onChange={setPhone} placeholder="+79991234567" keyboardType="phone-pad" />
|
||
<Field label="Пароль" value={password} onChange={setPassword} placeholder={`минимум ${Config.MIN_PASSWORD_LENGTH} символов`} secureTextEntry />
|
||
<Field label="Дата рождения (ГГГГ-ММ-ДД)" value={birthdate} onChange={setBirthdate} placeholder="1995-06-15" autoCapitalize="none" />
|
||
<Field label="Город" value={city} onChange={setCity} placeholder="Москва" />
|
||
|
||
<Text style={styles.label}>Пол</Text>
|
||
<View style={styles.row}>
|
||
{(['m','f','o'] as Gender[]).map((g) => (
|
||
<TouchableOpacity
|
||
key={g}
|
||
style={[styles.chip, gender === g && styles.chipActive]}
|
||
onPress={() => setGender(g)}>
|
||
<Text style={[styles.chipText, gender === g && styles.chipTextActive]}>
|
||
{g === 'm' ? 'Мужской' : g === 'f' ? 'Женский' : 'Другое'}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
|
||
<Text style={[styles.label, { marginTop: Spacing.l }]}>Согласия (обязательно)</Text>
|
||
<ConsentRow
|
||
label={`Мне исполнилось 18 лет`}
|
||
value={cAdult} onChange={setCAdult}
|
||
/>
|
||
<ConsentRow
|
||
label={`Пользовательское соглашение (версия 1.0)`}
|
||
value={cTerms} onChange={setCTerms}
|
||
/>
|
||
<ConsentRow
|
||
label={`Политика конфиденциальности (152-ФЗ, версия 1.0)`}
|
||
value={cPrivacy} onChange={setCPrivacy}
|
||
/>
|
||
<ConsentRow
|
||
label={`Отказ от ответственности (версия 1.0)`}
|
||
value={cDisclaimer} onChange={setCDisclaimer}
|
||
/>
|
||
|
||
{error && <Text style={styles.error}>{error}</Text>}
|
||
|
||
<TouchableOpacity
|
||
style={[styles.btn, !canSubmit && styles.btnDisabled]}
|
||
disabled={!canSubmit}
|
||
onPress={onSubmit}>
|
||
<Text style={styles.btnText}>{loading ? 'Отправка...' : 'Создать аккаунт'}</Text>
|
||
</TouchableOpacity>
|
||
|
||
<TouchableOpacity onPress={() => nav.navigate('Login')} style={{ marginTop: Spacing.l }}>
|
||
<Text style={styles.link}>У меня уже есть аккаунт</Text>
|
||
</TouchableOpacity>
|
||
</ScrollView>
|
||
);
|
||
}
|
||
|
||
function Field({ label, ...props }: any) {
|
||
return (
|
||
<View style={{ marginBottom: Spacing.m }}>
|
||
<Text style={styles.label}>{label}</Text>
|
||
<TextInput
|
||
style={styles.input}
|
||
placeholderTextColor={Colors.textDim}
|
||
{...props}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function ConsentRow({ label, value, onChange }: { label: string; value: boolean; onChange: (v: boolean) => void }) {
|
||
return (
|
||
<View style={styles.consent}>
|
||
<Switch
|
||
value={value}
|
||
onValueChange={onChange}
|
||
trackColor={{ true: Colors.primary }}
|
||
thumbColor={value ? Colors.primaryDark : '#f4f3f4'}
|
||
/>
|
||
<Text style={styles.consentText}>{label}</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
root: { flex: 1, backgroundColor: Colors.bg },
|
||
content: { padding: Spacing.l, paddingTop: Spacing.xxl },
|
||
title: { fontSize: FontSizes.xxl, fontWeight: '700', color: Colors.text },
|
||
subtitle: { fontSize: FontSizes.m, color: Colors.textDim, marginBottom: Spacing.l },
|
||
label: { fontSize: FontSizes.s, color: Colors.textDim, marginBottom: Spacing.xs },
|
||
input: {
|
||
borderWidth: 1, borderColor: Colors.border, borderRadius: Radius.m,
|
||
paddingHorizontal: Spacing.m, paddingVertical: Spacing.s,
|
||
fontSize: FontSizes.m, color: Colors.text, backgroundColor: Colors.bg,
|
||
},
|
||
row: { flexDirection: 'row', gap: Spacing.s as any },
|
||
chip: {
|
||
paddingVertical: Spacing.s, paddingHorizontal: Spacing.m,
|
||
borderRadius: Radius.full, borderWidth: 1, borderColor: Colors.border,
|
||
marginRight: Spacing.s,
|
||
},
|
||
chipActive: { backgroundColor: Colors.primary, borderColor: Colors.primary },
|
||
chipText: { color: Colors.text, fontSize: FontSizes.s },
|
||
chipTextActive: { color: Colors.textInverse, fontWeight: '600' },
|
||
consent: { flexDirection: 'row', alignItems: 'center', marginBottom: Spacing.s, gap: Spacing.s as any },
|
||
consentText: { flex: 1, fontSize: FontSizes.s, color: Colors.text },
|
||
btn: {
|
||
backgroundColor: Colors.primary, paddingVertical: Spacing.m, borderRadius: Radius.m,
|
||
alignItems: 'center', marginTop: Spacing.l,
|
||
},
|
||
btnDisabled: { opacity: 0.5 },
|
||
btnText: { color: Colors.textInverse, fontSize: FontSizes.l, fontWeight: '600' },
|
||
link: { color: Colors.secondary, textAlign: 'center', fontSize: FontSizes.m },
|
||
error: { color: Colors.danger, fontSize: FontSizes.s, marginTop: Spacing.s },
|
||
});
|