Первая версия мобильного приложения
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
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";
|
||||
import { colors } from "../src/theme";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <SafeAreaProvider>
|
||||
<AppProvider>
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<StatusBar style="dark" />
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
</SafeAreaView>
|
||||
</AppProvider>
|
||||
</SafeAreaProvider>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { clearPendingConsents, loadPendingConsents, useApp } from "../../src/app-context";
|
||||
import { AuthLoadingView } from "../../src/components/AuthLoadingView";
|
||||
import { ScreenShell } from "../../src/components/ScreenShell";
|
||||
import { pendingDialogKey, restorePendingIntents } from "../../src/pending-intent";
|
||||
import { spacing } from "../../src/theme";
|
||||
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 complete = async () => {
|
||||
if (!params.code || !params.state) return;
|
||||
const consents = await loadPendingConsents();
|
||||
if (!consents) throw new Error("Не найдены локально принятые согласия. Начните вход заново.");
|
||||
setError(undefined);
|
||||
await app.finishCallback(params.code, params.state, consents);
|
||||
await clearPendingConsents();
|
||||
await restorePendingIntents();
|
||||
|
||||
if (pendingDialogKey()) {
|
||||
router.replace("/dialogs?pending=1");
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace("/");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (completionStarted.current) return;
|
||||
if (params.error) {
|
||||
completionStarted.current = true;
|
||||
setError(new Error("Авторизация отменена или отклонена."));
|
||||
return;
|
||||
}
|
||||
if (!params.code || !params.state) return;
|
||||
completionStarted.current = true;
|
||||
void complete().catch(setError);
|
||||
}, [params.code, params.state, params.error]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
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 { 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 (
|
||||
<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 (
|
||||
<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" },
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AppState, FlatList, Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { ChatInputBar } from "../../src/components/ChatInputBar";
|
||||
import { ChatScreenHeader } from "../../src/components/ChatScreenHeader";
|
||||
import { GuestAuthGate } from "../../src/components/GuestAuthGate";
|
||||
import { MessageBubble } from "../../src/components/MessageBubble";
|
||||
import { ScreenShell } from "../../src/components/ScreenShell";
|
||||
import { isMessageBlockedError } from "../../src/api";
|
||||
import {
|
||||
clearPendingFileIntent,
|
||||
clearPendingTextIntent,
|
||||
loadPendingFileIntent,
|
||||
loadPendingTextIntent,
|
||||
} from "../../src/pending-intent";
|
||||
import { DEFAULT_MESSAGE_MAX_LENGTH, normalizeMessageText } from "../../src/message-text";
|
||||
import { pickFiles, type NativeFile } from "../../src/native-files";
|
||||
import { RealtimeClient, reconcileMessages } from "../../src/realtime";
|
||||
import { dialogApi, profileApi, publicApi, uploadAttachment } from "../../src/services";
|
||||
import type { Message } from "../../src/types";
|
||||
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_client: "Ожидает вашего ответа",
|
||||
closed: "Закрыт",
|
||||
};
|
||||
|
||||
export default function ChatScreen() {
|
||||
const { dialogId } = useLocalSearchParams<{ dialogId: string }>();
|
||||
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);
|
||||
const [retryPending, setRetryPending] = useState<(() => void) | undefined>();
|
||||
const pendingStarted = useRef<string | undefined>(undefined);
|
||||
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) => ({
|
||||
items: reconcileMessages(old?.items ?? [], incoming),
|
||||
next_cursor: old?.next_cursor ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (app.authStatus !== "authenticated") return;
|
||||
const syncRealtime = (state: string) => {
|
||||
if (state === "active") realtime.start();
|
||||
else realtime.stop();
|
||||
};
|
||||
syncRealtime(AppState.currentState);
|
||||
const subscription = AppState.addEventListener("change", syncRealtime);
|
||||
return () => {
|
||||
subscription.remove();
|
||||
realtime.stop();
|
||||
};
|
||||
}, [realtime, app.authStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const items = messages.data?.items ?? [];
|
||||
if (items.length) listRef.current?.scrollToEnd({ animated: true });
|
||||
}, [messages.data?.items.length]);
|
||||
|
||||
const handleSendError = async (reason: unknown) => {
|
||||
if (isMessageBlockedError(reason)) {
|
||||
setError(undefined);
|
||||
await messages.refetch();
|
||||
return true;
|
||||
}
|
||||
setError(reason);
|
||||
return false;
|
||||
};
|
||||
|
||||
const sendText = async (value = text, messageKey = Crypto.randomUUID()) => {
|
||||
const normalized = normalizeMessageText(value);
|
||||
if (!normalized || !dialogId) return;
|
||||
const maxLength = config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH;
|
||||
if (normalized.length > maxLength) {
|
||||
setError(new Error(`Сообщение слишком длинное. Максимум — ${maxLength} символов.`));
|
||||
return false;
|
||||
}
|
||||
setSending(true); setError(undefined);
|
||||
try {
|
||||
const message = await dialogApi.sendText(dialogId, normalized, messageKey);
|
||||
merge([message]); setText("");
|
||||
return true;
|
||||
} catch (reason) {
|
||||
return await handleSendError(reason);
|
||||
}
|
||||
finally { setSending(false); }
|
||||
};
|
||||
|
||||
const chooseFile = async () => {
|
||||
try {
|
||||
const [file] = await pickFiles({ mimeTypes: ["image/*", "application/pdf"] });
|
||||
if (file) await sendFile(file);
|
||||
} catch (reason) {
|
||||
setError(reason);
|
||||
}
|
||||
};
|
||||
|
||||
const sendFile = async (file: NativeFile, messageKey = Crypto.randomUUID()) => {
|
||||
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.mimeType)) {
|
||||
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, messageKey);
|
||||
merge([message]);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
return await handleSendError(reason);
|
||||
}
|
||||
finally { setSending(false); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogId || app.authStatus !== "authenticated") return;
|
||||
const textIntent = loadPendingTextIntent();
|
||||
const fileIntent = loadPendingFileIntent(dialogId);
|
||||
const intentKey = textIntent?.dialogId === dialogId
|
||||
? textIntent.messageKey
|
||||
: fileIntent?.messageKey;
|
||||
if (!intentKey || pendingStarted.current === intentKey) return;
|
||||
pendingStarted.current = intentKey;
|
||||
|
||||
const submit = async () => {
|
||||
const completed = textIntent?.dialogId === dialogId
|
||||
? await sendText(textIntent.text, textIntent.messageKey)
|
||||
: fileIntent
|
||||
? await sendFile(fileIntent.file, fileIntent.messageKey)
|
||||
: true;
|
||||
if (completed) {
|
||||
await Promise.all([
|
||||
clearPendingTextIntent(),
|
||||
clearPendingFileIntent(),
|
||||
]);
|
||||
setRetryPending(undefined);
|
||||
return;
|
||||
}
|
||||
setRetryPending(() => () => {
|
||||
setRetryPending(undefined);
|
||||
void submit().catch(setError);
|
||||
});
|
||||
};
|
||||
void submit().catch(setError);
|
||||
}, [dialogId, app.authStatus]);
|
||||
|
||||
const getAttachmentUrl = useCallback(async (attachmentId: string) => {
|
||||
const result = await profileApi.attachmentUrl(dialogId, attachmentId);
|
||||
return result.download_url;
|
||||
}, [dialogId]);
|
||||
|
||||
const closed = dialog.data?.status === "closed";
|
||||
const items = messages.data?.items ?? [];
|
||||
const subtitle = dialog.data?.status ? (statusLabel[dialog.data.status] ?? "Онлайн") : "Онлайн";
|
||||
|
||||
if (app.authStatus !== "authenticated") {
|
||||
return (
|
||||
<ScreenShell>
|
||||
<ChatScreenHeader title="Чат" subtitle="Требуется вход" />
|
||||
<GuestAuthGate
|
||||
icon="message-circle"
|
||||
title="Чат доступен после входа"
|
||||
description="Авторизуйтесь, чтобы переписываться с оператором и получать ответы."
|
||||
/>
|
||||
</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}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
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={() => void chooseFile()}
|
||||
onChangeText={setText}
|
||||
onSubmit={() => void sendText()}
|
||||
placeholder="Напишите сообщение..."
|
||||
sending={sending}
|
||||
value={text}
|
||||
maxLength={config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH}
|
||||
/>
|
||||
{error && (
|
||||
<View style={{ paddingHorizontal: spacing.lg, paddingBottom: spacing.sm }}>
|
||||
<ErrorNotice
|
||||
error={error}
|
||||
{...(retryPending ? { retry: retryPending } : {})}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { AppHeader } from "../../src/components/AppHeader";
|
||||
import { ChatScreenHeader } from "../../src/components/ChatScreenHeader";
|
||||
import { GuestAuthGate } from "../../src/components/GuestAuthGate";
|
||||
import { ScreenShell } from "../../src/components/ScreenShell";
|
||||
import {
|
||||
bindPendingFileIntent,
|
||||
bindPendingTextIntent,
|
||||
loadPendingTextIntent,
|
||||
pendingDialogKey,
|
||||
} from "../../src/pending-intent";
|
||||
import { dialogApi } from "../../src/services";
|
||||
import { ErrorNotice, Loading, styles } from "../../src/ui";
|
||||
import { spacing } from "../../src/theme";
|
||||
|
||||
export default function DialogsScreen() {
|
||||
const app = useApp();
|
||||
const router = useRouter();
|
||||
const [requestKey, setRequestKey] = useState<string>();
|
||||
const [bindError, setBindError] = useState<unknown>();
|
||||
const [bindAttempt, setBindAttempt] = useState(0);
|
||||
useEffect(() => {
|
||||
if (app.authStatus === "authenticated" && !requestKey) {
|
||||
setRequestKey(pendingDialogKey() ?? Crypto.randomUUID());
|
||||
}
|
||||
}, [app.authStatus, requestKey]);
|
||||
const chat = useQuery({
|
||||
queryKey: ["current-dialog", requestKey],
|
||||
queryFn: () => dialogApi.create(requestKey!),
|
||||
enabled: app.authStatus === "authenticated" && Boolean(requestKey),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.data) {
|
||||
const bindAndOpen = async () => {
|
||||
const intent = loadPendingTextIntent();
|
||||
if (intent && !intent.dialogId) {
|
||||
await bindPendingTextIntent(intent, chat.data.dialog_id);
|
||||
}
|
||||
await bindPendingFileIntent(chat.data.dialog_id);
|
||||
router.replace(`/dialogs/${chat.data.dialog_id}`);
|
||||
};
|
||||
void bindAndOpen().catch(setBindError);
|
||||
}
|
||||
}, [bindAttempt, chat.data, router]);
|
||||
|
||||
if (app.authStatus !== "authenticated") {
|
||||
return (
|
||||
<ScreenShell>
|
||||
<ChatScreenHeader title="Чат" subtitle="Требуется вход" />
|
||||
<GuestAuthGate
|
||||
icon="message-circle"
|
||||
title="Чат доступен после входа"
|
||||
description="Авторизуйтесь, чтобы переписываться с оператором и получать ответы."
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
<AppHeader />
|
||||
<View style={{ flex: 1, padding: spacing.lg, justifyContent: "center" }}>
|
||||
<Text accessibilityRole="header" style={styles.title}>Открываем чат…</Text>
|
||||
{chat.isLoading && <Loading />}
|
||||
{Boolean(chat.error || bindError) && (
|
||||
<ErrorNotice
|
||||
error={chat.error ?? bindError}
|
||||
retry={() => {
|
||||
setBindError(undefined);
|
||||
if (chat.error) void chat.refetch();
|
||||
else setBindAttempt((attempt) => attempt + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useApp } from "../src/app-context";
|
||||
import { AppHeader } from "../src/components/AppHeader";
|
||||
import { ChatInputBar } from "../src/components/ChatInputBar";
|
||||
import { ConsentModal } from "../src/components/ConsentModal";
|
||||
import { HanLogo } from "../src/components/HanLogo";
|
||||
import { NotificationCarousel } from "../src/components/NotificationCarousel";
|
||||
import { PopularQuestionsList } from "../src/components/PopularQuestionsList";
|
||||
import { QuickActions } from "../src/components/QuickActions";
|
||||
import { ScreenShell } from "../src/components/ScreenShell";
|
||||
import {
|
||||
clearPendingTextIntent,
|
||||
createPendingTextIntent,
|
||||
savePendingTextIntent,
|
||||
savePendingFileIntent,
|
||||
type PendingTextIntent,
|
||||
} from "../src/pending-intent";
|
||||
import { DEFAULT_MESSAGE_MAX_LENGTH, normalizeMessageText } from "../src/message-text";
|
||||
import { pickFiles, type NativeFile } from "../src/native-files";
|
||||
import { publicApi } from "../src/services";
|
||||
import type { Consents } from "../src/types";
|
||||
import { 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 [pending, setPending] = useState<PendingTextIntent | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [sendError, setSendError] = useState<unknown>();
|
||||
const [sending] = useState(false);
|
||||
const [afterNotificationAuth, setAfterNotificationAuth] = useState<(() => Promise<void>) | undefined>();
|
||||
const { authorize: authorizeParam } = useLocalSearchParams<{ authorize?: string }>();
|
||||
const handledAuthorizeParam = useRef(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (authorizeParam === "1" && !handledAuthorizeParam.current && authStatus !== "authenticated") {
|
||||
handledAuthorizeParam.current = true;
|
||||
setConsentOpen(true);
|
||||
}
|
||||
}, [authStatus, authorizeParam]);
|
||||
|
||||
const openChatWithText = async (intent: PendingTextIntent) => {
|
||||
setSendError(undefined);
|
||||
await savePendingTextIntent(intent);
|
||||
setMessage("");
|
||||
setPending(null);
|
||||
router.push("/dialogs?pending=1");
|
||||
};
|
||||
|
||||
const send = async (text: string) => {
|
||||
const normalized = normalizeMessageText(text);
|
||||
if (!normalized) return;
|
||||
const maxLength = config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH;
|
||||
if (normalized.length > maxLength) {
|
||||
setSendError(new Error(`Сообщение слишком длинное. Максимум — ${maxLength} символов.`));
|
||||
return;
|
||||
}
|
||||
const intent = createPendingTextIntent(normalized);
|
||||
try {
|
||||
if (authStatus !== "authenticated") {
|
||||
await savePendingTextIntent(intent);
|
||||
setPending(intent);
|
||||
setConsentOpen(true);
|
||||
return;
|
||||
}
|
||||
await openChatWithText(intent);
|
||||
} catch (error) {
|
||||
setSendError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const chooseFile = async () => {
|
||||
if (authStatus !== "authenticated") {
|
||||
setConsentOpen(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [file] = await pickFiles({ mimeTypes: ["image/*", "application/pdf"] });
|
||||
if (file) await sendFile(file);
|
||||
} catch (error) {
|
||||
setSendError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const sendFile = async (file: NativeFile) => {
|
||||
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.mimeType)) {
|
||||
setSendError(new Error("Недопустимый тип файла или превышен допустимый размер."));
|
||||
return;
|
||||
}
|
||||
setSendError(undefined);
|
||||
await savePendingFileIntent({
|
||||
file,
|
||||
dialogKey: Crypto.randomUUID(),
|
||||
messageKey: Crypto.randomUUID(),
|
||||
});
|
||||
router.push("/dialogs?pending=1");
|
||||
};
|
||||
|
||||
const accept = async (accepted: {
|
||||
personal_data: boolean;
|
||||
user_agreement: boolean;
|
||||
marketing: boolean;
|
||||
}) => {
|
||||
const versions = config.data?.consents;
|
||||
const consents: Consents = {
|
||||
personal_data: { accepted: accepted.personal_data, version: versions?.personal_data?.version ?? "current" },
|
||||
user_agreement: { accepted: accepted.user_agreement, version: versions?.user_agreement?.version ?? "current" },
|
||||
marketing: { accepted: accepted.marketing, version: versions?.marketing?.version ?? "current" },
|
||||
};
|
||||
setConsentOpen(false);
|
||||
try {
|
||||
const authorized = await authorize(consents);
|
||||
if (authorized && pending) await openChatWithText(pending);
|
||||
else if (authorized && afterNotificationAuth) await afterNotificationAuth();
|
||||
} catch (error) {
|
||||
setSendError(error);
|
||||
} finally {
|
||||
setAfterNotificationAuth(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
contentContainerStyle={{ paddingBottom: spacing }}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{(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}
|
||||
<NotificationCarousel
|
||||
authenticated={authStatus === "authenticated"}
|
||||
autoplay={config.data?.notification?.carousel_autoplay_enabled ?? false}
|
||||
autoplayIntervalMs={config.data?.notification?.carousel_autoplay_interval_ms ?? 5000}
|
||||
requireAuth={(afterAuth) => {
|
||||
setAfterNotificationAuth(afterAuth ? () => afterAuth : undefined);
|
||||
setConsentOpen(true);
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
<PopularQuestionsList questions={questions} onSelect={(text) => { setMessage(text); void send(text); }} />
|
||||
<ChatInputBar
|
||||
disabled={sending}
|
||||
onAttach={() => void chooseFile()}
|
||||
onChangeText={setMessage}
|
||||
onSubmit={() => void send(message)}
|
||||
sending={sending}
|
||||
value={message}
|
||||
maxLength={config.data?.messages?.max_text_length ?? DEFAULT_MESSAGE_MAX_LENGTH}
|
||||
/>
|
||||
<QuickActions
|
||||
authenticated={authStatus === "authenticated"}
|
||||
phone={config.data?.operator.call_phone}
|
||||
/>
|
||||
{Boolean(sendError) && (
|
||||
<View style={{ paddingHorizontal: 16, paddingBottom: 8 }}>
|
||||
<ErrorNotice error={sendError} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{consentOpen && (
|
||||
<ConsentModal
|
||||
consents={config.data?.consents}
|
||||
onAccept={(accepted) => void accept(accepted)}
|
||||
onCancel={() => {
|
||||
setConsentOpen(false);
|
||||
setPending(null);
|
||||
setAfterNotificationAuth(undefined);
|
||||
void clearPendingTextIntent().catch(setSendError);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const spacing = 16;
|
||||
@@ -0,0 +1,284 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { ApiError } from "../../src/api";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { ScreenShell } from "../../src/components/ScreenShell";
|
||||
import { notificationApi, notificationKeys, uploadDraftApi } from "../../src/notification-api";
|
||||
import { formatNotificationPrice, typeMap } from "../../src/notification-presenter";
|
||||
import { downloadAndOpen, pickFiles, type NativeFile } from "../../src/native-files";
|
||||
import { publicApi } from "../../src/services";
|
||||
import { colors, radii, spacing } from "../../src/theme";
|
||||
import type { NotificationButton, UploadDraft } from "../../src/types";
|
||||
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
|
||||
|
||||
export default function NotificationDetailScreen() {
|
||||
const { id = "" } = useLocalSearchParams<{ id: string }>();
|
||||
const { authStatus } = useApp();
|
||||
const authenticated = authStatus === "authenticated";
|
||||
const router = useRouter();
|
||||
const client = useQueryClient();
|
||||
const readSent = useRef(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const detail = useQuery({
|
||||
queryKey: notificationKeys.detail(id),
|
||||
queryFn: () => notificationApi.detail(id),
|
||||
enabled: authenticated && Boolean(id),
|
||||
retry: (count, reason) => !(reason instanceof ApiError && reason.status === 404) && count < 1,
|
||||
});
|
||||
const catalog = useQuery({
|
||||
queryKey: notificationKeys.catalog,
|
||||
queryFn: notificationApi.catalog,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
||||
const byCode = useMemo(() => typeMap(catalog.data ?? []), [catalog.data]);
|
||||
const type = detail.data ? byCode.get(detail.data.notification_type) : undefined;
|
||||
const canUpload = Boolean(detail.data?.details?.send_documents);
|
||||
const drafts = useQuery({
|
||||
queryKey: ["uploads", "notification", id],
|
||||
queryFn: () => uploadDraftApi.list(id),
|
||||
enabled: authenticated && Boolean(id) && canUpload,
|
||||
});
|
||||
const pending = drafts.data ?? detail.data?.details?.pending_documents ?? [];
|
||||
const closed = detail.data?.lifecycle_status === "closed";
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail.data || readSent.current || detail.data.is_read !== false) return;
|
||||
readSent.current = true;
|
||||
void notificationApi.read(id).then((state) => {
|
||||
client.setQueryData(notificationKeys.detail(id), { ...detail.data, ...state });
|
||||
client.setQueryData(notificationKeys.counter, { unread_count: state.unread_count });
|
||||
}).catch(setError);
|
||||
}, [client, detail.data, id]);
|
||||
|
||||
const pressButton = useMutation({
|
||||
mutationFn: (button: NotificationButton) => notificationApi.button(id, button.code),
|
||||
onSuccess: async (state) => {
|
||||
client.setQueryData(notificationKeys.counter, { unread_count: state.unread_count });
|
||||
await client.invalidateQueries({ queryKey: ["notifications"] });
|
||||
router.replace("/notifications");
|
||||
},
|
||||
onError: setError,
|
||||
});
|
||||
const removeDraft = useMutation({
|
||||
mutationFn: uploadDraftApi.remove,
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ["uploads", "notification", id] }),
|
||||
onError: setError,
|
||||
});
|
||||
|
||||
const chooseFile = async () => {
|
||||
try {
|
||||
const files = await pickFiles({
|
||||
multiple: true,
|
||||
mimeTypes: config.data?.attachments.allowed_mime_types,
|
||||
});
|
||||
if (files.length) await uploadFiles(files);
|
||||
} catch (reason) {
|
||||
setError(reason);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: NativeFile[]) => {
|
||||
const limits = config.data?.attachments;
|
||||
const maxBytes = (limits?.max_size_mb ?? 5) * 1024 * 1024;
|
||||
const allowed = limits?.allowed_mime_types ?? [];
|
||||
if (pending.length + files.length > 10) {
|
||||
setError(new Error("К одному уведомлению можно приложить не более 10 файлов."));
|
||||
return;
|
||||
}
|
||||
const invalid = files.find((file) => file.size > maxBytes || (allowed.length > 0 && !allowed.includes(file.mimeType)));
|
||||
if (invalid) {
|
||||
setError(new Error(`Файл «${invalid.name}» имеет недопустимый тип или размер.`));
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
try {
|
||||
for (const file of files) await uploadDraftApi.upload(id, file);
|
||||
await drafts.refetch();
|
||||
await detail.refetch();
|
||||
} catch (reason) {
|
||||
setError(reason);
|
||||
}
|
||||
};
|
||||
|
||||
const download = async (documentId: string) => {
|
||||
setError(undefined);
|
||||
try {
|
||||
const result = await notificationApi.documentUrl(id, documentId);
|
||||
const title = detail.data?.details?.documents?.find((item) => item.document_id === documentId)?.title;
|
||||
await downloadAndOpen(result.download_url, title ?? "document");
|
||||
await Promise.all([detail.refetch(), client.invalidateQueries({ queryKey: ["notifications"] })]);
|
||||
} catch (reason) {
|
||||
setError(reason);
|
||||
}
|
||||
};
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<ScreenShell>
|
||||
<DetailHeader title="Уведомление" onBack={() => router.replace("/notifications")} />
|
||||
<View style={local.center}>
|
||||
<Text style={styles.text}>Для просмотра уведомления требуется авторизация.</Text>
|
||||
<Button title="Перейти в Центр" onPress={() => router.replace("/notifications")} />
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const unavailable = detail.error instanceof ApiError && detail.error.status === 404;
|
||||
const notification = detail.data;
|
||||
const details = notification?.details;
|
||||
const price = formatNotificationPrice(notification?.price);
|
||||
const oldPrice = formatNotificationPrice(notification?.old_price);
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
<DetailHeader title={type?.label ?? "Уведомление"} onBack={() => router.back()} />
|
||||
<ScrollView contentContainerStyle={local.content}>
|
||||
{(detail.isLoading || catalog.isLoading) && <Loading />}
|
||||
{unavailable ? (
|
||||
<View style={local.empty}>
|
||||
<Feather name="slash" size={32} color={colors.mutedForeground} />
|
||||
<Text style={styles.title}>Уведомление недоступно</Text>
|
||||
<Text style={styles.muted}>Возможно, оно уже закрыто или было удалено.</Text>
|
||||
</View>
|
||||
) : (detail.error || catalog.error) ? (
|
||||
<ErrorNotice error={detail.error ?? catalog.error} retry={() => { void detail.refetch(); void catalog.refetch(); }} />
|
||||
) : notification ? (
|
||||
<>
|
||||
{closed && <Text style={styles.error}>Уведомление больше не актуально. Действия недоступны.</Text>}
|
||||
{details?.deadline ? (
|
||||
<View style={local.deadline}>
|
||||
<Feather name="clock" size={16} color={colors.warning} />
|
||||
<Text style={styles.text}>Срок: {new Date(details.deadline).toLocaleString("ru-RU")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<Text accessibilityRole="header" style={styles.title}>{details?.details_header ?? notification.header}</Text>
|
||||
{details?.details_text || notification.text ? <Text style={styles.text}>{details?.details_text ?? notification.text}</Text> : null}
|
||||
{price ? (
|
||||
<View style={local.priceRow}>
|
||||
<Text style={local.price}>{price}</Text>
|
||||
{oldPrice ? <Text style={local.oldPrice}>{oldPrice}</Text> : null}
|
||||
</View>
|
||||
) : null}
|
||||
{details?.todo_header ? <Text style={styles.heading}>{details.todo_header}</Text> : null}
|
||||
{details?.todo_plan?.map((step) => (
|
||||
<View key={`${step.number}-${step.text}`} style={local.step}>
|
||||
<View style={local.stepNumber}><Text style={local.stepNumberText}>{step.number}</Text></View>
|
||||
<Text style={[styles.text, { flex: 1 }]}>{step.text}</Text>
|
||||
</View>
|
||||
))}
|
||||
{details?.documents?.length ? (
|
||||
<View style={local.block}>
|
||||
<Text style={styles.heading}>Документы</Text>
|
||||
{details.documents.map((document) => (
|
||||
<Pressable key={document.document_id} onPress={() => void download(document.document_id)} style={local.file}>
|
||||
<Feather name="file-text" size={20} color={colors.info} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.text}>{document.title}</Text>
|
||||
<Text style={styles.muted}>{formatBytes(document.size_bytes)}</Text>
|
||||
</View>
|
||||
<Feather name="download" size={18} color={colors.foreground} />
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{canUpload ? (
|
||||
<View style={local.block}>
|
||||
<Text style={styles.heading}>Приложить документы</Text>
|
||||
<Text style={styles.muted}>Черновики сохраняются, пока вы не отправите или не удалите их.</Text>
|
||||
{drafts.isLoading && <Loading />}
|
||||
{pending.map((draft) => (
|
||||
<DraftRow
|
||||
key={draft.draft_id}
|
||||
draft={draft}
|
||||
disabled={removeDraft.isPending || closed}
|
||||
onRemove={() => removeDraft.mutate(draft.draft_id)}
|
||||
/>
|
||||
))}
|
||||
<Button title="Добавить файлы" secondary disabled={closed || pending.length >= 10} onPress={() => void chooseFile()} />
|
||||
</View>
|
||||
) : null}
|
||||
<View style={local.buttons}>
|
||||
{type?.button_primary ? (
|
||||
<Button
|
||||
title={type.button_primary.label}
|
||||
disabled={closed || pressButton.isPending || (type.button_primary.code === "send_docs" && !pending.some((draft) => draft.scan_status === "clean"))}
|
||||
onPress={() => pressButton.mutate(type.button_primary!)}
|
||||
/>
|
||||
) : null}
|
||||
{type?.button_secondary ? (
|
||||
<Button
|
||||
secondary
|
||||
title={type.button_secondary.label}
|
||||
disabled={closed || pressButton.isPending}
|
||||
onPress={() => pressButton.mutate(type.button_secondary!)}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{error && <ErrorNotice error={error} />}
|
||||
</>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailHeader({ title, onBack }: { title: string; onBack: () => void }) {
|
||||
return (
|
||||
<View style={local.header}>
|
||||
<Pressable accessibilityLabel="Назад" accessibilityRole="button" onPress={onBack} style={local.back}>
|
||||
<Feather name="arrow-left" size={20} color={colors.foreground} />
|
||||
</Pressable>
|
||||
<Text numberOfLines={1} style={[styles.heading, { flex: 1 }]}>{title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftRow({ draft, disabled, onRemove }: { draft: UploadDraft; disabled: boolean; onRemove: () => void }) {
|
||||
const title = draft.title;
|
||||
const status = {
|
||||
pending: "Проверяется",
|
||||
clean: "Готов к отправке",
|
||||
infected: "Файл отклонён",
|
||||
failed: "Ошибка проверки",
|
||||
}[draft.scan_status];
|
||||
return (
|
||||
<View style={local.file}>
|
||||
<Feather name={draft.scan_status === "clean" ? "check-circle" : "file"} size={20} color={draft.scan_status === "clean" ? colors.success : colors.warning} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.text}>{title}</Text>
|
||||
<Text style={styles.muted}>{status} · {formatBytes(draft.size_bytes)}</Text>
|
||||
</View>
|
||||
<Pressable disabled={disabled} accessibilityLabel={`Удалить ${title}`} onPress={onRemove}>
|
||||
<Feather name="trash-2" size={18} color={colors.destructive} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} КБ`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
|
||||
}
|
||||
|
||||
const local = StyleSheet.create({
|
||||
header: { flexDirection: "row", alignItems: "center", gap: spacing.md, padding: spacing.lg, borderBottomWidth: 1, borderBottomColor: colors.border },
|
||||
back: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
|
||||
content: { padding: spacing.lg, gap: spacing.md, paddingBottom: 40 },
|
||||
center: { flex: 1, justifyContent: "center", gap: spacing.md, padding: spacing.lg },
|
||||
empty: { alignItems: "center", gap: spacing.sm, paddingVertical: 48 },
|
||||
deadline: { flexDirection: "row", alignItems: "center", gap: spacing.sm, borderRadius: radii.md, backgroundColor: "#fff7df", padding: spacing.md },
|
||||
priceRow: { flexDirection: "row", alignItems: "baseline", gap: spacing.sm },
|
||||
price: { fontSize: 22, fontWeight: "700", color: colors.foreground },
|
||||
oldPrice: { fontSize: 14, color: colors.mutedForeground, textDecorationLine: "line-through" },
|
||||
step: { flexDirection: "row", alignItems: "flex-start", gap: spacing.md },
|
||||
stepNumber: { width: 28, height: 28, borderRadius: radii.full, alignItems: "center", justifyContent: "center", backgroundColor: colors.primary },
|
||||
stepNumberText: { color: colors.primaryForeground, fontSize: 13, fontWeight: "700" },
|
||||
block: { gap: spacing.sm, paddingVertical: spacing.sm },
|
||||
file: { flexDirection: "row", alignItems: "center", gap: spacing.md, borderWidth: 1, borderColor: colors.border, borderRadius: radii.md, padding: spacing.md, backgroundColor: colors.card },
|
||||
buttons: { gap: spacing.sm, paddingTop: spacing.sm },
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { GuestAuthGate } from "../../src/components/GuestAuthGate";
|
||||
import { NotificationCard } from "../../src/components/NotificationCard";
|
||||
import { ScreenShell } from "../../src/components/ScreenShell";
|
||||
import { useNotificationAction } from "../../src/notification-actions";
|
||||
import { notificationApi, notificationKeys } from "../../src/notification-api";
|
||||
import { typeMap } from "../../src/notification-presenter";
|
||||
import { colors, radii, spacing } from "../../src/theme";
|
||||
import { ErrorNotice, Loading, styles } from "../../src/ui";
|
||||
|
||||
export default function NotificationCenterScreen() {
|
||||
const { authStatus } = useApp();
|
||||
const authenticated = authStatus === "authenticated";
|
||||
const router = useRouter();
|
||||
const [actionError, setActionError] = useState<unknown>();
|
||||
const catalog = useQuery({
|
||||
queryKey: notificationKeys.catalog,
|
||||
queryFn: notificationApi.catalog,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const notifications = useQuery({
|
||||
queryKey: notificationKeys.center,
|
||||
queryFn: () => notificationApi.list("center"),
|
||||
enabled: authenticated,
|
||||
});
|
||||
const byCode = useMemo(() => typeMap(catalog.data ?? []), [catalog.data]);
|
||||
const action = useNotificationAction({
|
||||
authenticated,
|
||||
onError: setActionError,
|
||||
requireAuth: () => router.replace({ pathname: "/", params: { authorize: "1" } }),
|
||||
});
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
<View style={local.header}>
|
||||
<Pressable accessibilityLabel="Назад" accessibilityRole="button" onPress={() => router.back()} style={local.back}>
|
||||
<Feather name="arrow-left" size={20} color={colors.foreground} />
|
||||
</Pressable>
|
||||
<Text accessibilityRole="header" style={styles.title}>Центр уведомлений</Text>
|
||||
</View>
|
||||
|
||||
{!authenticated ? (
|
||||
<GuestAuthGate
|
||||
icon="bell"
|
||||
title="Уведомления доступны после входа"
|
||||
description="Авторизуйтесь, чтобы видеть важные напоминания, документы и статусы услуг."
|
||||
/>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={local.content}>
|
||||
{(notifications.isLoading || catalog.isLoading) && <Loading />}
|
||||
{(notifications.error || catalog.error) && (
|
||||
<ErrorNotice
|
||||
error={notifications.error ?? catalog.error}
|
||||
retry={() => { void notifications.refetch(); void catalog.refetch(); }}
|
||||
/>
|
||||
)}
|
||||
{!notifications.isLoading && !notifications.error && notifications.data?.length === 0 && (
|
||||
<View style={local.empty}>
|
||||
<Feather name="check-circle" size={32} color={colors.success} />
|
||||
<Text style={styles.heading}>Новых уведомлений нет</Text>
|
||||
<Text style={styles.muted}>Здесь появятся важные сообщения и задачи.</Text>
|
||||
</View>
|
||||
)}
|
||||
{notifications.data?.map((item) => (
|
||||
<NotificationCard
|
||||
key={item.id}
|
||||
compact
|
||||
item={item}
|
||||
type={byCode.get(item.notification_type)}
|
||||
onCta={() => void action(item, byCode.get(item.notification_type))}
|
||||
/>
|
||||
))}
|
||||
{Boolean(actionError) && <ErrorNotice error={actionError} />}
|
||||
</ScrollView>
|
||||
)}
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const local = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
padding: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
back: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
|
||||
content: { padding: spacing.lg, gap: spacing.md, paddingBottom: 40 },
|
||||
empty: { alignItems: "center", gap: spacing.sm, paddingVertical: 48 },
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useRouter } from "expo-router";
|
||||
import React, { useState } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { useApp } from "../src/app-context";
|
||||
import { AccordionSection } from "../src/components/AccordionSection";
|
||||
import { GuestAuthGate } from "../src/components/GuestAuthGate";
|
||||
import { ScreenShell } from "../src/components/ScreenShell";
|
||||
import { isProduction } from "../src/config";
|
||||
import { downloadAndOpen } from "../src/native-files";
|
||||
import { profileApi } from "../src/services";
|
||||
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 });
|
||||
const [downloadError, setDownloadError] = useState<unknown>();
|
||||
|
||||
const download = async (id: string) => {
|
||||
try {
|
||||
const result = await profileApi.documentUrl(id);
|
||||
const name = documents.data?.items.find((item) => item.document_id === id)?.name;
|
||||
await downloadAndOpen(result.download_url, name ?? "document");
|
||||
} catch (error) { setDownloadError(error); }
|
||||
};
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<ScreenShell>
|
||||
<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>
|
||||
<GuestAuthGate
|
||||
icon="user"
|
||||
title="Профиль доступен после входа"
|
||||
description="Авторизуйтесь, чтобы видеть личные данные и документы компании."
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const personal = profile.data?.profile.personal_data;
|
||||
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>
|
||||
|
||||
<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()} />
|
||||
{Boolean(downloadError) && <ErrorNotice error={downloadError} />}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
Reference in New Issue
Block a user