128 lines
7.0 KiB
TypeScript
128 lines
7.0 KiB
TypeScript
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>;
|
|
}
|