Files
han-app/codebase/backend/frontend-test-site/app/index.tsx
T

211 lines
8.9 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React, { useState } from "react";
import { Linking, ScrollView, Switch, Text, View } from "react-native";
import { useApp } from "../src/app-context";
import { AppHeader } from "../src/components/AppHeader";
import { ChatInputBar } from "../src/components/ChatInputBar";
import { HanLogo } from "../src/components/HanLogo";
import { PopularQuestionsList } from "../src/components/PopularQuestionsList";
import { QuickActions } from "../src/components/QuickActions";
import { ScreenShell } from "../src/components/ScreenShell";
import {
clearPendingTextIntent,
createPendingTextIntent,
savePendingTextIntent,
type PendingTextIntent,
} from "../src/pending-intent";
import { dialogApi, publicApi, uploadAttachment } from "../src/services";
import type { Consents } from "../src/types";
import { Button, ErrorNotice, Loading, styles } from "../src/ui";
export default function HomeScreen() {
const { authStatus, authorize } = 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<PendingTextIntent | null>(null);
const [message, setMessage] = useState("");
const [sendError, setSendError] = useState<unknown>();
const [sending, setSending] = useState(false);
const router = useRouter();
const sendAuthenticated = async (intent: PendingTextIntent) => {
setSending(true);
setSendError(undefined);
try {
const dialog = await dialogApi.create(intent.dialogKey);
await dialogApi.sendText(dialog.dialog_id, intent.text, intent.messageKey);
setMessage("");
setPending(null);
clearPendingTextIntent();
router.push(`/dialogs/${dialog.dialog_id}`);
} catch (error) {
setSendError(error);
} finally {
setSending(false);
}
};
const send = async (text: string) => {
const normalized = text.trim();
if (!normalized) return;
if (normalized.length > 4000) {
setSendError(new Error("Сообщение слишком длинное. Максимум — 4000 символов."));
return;
}
const intent = createPendingTextIntent(normalized);
if (authStatus !== "authenticated") {
setPending(intent);
savePendingTextIntent(intent);
setConsentOpen(true);
return;
}
await sendAuthenticated(intent);
};
const chooseFile = () => {
if (authStatus !== "authenticated") {
setConsentOpen(true);
return;
}
if (typeof document === "undefined") {
setSendError(new Error("Выбор файла в этой сборке доступен только в web."));
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*,application/pdf";
input.onchange = () => {
const file = input.files?.[0];
if (file) void sendFile(file);
};
input.click();
};
const sendFile = async (file: File) => {
const limits = config.data?.attachments;
const max = (limits?.max_size_mb ?? 5) * 1024 * 1024;
const allowed = limits?.allowed_mime_types ?? ["image/jpeg", "image/png", "image/webp", "application/pdf"];
if (file.size > max || !allowed.includes(file.type)) {
setSendError(new Error("Недопустимый тип файла или превышен допустимый размер."));
return;
}
setSending(true);
setSendError(undefined);
try {
const dialog = await dialogApi.create(crypto.randomUUID());
const uploaded = await uploadAttachment(dialog.dialog_id, file);
await dialogApi.sendFile(
dialog.dialog_id,
uploaded.attachmentId,
uploaded.checksum,
crypto.randomUUID(),
);
router.push(`/dialogs/${dialog.dialog_id}`);
} catch (error) {
setSendError(error);
} finally {
setSending(false);
}
};
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 welcome = content.data?.texts.welcome;
const questions = content.data?.popular_questions ?? [];
return (
<ScreenShell>
<AppHeader guestLabel={authStatus === "authenticated" ? undefined : "Гость"} />
<View style={{ flex: 1 }}>
<HanLogo />
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: spacing }}>
{(config.isLoading || content.isLoading) && <Loading />}
{(config.error || content.error) && (
<View style={{ paddingHorizontal: 16 }}>
<ErrorNotice error={config.error ?? content.error} retry={() => { void config.refetch(); void content.refetch(); }} />
</View>
)}
{welcome ? (
<Text style={[styles.muted, { paddingHorizontal: 16, marginBottom: 8 }]}>{welcome}</Text>
) : null}
</ScrollView>
<PopularQuestionsList questions={questions} onSelect={(text) => { setMessage(text); void send(text); }} />
<ChatInputBar
disabled={sending}
onAttach={chooseFile}
onChangeText={setMessage}
onSubmit={() => void send(message)}
sending={sending}
value={message}
/>
<QuickActions phone={config.data?.operator.call_phone} />
{sendError && (
<View style={{ paddingHorizontal: 16, paddingBottom: 8 }}>
<ErrorNotice error={sendError} />
</View>
)}
</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);
clearPendingTextIntent();
}}
/>
</View>
</View>
</View>
)}
</ScreenShell>
);
}
const spacing = 16;
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>;
}