Разработана первая версия приложений
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import React from "react";
|
||||
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
|
||||
import { AppProvider } from "../src/app-context";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <SafeAreaProvider>
|
||||
<AppProvider>
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: "#f6f8fb" }}>
|
||||
<StatusBar style="dark" />
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
</SafeAreaView>
|
||||
</AppProvider>
|
||||
</SafeAreaProvider>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import type { Consents } from "../../src/types";
|
||||
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
|
||||
|
||||
export default function AuthCallbackScreen() {
|
||||
const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>();
|
||||
const app = useApp();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const retry = () => {
|
||||
if (!params.code || !params.state || typeof window === "undefined") return;
|
||||
const raw = window.sessionStorage.getItem("han.pending-consents");
|
||||
if (!raw) return;
|
||||
setError(undefined);
|
||||
void app.finishCallback(params.code, params.state, JSON.parse(raw) as Consents).catch(setError);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (params.error) {
|
||||
setError(new Error("Авторизация отменена или отклонена."));
|
||||
return;
|
||||
}
|
||||
if (!params.code || !params.state) return;
|
||||
const raw = typeof window !== "undefined" ? window.sessionStorage.getItem("han.pending-consents") : null;
|
||||
if (!raw) {
|
||||
setError(new Error("Не найдены локально принятые согласия. Начните вход заново."));
|
||||
return;
|
||||
}
|
||||
const consents = JSON.parse(raw) as Consents;
|
||||
void app.finishCallback(params.code, params.state, consents)
|
||||
.then(() => window.sessionStorage.removeItem("han.pending-consents"))
|
||||
.catch(setError);
|
||||
}, [params.code, params.state, params.error]);
|
||||
return <View style={styles.page}>
|
||||
<Text accessibilityRole="header" style={styles.title}>Завершение входа</Text>
|
||||
{!error && <Loading />}
|
||||
{error && <><ErrorNotice error={error} /><Button title="Повторить bootstrap" onPress={retry} /></>}
|
||||
</View>;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { getDiagnostics, sessionMemory } from "../src/api";
|
||||
import { getTokenInfo } from "../src/auth";
|
||||
import { useApp } from "../src/app-context";
|
||||
import { isProduction } from "../src/config";
|
||||
import { getRealtimeDiagnostics } from "../src/realtime";
|
||||
import { Header, styles } from "../src/ui";
|
||||
|
||||
export default function DiagnosticsScreen() {
|
||||
const app = useApp();
|
||||
const [, render] = useState(0);
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => render((value) => value + 1), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
if (isProduction) return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} />
|
||||
<Text accessibilityRole="header" style={styles.title}>Диагностика</Text>
|
||||
<Text style={styles.error}>Экран отключён в production.</Text>
|
||||
</ScrollView>;
|
||||
const token = getTokenInfo();
|
||||
return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} />
|
||||
<Text accessibilityRole="header" style={styles.title}>Безопасная диагностика</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.text}>Режим: {app.authStatus}</Text>
|
||||
<Text style={styles.text}>Realtime: {app.realtimeState}</Text>
|
||||
<Text style={styles.text}>UX-сессия: {sessionMemory.id ?? "нет"}</Text>
|
||||
<Text style={styles.text}>Access token истекает: {token ? new Date(token.expiresAt).toLocaleString("ru-RU") : "нет"}</Text>
|
||||
{getRealtimeDiagnostics().map((item) =>
|
||||
<Text key={item.dialog} style={styles.text}>Cursor {item.dialog}: {item.cursor}</Text>,
|
||||
)}
|
||||
<Text style={styles.muted}>Токены, OTP, персональные данные, сообщения и presigned URL здесь никогда не отображаются.</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<Text accessibilityRole="header" style={styles.heading}>Последние запросы</Text>
|
||||
{!getDiagnostics().length && <Text style={styles.muted}>Запросов ещё нет.</Text>}
|
||||
{getDiagnostics().map((item) => <View key={`${item.at}-${item.requestId}`} style={styles.row}>
|
||||
<Text style={styles.badge}>{item.status}</Text>
|
||||
<Text style={styles.text}>{item.method} {item.path}</Text>
|
||||
<Text style={styles.muted}>request_id: {item.requestId}</Text>
|
||||
</View>)}
|
||||
</View>
|
||||
</ScrollView>;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Platform, ScrollView, Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { RealtimeClient, reconcileMessages } from "../../src/realtime";
|
||||
import { dialogApi, profileApi, publicApi, uploadAttachment } from "../../src/services";
|
||||
import type { Message } from "../../src/types";
|
||||
import { Button, ErrorNotice, Field, Header, Loading, styles } from "../../src/ui";
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
open: "Открыт",
|
||||
waiting_for_company: "Ожидает ответа компании",
|
||||
waiting_for_client: "Ожидает вашего ответа",
|
||||
closed: "Закрыт",
|
||||
accepted: "Принято",
|
||||
delivered: "Доставлено",
|
||||
failed: "Ошибка доставки",
|
||||
rejected: "Отклонено",
|
||||
};
|
||||
|
||||
export default function ChatScreen() {
|
||||
const { dialogId } = useLocalSearchParams<{ dialogId: string }>();
|
||||
const app = useApp();
|
||||
const client = useQueryClient();
|
||||
const [text, setText] = useState("");
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [sending, setSending] = useState(false);
|
||||
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
||||
const dialog = useQuery({ queryKey: ["dialog", dialogId], queryFn: () => dialogApi.get(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
|
||||
const messages = useQuery({ queryKey: ["messages", dialogId], queryFn: () => dialogApi.messages(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
|
||||
|
||||
const realtime = useMemo(() => new RealtimeClient(
|
||||
dialogId ? [dialogId] : [],
|
||||
(event) => {
|
||||
if (event.type === "message.new") merge([event.message]);
|
||||
if (event.type === "message.status") {
|
||||
client.setQueryData(["messages", dialogId], (old: typeof messages.data) => old && ({
|
||||
...old,
|
||||
items: old.items.map((item) => item.message_id === event.message_id ? { ...item, safety_status: event.safety_status, delivery_status: event.delivery_status } : item),
|
||||
}));
|
||||
}
|
||||
if (event.type === "dialog.status") void dialog.refetch();
|
||||
},
|
||||
(_, incoming) => merge(incoming),
|
||||
app.setRealtimeState,
|
||||
), [dialogId]);
|
||||
|
||||
function merge(incoming: Message[]) {
|
||||
client.setQueryData(["messages", dialogId], (old: typeof messages.data) =>
|
||||
old ? { ...old, items: reconcileMessages(old.items, incoming) } : { items: incoming, next_cursor: null },
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (app.authStatus === "authenticated") realtime.start();
|
||||
return () => realtime.stop();
|
||||
}, [realtime, app.authStatus]);
|
||||
|
||||
const sendText = async () => {
|
||||
const normalized = text.trim();
|
||||
if (!normalized || !dialogId) return;
|
||||
setSending(true); setError(undefined);
|
||||
try {
|
||||
const message = await dialogApi.sendText(dialogId, normalized, crypto.randomUUID());
|
||||
merge([message]); setText("");
|
||||
} catch (reason) { setError(reason); }
|
||||
finally { setSending(false); }
|
||||
};
|
||||
|
||||
const chooseFile = () => {
|
||||
if (Platform.OS !== "web") {
|
||||
setError(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)) {
|
||||
setError(new Error("Недопустимый тип файла или превышен допустимый размер."));
|
||||
return;
|
||||
}
|
||||
setSending(true); setError(undefined);
|
||||
try {
|
||||
const uploaded = await uploadAttachment(dialogId, file);
|
||||
const message = await dialogApi.sendFile(dialogId, uploaded.attachmentId, uploaded.checksum, crypto.randomUUID());
|
||||
merge([message]);
|
||||
} catch (reason) { setError(reason); }
|
||||
finally { setSending(false); }
|
||||
};
|
||||
|
||||
const downloadAttachment = async (attachmentId: string) => {
|
||||
try {
|
||||
const result = await profileApi.attachmentUrl(dialogId, attachmentId);
|
||||
if (typeof window !== "undefined") window.location.assign(result.download_url);
|
||||
} catch (reason) { setError(reason); }
|
||||
};
|
||||
|
||||
const closed = dialog.data?.status === "closed";
|
||||
return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} />
|
||||
<Text accessibilityRole="header" style={styles.title}>Чат</Text>
|
||||
{app.authStatus !== "authenticated" && <Text style={styles.error}>Для просмотра чата требуется авторизация.</Text>}
|
||||
{(dialog.isLoading || messages.isLoading) && <Loading />}
|
||||
{(dialog.error || messages.error) && <ErrorNotice error={dialog.error ?? messages.error} retry={() => { void dialog.refetch(); void messages.refetch(); }} />}
|
||||
<Text style={styles.badge}>Статус: {statusLabel[dialog.data?.status ?? ""] ?? "—"}</Text>
|
||||
<View accessibilityLiveRegion="polite" style={{ gap: 10 }}>
|
||||
{(messages.data?.items ?? []).map((message) => <View key={message.message_id} style={message.sender_type === "client" ? styles.messageClient : styles.messageCompany}>
|
||||
<Text style={styles.text}>{message.content_kind === "file" ? `Файл: ${message.attachments[0]?.file_name ?? "вложение"}` : message.text}</Text>
|
||||
{message.attachments.map((attachment) =>
|
||||
<Button key={attachment.attachment_id} title="Скачать вложение" secondary onPress={() => void downloadAttachment(attachment.attachment_id)} />,
|
||||
)}
|
||||
<Text style={styles.muted}>{message.sender_type === "client" ? "Вы" : "Компания"} · {new Date(message.created_at).toLocaleString("ru-RU")} · {statusLabel[message.delivery_status] ?? message.delivery_status}</Text>
|
||||
</View>)}
|
||||
{!messages.isLoading && !messages.data?.items.length && <Text style={styles.muted}>Сообщений пока нет.</Text>}
|
||||
</View>
|
||||
{closed ? <Text style={styles.muted}>Диалог закрыт и доступен только для чтения.</Text> : <View style={styles.card}>
|
||||
<Field label="Новое сообщение" multiline value={text} onChangeText={setText} />
|
||||
<View style={styles.row}>
|
||||
<Button title={sending ? "Отправка…" : "Отправить"} disabled={sending || !text.trim()} onPress={() => void sendText()} />
|
||||
<Button title="Прикрепить изображение или PDF" secondary disabled={sending} onPress={chooseFile} />
|
||||
</View>
|
||||
{error && <ErrorNotice error={error} />}
|
||||
</View>}
|
||||
</ScrollView>;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link } from "expo-router";
|
||||
import React from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { dialogApi } from "../../src/services";
|
||||
import { Button, ErrorNotice, Header, Loading, styles } from "../../src/ui";
|
||||
|
||||
const statusLabels = {
|
||||
open: "Открыт",
|
||||
waiting_for_company: "Ожидает ответа компании",
|
||||
waiting_for_client: "Ожидает вашего ответа",
|
||||
closed: "Закрыт",
|
||||
};
|
||||
|
||||
export default function DialogsScreen() {
|
||||
const app = useApp();
|
||||
const dialogs = useInfiniteQuery({
|
||||
queryKey: ["dialogs"],
|
||||
queryFn: ({ pageParam }) => dialogApi.list(pageParam),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (page) => page.next_cursor ?? undefined,
|
||||
enabled: app.authStatus === "authenticated",
|
||||
});
|
||||
if (app.authStatus !== "authenticated") return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} />
|
||||
<Text accessibilityRole="header" style={styles.title}>История диалогов</Text>
|
||||
<Text style={styles.text}>История доступна после авторизации. Отправьте сообщение на главной странице, чтобы войти.</Text>
|
||||
<Link href="/" style={styles.link}>На главную</Link>
|
||||
</ScrollView>;
|
||||
|
||||
const items = dialogs.data?.pages.flatMap((page) => page.items) ?? [];
|
||||
return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} />
|
||||
<Text accessibilityRole="header" style={styles.title}>История диалогов</Text>
|
||||
{dialogs.isLoading && <Loading />}
|
||||
{dialogs.error && <ErrorNotice error={dialogs.error} retry={() => void dialogs.refetch()} />}
|
||||
{!dialogs.isLoading && !items.length && <Text style={styles.muted}>Диалогов пока нет.</Text>}
|
||||
{items.map((dialog) => <View key={dialog.dialog_id} style={styles.card}>
|
||||
<Text style={styles.heading}>{statusLabels[dialog.status]}</Text>
|
||||
<Text style={styles.muted}>Диалог {dialog.dialog_id.slice(0, 8)}…</Text>
|
||||
<Link href={`/dialogs/${dialog.dialog_id}`} style={styles.link}>Открыть диалог</Link>
|
||||
</View>)}
|
||||
{dialogs.hasNextPage && <Button title="Показать ещё" secondary disabled={dialogs.isFetchingNextPage} onPress={() => void dialogs.fetchNextPage()} />}
|
||||
</ScrollView>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "expo-router";
|
||||
import React, { useState } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useApp } from "../src/app-context";
|
||||
import { profileApi } from "../src/services";
|
||||
import { Button, ErrorNotice, Header, Loading, styles } from "../src/ui";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const app = useApp();
|
||||
const enabled = app.authStatus === "authenticated";
|
||||
const profile = useQuery({ queryKey: ["profile"], queryFn: profileApi.me, enabled });
|
||||
const documents = useQuery({ queryKey: ["documents"], queryFn: profileApi.documents, enabled });
|
||||
const [downloadError, setDownloadError] = useState<unknown>();
|
||||
|
||||
const download = async (id: string) => {
|
||||
try {
|
||||
const result = await profileApi.documentUrl(id);
|
||||
if (typeof window !== "undefined") window.location.assign(result.download_url);
|
||||
} catch (error) { setDownloadError(error); }
|
||||
};
|
||||
|
||||
if (!enabled) return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} />
|
||||
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
|
||||
<Text style={styles.text}>Профиль доступен после авторизации.</Text>
|
||||
<Link href="/" style={styles.link}>Перейти в чат для входа</Link>
|
||||
</ScrollView>;
|
||||
|
||||
const personal = profile.data?.profile.personal_data;
|
||||
return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} />
|
||||
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
|
||||
{(profile.isLoading || documents.isLoading) && <Loading />}
|
||||
{(profile.error || documents.error) && <ErrorNotice error={profile.error ?? documents.error} retry={() => { void profile.refetch(); void documents.refetch(); }} />}
|
||||
<View style={styles.card}>
|
||||
<Text accessibilityRole="header" style={styles.heading}>Личные данные</Text>
|
||||
<Row label="ФИО" value={personal?.full_name} />
|
||||
<Row label="Гражданство" value={personal?.citizenship} />
|
||||
<Row label="Телефон в РФ" value={personal?.russian_phone} />
|
||||
<Row label="Зарубежный телефон" value={personal?.foreign_phone} />
|
||||
<Row label="Email" value={personal?.email} />
|
||||
<Text style={styles.muted}>Редактирование профиля недоступно. Для изменения данных напишите оператору.</Text>
|
||||
<Link href="/" style={styles.link}>Написать оператору</Link>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<Text accessibilityRole="header" style={styles.heading}>Документы</Text>
|
||||
{!documents.data?.items.length && <Text style={styles.muted}>Документов пока нет.</Text>}
|
||||
{documents.data?.items.map((document) => <View key={document.document_id} style={styles.row}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.text}>{document.name}</Text>
|
||||
<Text style={styles.muted}>{new Date(document.sent_at).toLocaleDateString("ru-RU")}</Text>
|
||||
</View>
|
||||
<Button title="Скачать" secondary onPress={() => void download(document.document_id)} />
|
||||
</View>)}
|
||||
{downloadError && <ErrorNotice error={downloadError} />}
|
||||
</View>
|
||||
</ScrollView>;
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return <View><Text style={styles.muted}>{label}</Text><Text style={styles.text}>{value || "Не указано"}</Text></View>;
|
||||
}
|
||||
Reference in New Issue
Block a user