Накатил человеческий дизайн

This commit is contained in:
mi
2026-07-21 16:01:24 +03:00
parent 9cfc157124
commit 627371b221
35 changed files with 2415 additions and 293 deletions
@@ -3,11 +3,12 @@ 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";
import { colors } from "../src/theme";
export default function RootLayout() {
return <SafeAreaProvider>
<AppProvider>
<SafeAreaView style={{ flex: 1, backgroundColor: "#f6f8fb" }}>
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
<StatusBar style="dark" />
<Stack screenOptions={{ headerShown: false }} />
</SafeAreaView>
@@ -1,22 +1,44 @@
import { useLocalSearchParams } from "expo-router";
import { useLocalSearchParams, useRouter } from "expo-router";
import React, { useEffect, useRef, useState } from "react";
import { Text, View } from "react-native";
import { useApp } from "../../src/app-context";
import { AuthLoadingView } from "../../src/components/AuthLoadingView";
import { ScreenShell } from "../../src/components/ScreenShell";
import { clearPendingTextIntent, loadPendingTextIntent } from "../../src/pending-intent";
import { dialogApi } from "../../src/services";
import { spacing } from "../../src/theme";
import type { Consents } from "../../src/types";
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
import { Button, ErrorNotice, styles } from "../../src/ui";
export default function AuthCallbackScreen() {
const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>();
const app = useApp();
const router = useRouter();
const [error, setError] = useState<unknown>();
const completionStarted = useRef(false);
const retry = () => {
const complete = async () => {
if (!params.code || !params.state || typeof window === "undefined") return;
const raw = window.sessionStorage.getItem("han.pending-consents");
if (!raw) return;
if (!raw) throw new Error("Не найдены локально принятые согласия. Начните вход заново.");
setError(undefined);
void app.finishCallback(params.code, params.state, JSON.parse(raw) as Consents).catch(setError);
const consents = JSON.parse(raw) as Consents;
await app.finishCallback(params.code, params.state, consents);
const intent = loadPendingTextIntent();
if (intent) {
const dialog = await dialogApi.create(intent.dialogKey);
await dialogApi.sendText(dialog.dialog_id, intent.text, intent.messageKey);
clearPendingTextIntent();
window.sessionStorage.removeItem("han.pending-consents");
router.replace(`/dialogs/${dialog.dialog_id}`);
return;
}
window.sessionStorage.removeItem("han.pending-consents");
router.replace("/");
};
useEffect(() => {
if (completionStarted.current) return;
if (params.error) {
@@ -26,19 +48,23 @@ export default function AuthCallbackScreen() {
}
if (!params.code || !params.state) return;
completionStarted.current = true;
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);
void complete().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>;
return (
<ScreenShell>
{!error ? (
<AuthLoadingView />
) : (
<View style={{ flex: 1, justifyContent: "center", padding: spacing.lg, gap: spacing.lg }}>
<Text accessibilityRole="header" style={styles.title}>Не удалось завершить вход</Text>
<>
<ErrorNotice error={error} />
<Button title="Повторить" onPress={() => void complete().catch(setError)} />
<Button title="На главную" secondary onPress={() => router.replace("/")} />
</>
</View>
)}
</ScreenShell>
);
}
@@ -1,46 +1,76 @@
import React, { useEffect, useState } from "react";
import { ScrollView, Text, View } from "react-native";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { Feather } from "@expo/vector-icons";
import { useRouter } from "expo-router";
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";
import { ScreenShell } from "../src/components/ScreenShell";
import { styles } from "../src/ui";
import { colors, radii, spacing } from "../src/theme";
export default function DiagnosticsScreen() {
const app = useApp();
const router = useRouter();
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>;
if (isProduction) {
return (
<ScreenShell>
<View style={stylesLocal.topBar}>
<Pressable onPress={() => router.back()} style={stylesLocal.backButton}>
<Feather name="arrow-left" size={20} color={colors.foreground} />
</Pressable>
<Text accessibilityRole="header" style={styles.title}>Диагностика</Text>
</View>
<View style={{ padding: spacing.lg }}>
<Text style={styles.error}>Экран отключён в production.</Text>
</View>
</ScreenShell>
);
}
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>;
return (
<ScreenShell>
<ScrollView contentContainerStyle={{ padding: spacing.lg, gap: spacing.lg, paddingBottom: spacing.xl }}>
<View style={stylesLocal.topBar}>
<Pressable onPress={() => router.back()} style={stylesLocal.backButton}>
<Feather name="arrow-left" size={20} color={colors.foreground} />
</Pressable>
<Text accessibilityRole="header" style={styles.title}>Безопасная диагностика</Text>
</View>
<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>
</ScreenShell>
);
}
const stylesLocal = StyleSheet.create({
topBar: { flexDirection: "row", alignItems: "center", gap: spacing.md, marginBottom: spacing.sm },
backButton: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
});
@@ -1,22 +1,23 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalSearchParams, useRouter } from "expo-router";
import React, { useEffect, useMemo, useState } from "react";
import { Platform, ScrollView, Text, View } from "react-native";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FlatList, Platform, Text, View } from "react-native";
import { useApp } from "../../src/app-context";
import { ChatInputBar } from "../../src/components/ChatInputBar";
import { ChatScreenHeader } from "../../src/components/ChatScreenHeader";
import { MessageBubble } from "../../src/components/MessageBubble";
import { ScreenShell } from "../../src/components/ScreenShell";
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";
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
import { colors, spacing } from "../../src/theme";
const statusLabel: Record<string, string> = {
open: "Открыт",
waiting_for_company: "Ожидает ответа компании",
waiting_for_company: "Ожидает ответа",
waiting_for_client: "Ожидает вашего ответа",
closed: "Закрыт",
accepted: "Принято",
delivered: "Доставлено",
failed: "Ошибка доставки",
rejected: "Отклонено",
};
export default function ChatScreen() {
@@ -24,6 +25,7 @@ export default function ChatScreen() {
const app = useApp();
const client = useQueryClient();
const router = useRouter();
const listRef = useRef<FlatList<Message>>(null);
const [text, setText] = useState("");
const [error, setError] = useState<unknown>();
const [sending, setSending] = useState(false);
@@ -48,9 +50,10 @@ export default function ChatScreen() {
), [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 },
);
client.setQueryData(["messages", dialogId], (old: typeof messages.data) => ({
items: reconcileMessages(old?.items ?? [], incoming),
next_cursor: old?.next_cursor ?? null,
}));
}
useEffect(() => {
@@ -58,6 +61,11 @@ export default function ChatScreen() {
return () => realtime.stop();
}, [realtime, app.authStatus]);
useEffect(() => {
const items = messages.data?.items ?? [];
if (items.length) listRef.current?.scrollToEnd({ animated: true });
}, [messages.data?.items.length]);
const sendText = async () => {
const normalized = text.trim();
if (!normalized || !dialogId) return;
@@ -98,41 +106,79 @@ export default function ChatScreen() {
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 getAttachmentUrl = useCallback(async (attachmentId: string) => {
const result = await profileApi.attachmentUrl(dialogId, attachmentId);
return result.download_url;
}, [dialogId]);
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)} />,
const items = messages.data?.items ?? [];
const subtitle = dialog.data?.status ? (statusLabel[dialog.data.status] ?? "Онлайн") : "Онлайн";
if (app.authStatus !== "authenticated") {
return (
<ScreenShell>
<ChatScreenHeader subtitle="Требуется вход" />
<View style={{ flex: 1, padding: spacing.lg, justifyContent: "center" }}>
<Text style={styles.error}>Для просмотра чата требуется авторизация.</Text>
<Button title="На главную" secondary onPress={() => router.replace("/")} />
</View>
</ScreenShell>
);
}
return (
<ScreenShell>
<ChatScreenHeader subtitle={subtitle} />
<View style={{ flex: 1, backgroundColor: colors.background }}>
{(dialog.isLoading || messages.isLoading) && (
<View style={{ padding: spacing.lg }}><Loading /></View>
)}
{(dialog.error || messages.error) && (
<View style={{ padding: spacing.lg }}>
<ErrorNotice error={dialog.error ?? messages.error} retry={() => { void dialog.refetch(); void messages.refetch(); }} />
</View>
)}
<FlatList
accessibilityLiveRegion="polite"
ref={listRef}
data={items}
keyExtractor={(item) => item.message_id}
contentContainerStyle={{ paddingHorizontal: spacing.lg, paddingVertical: spacing.lg, flexGrow: 1 }}
ListEmptyComponent={!messages.isLoading ? <Text style={[styles.muted, { textAlign: "center", marginTop: 40 }]}>Сообщений пока нет.</Text> : null}
renderItem={({ item }) => (
<MessageBubble
getAttachmentUrl={getAttachmentUrl}
message={item}
onAttachmentError={setError}
/>
)}
/>
{closed ? (
<View style={{ padding: spacing.lg, borderTopWidth: 1, borderTopColor: colors.border }}>
<Text style={[styles.muted, { marginBottom: spacing.sm }]}>Предыдущая беседа завершена.</Text>
<Button title="Продолжить общение" onPress={() => router.replace("/dialogs")} />
</View>
) : (
<>
<ChatInputBar
disabled={sending}
hint=""
onAttach={chooseFile}
onChangeText={setText}
onSubmit={() => void sendText()}
placeholder="Напишите сообщение..."
sending={sending}
value={text}
/>
{error && (
<View style={{ paddingHorizontal: spacing.lg, paddingBottom: spacing.sm }}>
<ErrorNotice error={error} />
</View>
)}
</>
)}
<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 ? <View style={styles.card}>
<Text style={styles.muted}>Предыдущая беседа завершена.</Text>
<Button title="Продолжить общение" onPress={() => router.replace("/dialogs")} />
</View> : <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>;
</ScreenShell>
);
}
@@ -1,10 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { Link, useRouter } from "expo-router";
import React, { useEffect, useRef } from "react";
import { ScrollView, Text } from "react-native";
import { ScrollView, Text, View } from "react-native";
import { useApp } from "../../src/app-context";
import { AppHeader } from "../../src/components/AppHeader";
import { ScreenShell } from "../../src/components/ScreenShell";
import { dialogApi } from "../../src/services";
import { ErrorNotice, Header, Loading, styles } from "../../src/ui";
import { ErrorNotice, Loading, styles } from "../../src/ui";
import { spacing } from "../../src/theme";
export default function DialogsScreen() {
const app = useApp();
@@ -20,17 +23,27 @@ export default function DialogsScreen() {
if (chat.data) router.replace(`/dialogs/${chat.data.dialog_id}`);
}, [chat.data, router]);
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>;
if (app.authStatus !== "authenticated") {
return (
<ScreenShell>
<AppHeader guestLabel="Гость" />
<View style={{ flex: 1, padding: spacing.lg, justifyContent: "center", gap: spacing.md }}>
<Text accessibilityRole="header" style={styles.title}>Чат</Text>
<Text style={styles.text}>Чат с компанией доступен после авторизации. Отправьте сообщение на главной странице, чтобы войти.</Text>
<Link href="/" style={styles.link}>На главную</Link>
</View>
</ScreenShell>
);
}
return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} />
<Text accessibilityRole="header" style={styles.title}>Чат</Text>
{chat.isLoading && <Loading />}
{chat.error && <ErrorNotice error={chat.error} retry={() => void chat.refetch()} />}
</ScrollView>;
return (
<ScreenShell>
<AppHeader />
<View style={{ flex: 1, padding: spacing.lg, justifyContent: "center" }}>
<Text accessibilityRole="header" style={styles.title}>Открываем чат</Text>
{chat.isLoading && <Loading />}
{chat.error && <ErrorNotice error={chat.error} retry={() => void chat.refetch()} />}
</View>
</ScreenShell>
);
}
+146 -63
View File
@@ -1,39 +1,45 @@
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 { 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, 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>;
import { Button, ErrorNotice, Loading, styles } from "../src/ui";
export default function HomeScreen() {
const { authStatus, realtimeState, authorize, signOut } = useApp();
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<string | null>(null);
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 { control, handleSubmit, setValue, reset, formState: { errors } } = useForm<Form>({
defaultValues: { text: "" }, resolver: zodResolver(schema),
});
const sendAuthenticated = async (text: string) => {
const sendAuthenticated = async (intent: PendingTextIntent) => {
setSending(true);
setSendError(undefined);
try {
const dialog = await dialogApi.create(crypto.randomUUID());
await dialogApi.sendText(dialog.dialog_id, text, crypto.randomUUID());
reset();
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);
@@ -43,12 +49,66 @@ export default function HomeScreen() {
};
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(text);
setPending(intent);
savePendingTextIntent(intent);
setConsentOpen(true);
return;
}
await sendAuthenticated(text);
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 () => {
@@ -68,57 +128,80 @@ export default function HomeScreen() {
}
};
const welcome = content.data?.texts.welcome;
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>}
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>
</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); }} />
{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>
</View>
</View>}
</ScrollView>;
)}
</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>
@@ -1,13 +1,20 @@
import { Feather } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { Link } from "expo-router";
import { Link, useRouter } from "expo-router";
import React, { useState } from "react";
import { ScrollView, Text, View } from "react-native";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { useApp } from "../src/app-context";
import { AccordionSection } from "../src/components/AccordionSection";
import { AppHeader } from "../src/components/AppHeader";
import { ScreenShell } from "../src/components/ScreenShell";
import { isProduction } from "../src/config";
import { profileApi } from "../src/services";
import { Button, ErrorNotice, Header, Loading, styles } from "../src/ui";
import { Button, ErrorNotice, Loading, styles } from "../src/ui";
import { colors, radii, spacing } from "../src/theme";
export default function ProfileScreen() {
const app = useApp();
const router = useRouter();
const enabled = app.authStatus === "authenticated";
const profile = useQuery({ queryKey: ["profile"], queryFn: profileApi.me, enabled });
const documents = useQuery({ queryKey: ["documents"], queryFn: profileApi.documents, enabled });
@@ -20,44 +27,112 @@ export default function ProfileScreen() {
} 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>;
if (!enabled) {
return (
<ScreenShell>
<AppHeader guestLabel="Гость" />
<View style={{ flex: 1, padding: spacing.lg, justifyContent: "center", gap: spacing.md }}>
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
<Text style={styles.text}>Профиль доступен после авторизации.</Text>
<Link href="/" style={styles.link}>Перейти на главную для входа</Link>
</View>
</ScreenShell>
);
}
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>
const fullName = personal?.full_name ?? "Пользователь";
return (
<ScreenShell>
<ScrollView contentContainerStyle={{ paddingBottom: spacing.xl }}>
<View style={stylesLocal.topBar}>
<Pressable accessibilityRole="button" accessibilityLabel="Назад" onPress={() => router.back()} style={({ pressed }) => [stylesLocal.backButton, pressed && stylesLocal.pressed]}>
<Feather name="arrow-left" size={20} color={colors.foreground} />
</Pressable>
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
</View>
<Button title="Скачать" secondary onPress={() => void download(document.document_id)} />
</View>)}
{downloadError && <ErrorNotice error={downloadError} />}
</View>
</ScrollView>;
<View style={stylesLocal.avatarBlock}>
<View style={stylesLocal.avatar}>
<Feather name="user" size={40} color={colors.primaryForeground} />
</View>
<Text style={stylesLocal.name}>{fullName}</Text>
<Text style={styles.muted}>{personal?.citizenship ? `Гражданство: ${personal.citizenship}` : "Мигрант"}</Text>
</View>
{(profile.isLoading || documents.isLoading) && <View style={{ padding: spacing.lg }}><Loading /></View>}
{(profile.error || documents.error) && (
<View style={{ paddingHorizontal: spacing.lg }}>
<ErrorNotice error={profile.error ?? documents.error} retry={() => { void profile.refetch(); void documents.refetch(); }} />
</View>
)}
<View style={{ paddingHorizontal: spacing.lg }}>
<AccordionSection
defaultOpen
title="Личная информация"
items={[
{ title: "Имя", value: personal?.full_name ?? "Не указано", icon: "user" },
{ title: "Телефон в РФ", value: personal?.russian_phone ?? "Не указано", icon: "phone" },
{ title: "Зарубежный телефон", value: personal?.foreign_phone ?? "Не указано", icon: "phone" },
{ title: "Email", value: personal?.email ?? "Не указано", icon: "mail" },
]}
/>
<AccordionSection
title="Готовые документы"
items={documents.data?.items.length
? documents.data.items.map((doc) => ({
title: doc.name,
value: new Date(doc.sent_at).toLocaleDateString("ru-RU"),
icon: "file-text" as const,
action: "download",
}))
: [{ title: "Документов пока нет", value: "Оператор отправит их в этот раздел", icon: "file-text" }]}
onItemPress={(index) => {
const doc = documents.data?.items[index];
if (doc) void download(doc.document_id);
}}
/>
<Text style={[styles.muted, { marginBottom: spacing.md }]}>
Редактирование профиля недоступно. Для изменения данных напишите оператору.
</Text>
<Link href="/" style={styles.link}>Написать оператору</Link>
{!isProduction && (
<Pressable onPress={() => router.push("/diagnostics")} style={({ pressed }) => [stylesLocal.menuItem, pressed && stylesLocal.pressed]}>
<Text style={stylesLocal.menuText}>Диагностика (dev)</Text>
<Feather name="chevron-right" size={20} color={colors.mutedForeground} />
</Pressable>
)}
<Button title="Выйти из аккаунта" danger onPress={() => void app.signOut()} />
{downloadError && <ErrorNotice error={downloadError} />}
</View>
</ScrollView>
</ScreenShell>
);
}
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>;
}
const stylesLocal = StyleSheet.create({
topBar: { flexDirection: "row", alignItems: "center", gap: spacing.md, paddingHorizontal: spacing.lg, paddingVertical: spacing.lg },
backButton: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
avatarBlock: { alignItems: "center", paddingVertical: spacing.lg, marginBottom: spacing.sm },
avatar: { width: 80, height: 80, borderRadius: radii.full, backgroundColor: colors.primary, alignItems: "center", justifyContent: "center", marginBottom: spacing.md },
name: { fontSize: 18, fontWeight: "500", color: colors.foreground, marginBottom: 4 },
menuItem: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radii.lg,
padding: spacing.lg,
marginBottom: spacing.md,
},
menuText: { fontSize: 14, fontWeight: "500", color: colors.foreground },
pressed: { backgroundColor: colors.accent },
});