Разработана первая версия приложений

This commit is contained in:
mi
2026-07-10 18:06:14 +03:00
parent aa8761d1b3
commit 8c7b4074c4
162 changed files with 12178 additions and 16 deletions
@@ -0,0 +1,127 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React, { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Linking, ScrollView, Switch, Text, View } from "react-native";
import { z } from "zod";
import { useApp } from "../src/app-context";
import { dialogApi, publicApi } from "../src/services";
import type { Consents } from "../src/types";
import { Button, ErrorNotice, Field, Header, Loading, styles } from "../src/ui";
const schema = z.object({ text: z.string().trim().min(1, "Введите сообщение").max(4000, "Сообщение слишком длинное") });
type Form = z.infer<typeof schema>;
export default function HomeScreen() {
const { authStatus, realtimeState, authorize, signOut } = useApp();
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
const content = useQuery({ queryKey: ["public-content"], queryFn: publicApi.content });
const [consentOpen, setConsentOpen] = useState(false);
const [required, setRequired] = useState({ personal: false, agreement: false, marketing: false });
const [pending, setPending] = useState<string | null>(null);
const [sendError, setSendError] = useState<unknown>();
const [sending, setSending] = useState(false);
const router = useRouter();
const { control, handleSubmit, setValue, reset, formState: { errors } } = useForm<Form>({
defaultValues: { text: "" }, resolver: zodResolver(schema),
});
const sendAuthenticated = async (text: string) => {
setSending(true);
setSendError(undefined);
try {
const dialog = await dialogApi.create(crypto.randomUUID());
await dialogApi.sendText(dialog.dialog_id, text, crypto.randomUUID());
reset();
router.push(`/dialogs/${dialog.dialog_id}`);
} catch (error) {
setSendError(error);
} finally {
setSending(false);
}
};
const send = async (text: string) => {
if (authStatus !== "authenticated") {
setPending(text);
setConsentOpen(true);
return;
}
await sendAuthenticated(text);
};
const accept = async () => {
if (!required.personal || !required.agreement) return;
const versions = config.data?.consents;
const consents: Consents = {
personal_data: { accepted: true, version: versions?.personal_data?.version ?? "current" },
user_agreement: { accepted: true, version: versions?.user_agreement?.version ?? "current" },
marketing: { accepted: required.marketing, version: versions?.marketing?.version ?? "current" },
};
setConsentOpen(false);
try {
const authorized = await authorize(consents);
if (authorized && pending) await sendAuthenticated(pending);
} catch (error) {
setSendError(error);
}
};
const questions = content.data?.popular_questions ?? [];
return <ScrollView contentContainerStyle={styles.page}>
<Header status={authStatus} realtime={realtimeState} onLogout={authStatus === "authenticated" ? () => void signOut() : undefined} />
<Text accessibilityRole="header" style={styles.title}>Помощь мигрантам</Text>
<Text style={styles.text}>{content.data?.texts.welcome ?? "Задайте вопрос — оператор ответит в чате."}</Text>
{(config.isLoading || content.isLoading) && <Loading />}
{(config.error || content.error) && <ErrorNotice error={config.error ?? content.error} retry={() => { void config.refetch(); void content.refetch(); }} />}
<View style={styles.card}>
<Text accessibilityRole="header" style={styles.heading}>Популярные вопросы</Text>
<View style={styles.row}>
{questions.map((question, index) => {
const text = question.text;
return <Button key={question.id ?? index} title={text} secondary onPress={() => { setValue("text", text); void send(text); }} />;
})}
{!questions.length && <Text style={styles.muted}>Популярные вопросы пока не опубликованы.</Text>}
</View>
</View>
<View style={styles.card}>
<Text accessibilityRole="header" style={styles.heading}>Написать оператору</Text>
<Controller control={control} name="text" render={({ field }) =>
<Field label="Сообщение" multiline value={field.value} onChangeText={field.onChange} error={errors.text?.message} />
} />
<Button title={sending ? "Отправляем…" : "Отправить"} disabled={sending} onPress={() => void handleSubmit(({ text }) => send(text))()} />
{sendError && <ErrorNotice error={sendError} />}
</View>
{consentOpen && <View accessibilityViewIsModal style={styles.modalBackdrop}>
<View style={styles.modal}>
<Text accessibilityRole="header" style={styles.heading}>Согласия перед входом</Text>
<Text style={styles.text}>Для отправки сообщения необходимо войти по номеру телефона. Код вводится только на защищённой странице авторизации.</Text>
{(["personal_data", "user_agreement", "marketing"] as const).map((key) => {
const item = config.data?.consents?.[key];
if (!item) return null;
return item.document_url ? <Text key={key} accessibilityRole="link" style={styles.link} onPress={() => void Linking.openURL(item.document_url!)}>
{key === "personal_data" ? "Политика персональных данных" : key === "user_agreement" ? "Пользовательское соглашение" : "Согласие на рекламу"} · версия {item.version}
</Text> : null;
})}
<ConsentRow label="Обработка персональных данных (обязательно)" value={required.personal} onChange={(personal) => setRequired({ ...required, personal })} />
<ConsentRow label="Пользовательское соглашение (обязательно)" value={required.agreement} onChange={(agreement) => setRequired({ ...required, agreement })} />
<ConsentRow label="Рекламные коммуникации (необязательно)" value={required.marketing} onChange={(marketing) => setRequired({ ...required, marketing })} />
<View style={styles.row}>
<Button title="Продолжить" disabled={!required.personal || !required.agreement} onPress={() => void accept()} />
<Button title="Отмена" secondary onPress={() => { setConsentOpen(false); setPending(null); }} />
</View>
</View>
</View>}
</ScrollView>;
}
function ConsentRow({ label, value, onChange }: { label: string; value: boolean; onChange: (value: boolean) => void }) {
return <View style={[styles.row, { justifyContent: "space-between" }]}>
<Text style={[styles.text, { flex: 1 }]}>{label}</Text>
<Switch accessibilityLabel={label} value={value} onValueChange={onChange} />
</View>;
}