Накатил человеческий дизайн
This commit is contained in:
@@ -3,11 +3,12 @@ import { StatusBar } from "expo-status-bar";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { AppProvider } from "../src/app-context";
|
import { AppProvider } from "../src/app-context";
|
||||||
|
import { colors } from "../src/theme";
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return <SafeAreaProvider>
|
return <SafeAreaProvider>
|
||||||
<AppProvider>
|
<AppProvider>
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: "#f6f8fb" }}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||||
<StatusBar style="dark" />
|
<StatusBar style="dark" />
|
||||||
<Stack screenOptions={{ headerShown: false }} />
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|||||||
@@ -1,22 +1,44 @@
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { useApp } from "../../src/app-context";
|
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 type { Consents } from "../../src/types";
|
||||||
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
|
import { Button, ErrorNotice, styles } from "../../src/ui";
|
||||||
|
|
||||||
export default function AuthCallbackScreen() {
|
export default function AuthCallbackScreen() {
|
||||||
const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>();
|
const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>();
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
|
const router = useRouter();
|
||||||
const [error, setError] = useState<unknown>();
|
const [error, setError] = useState<unknown>();
|
||||||
const completionStarted = useRef(false);
|
const completionStarted = useRef(false);
|
||||||
const retry = () => {
|
|
||||||
|
const complete = async () => {
|
||||||
if (!params.code || !params.state || typeof window === "undefined") return;
|
if (!params.code || !params.state || typeof window === "undefined") return;
|
||||||
const raw = window.sessionStorage.getItem("han.pending-consents");
|
const raw = window.sessionStorage.getItem("han.pending-consents");
|
||||||
if (!raw) return;
|
if (!raw) throw new Error("Не найдены локально принятые согласия. Начните вход заново.");
|
||||||
setError(undefined);
|
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(() => {
|
useEffect(() => {
|
||||||
if (completionStarted.current) return;
|
if (completionStarted.current) return;
|
||||||
if (params.error) {
|
if (params.error) {
|
||||||
@@ -26,19 +48,23 @@ export default function AuthCallbackScreen() {
|
|||||||
}
|
}
|
||||||
if (!params.code || !params.state) return;
|
if (!params.code || !params.state) return;
|
||||||
completionStarted.current = true;
|
completionStarted.current = true;
|
||||||
const raw = typeof window !== "undefined" ? window.sessionStorage.getItem("han.pending-consents") : null;
|
void complete().catch(setError);
|
||||||
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]);
|
}, [params.code, params.state, params.error]);
|
||||||
return <View style={styles.page}>
|
|
||||||
<Text accessibilityRole="header" style={styles.title}>Завершение входа</Text>
|
return (
|
||||||
{!error && <Loading />}
|
<ScreenShell>
|
||||||
{error && <><ErrorNotice error={error} /><Button title="Повторить bootstrap" onPress={retry} /></>}
|
{!error ? (
|
||||||
</View>;
|
<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 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 { getDiagnostics, sessionMemory } from "../src/api";
|
||||||
import { getTokenInfo } from "../src/auth";
|
import { getTokenInfo } from "../src/auth";
|
||||||
import { useApp } from "../src/app-context";
|
import { useApp } from "../src/app-context";
|
||||||
import { isProduction } from "../src/config";
|
import { isProduction } from "../src/config";
|
||||||
import { getRealtimeDiagnostics } from "../src/realtime";
|
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() {
|
export default function DiagnosticsScreen() {
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
|
const router = useRouter();
|
||||||
const [, render] = useState(0);
|
const [, render] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => render((value) => value + 1), 1000);
|
const timer = setInterval(() => render((value) => value + 1), 1000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
if (isProduction) return <ScrollView contentContainerStyle={styles.page}>
|
|
||||||
<Header status={app.authStatus} realtime={app.realtimeState} />
|
if (isProduction) {
|
||||||
<Text accessibilityRole="header" style={styles.title}>Диагностика</Text>
|
return (
|
||||||
<Text style={styles.error}>Экран отключён в production.</Text>
|
<ScreenShell>
|
||||||
</ScrollView>;
|
<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();
|
const token = getTokenInfo();
|
||||||
return <ScrollView contentContainerStyle={styles.page}>
|
return (
|
||||||
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} />
|
<ScreenShell>
|
||||||
<Text accessibilityRole="header" style={styles.title}>Безопасная диагностика</Text>
|
<ScrollView contentContainerStyle={{ padding: spacing.lg, gap: spacing.lg, paddingBottom: spacing.xl }}>
|
||||||
<View style={styles.card}>
|
<View style={stylesLocal.topBar}>
|
||||||
<Text style={styles.text}>Режим: {app.authStatus}</Text>
|
<Pressable onPress={() => router.back()} style={stylesLocal.backButton}>
|
||||||
<Text style={styles.text}>Realtime: {app.realtimeState}</Text>
|
<Feather name="arrow-left" size={20} color={colors.foreground} />
|
||||||
<Text style={styles.text}>UX-сессия: {sessionMemory.id ?? "нет"}</Text>
|
</Pressable>
|
||||||
<Text style={styles.text}>Access token истекает: {token ? new Date(token.expiresAt).toLocaleString("ru-RU") : "нет"}</Text>
|
<Text accessibilityRole="header" style={styles.title}>Безопасная диагностика</Text>
|
||||||
{getRealtimeDiagnostics().map((item) =>
|
</View>
|
||||||
<Text key={item.dialog} style={styles.text}>Cursor {item.dialog}: {item.cursor}</Text>,
|
<View style={styles.card}>
|
||||||
)}
|
<Text style={styles.text}>Режим: {app.authStatus}</Text>
|
||||||
<Text style={styles.muted}>Токены, OTP, персональные данные, сообщения и presigned URL здесь никогда не отображаются.</Text>
|
<Text style={styles.text}>Realtime: {app.realtimeState}</Text>
|
||||||
</View>
|
<Text style={styles.text}>UX-сессия: {sessionMemory.id ?? "нет"}</Text>
|
||||||
<View style={styles.card}>
|
<Text style={styles.text}>Access token истекает: {token ? new Date(token.expiresAt).toLocaleString("ru-RU") : "нет"}</Text>
|
||||||
<Text accessibilityRole="header" style={styles.heading}>Последние запросы</Text>
|
{getRealtimeDiagnostics().map((item) =>
|
||||||
{!getDiagnostics().length && <Text style={styles.muted}>Запросов ещё нет.</Text>}
|
<Text key={item.dialog} style={styles.text}>Cursor {item.dialog}: {item.cursor}</Text>,
|
||||||
{getDiagnostics().map((item) => <View key={`${item.at}-${item.requestId}`} style={styles.row}>
|
)}
|
||||||
<Text style={styles.badge}>{item.status}</Text>
|
<Text style={styles.muted}>Токены, OTP, персональные данные, сообщения и presigned URL здесь никогда не отображаются.</Text>
|
||||||
<Text style={styles.text}>{item.method} {item.path}</Text>
|
</View>
|
||||||
<Text style={styles.muted}>request_id: {item.requestId}</Text>
|
<View style={styles.card}>
|
||||||
</View>)}
|
<Text accessibilityRole="header" style={styles.heading}>Последние запросы</Text>
|
||||||
</View>
|
{!getDiagnostics().length && <Text style={styles.muted}>Запросов ещё нет.</Text>}
|
||||||
</ScrollView>;
|
{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 { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Platform, ScrollView, Text, View } from "react-native";
|
import { FlatList, Platform, Text, View } from "react-native";
|
||||||
import { useApp } from "../../src/app-context";
|
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 { RealtimeClient, reconcileMessages } from "../../src/realtime";
|
||||||
import { dialogApi, profileApi, publicApi, uploadAttachment } from "../../src/services";
|
import { dialogApi, profileApi, publicApi, uploadAttachment } from "../../src/services";
|
||||||
import type { Message } from "../../src/types";
|
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> = {
|
const statusLabel: Record<string, string> = {
|
||||||
open: "Открыт",
|
open: "Открыт",
|
||||||
waiting_for_company: "Ожидает ответа компании",
|
waiting_for_company: "Ожидает ответа",
|
||||||
waiting_for_client: "Ожидает вашего ответа",
|
waiting_for_client: "Ожидает вашего ответа",
|
||||||
closed: "Закрыт",
|
closed: "Закрыт",
|
||||||
accepted: "Принято",
|
|
||||||
delivered: "Доставлено",
|
|
||||||
failed: "Ошибка доставки",
|
|
||||||
rejected: "Отклонено",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ChatScreen() {
|
export default function ChatScreen() {
|
||||||
@@ -24,6 +25,7 @@ export default function ChatScreen() {
|
|||||||
const app = useApp();
|
const app = useApp();
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const listRef = useRef<FlatList<Message>>(null);
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [error, setError] = useState<unknown>();
|
const [error, setError] = useState<unknown>();
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
@@ -48,9 +50,10 @@ export default function ChatScreen() {
|
|||||||
), [dialogId]);
|
), [dialogId]);
|
||||||
|
|
||||||
function merge(incoming: Message[]) {
|
function merge(incoming: Message[]) {
|
||||||
client.setQueryData(["messages", dialogId], (old: typeof messages.data) =>
|
client.setQueryData(["messages", dialogId], (old: typeof messages.data) => ({
|
||||||
old ? { ...old, items: reconcileMessages(old.items, incoming) } : { items: incoming, next_cursor: null },
|
items: reconcileMessages(old?.items ?? [], incoming),
|
||||||
);
|
next_cursor: old?.next_cursor ?? null,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -58,6 +61,11 @@ export default function ChatScreen() {
|
|||||||
return () => realtime.stop();
|
return () => realtime.stop();
|
||||||
}, [realtime, app.authStatus]);
|
}, [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 sendText = async () => {
|
||||||
const normalized = text.trim();
|
const normalized = text.trim();
|
||||||
if (!normalized || !dialogId) return;
|
if (!normalized || !dialogId) return;
|
||||||
@@ -98,41 +106,79 @@ export default function ChatScreen() {
|
|||||||
finally { setSending(false); }
|
finally { setSending(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadAttachment = async (attachmentId: string) => {
|
const getAttachmentUrl = useCallback(async (attachmentId: string) => {
|
||||||
try {
|
const result = await profileApi.attachmentUrl(dialogId, attachmentId);
|
||||||
const result = await profileApi.attachmentUrl(dialogId, attachmentId);
|
return result.download_url;
|
||||||
if (typeof window !== "undefined") window.location.assign(result.download_url);
|
}, [dialogId]);
|
||||||
} catch (reason) { setError(reason); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const closed = dialog.data?.status === "closed";
|
const closed = dialog.data?.status === "closed";
|
||||||
return <ScrollView contentContainerStyle={styles.page}>
|
const items = messages.data?.items ?? [];
|
||||||
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} />
|
const subtitle = dialog.data?.status ? (statusLabel[dialog.data.status] ?? "Онлайн") : "Онлайн";
|
||||||
<Text accessibilityRole="header" style={styles.title}>Чат</Text>
|
|
||||||
{app.authStatus !== "authenticated" && <Text style={styles.error}>Для просмотра чата требуется авторизация.</Text>}
|
if (app.authStatus !== "authenticated") {
|
||||||
{(dialog.isLoading || messages.isLoading) && <Loading />}
|
return (
|
||||||
{(dialog.error || messages.error) && <ErrorNotice error={dialog.error ?? messages.error} retry={() => { void dialog.refetch(); void messages.refetch(); }} />}
|
<ScreenShell>
|
||||||
<Text style={styles.badge}>Статус: {statusLabel[dialog.data?.status ?? ""] ?? "—"}</Text>
|
<ChatScreenHeader subtitle="Требуется вход" />
|
||||||
<View accessibilityLiveRegion="polite" style={{ gap: 10 }}>
|
<View style={{ flex: 1, padding: spacing.lg, justifyContent: "center" }}>
|
||||||
{(messages.data?.items ?? []).map((message) => <View key={message.message_id} style={message.sender_type === "client" ? styles.messageClient : styles.messageCompany}>
|
<Text style={styles.error}>Для просмотра чата требуется авторизация.</Text>
|
||||||
<Text style={styles.text}>{message.content_kind === "file" ? `Файл: ${message.attachments[0]?.file_name ?? "вложение"}` : message.text}</Text>
|
<Button title="На главную" secondary onPress={() => router.replace("/")} />
|
||||||
{message.attachments.map((attachment) =>
|
</View>
|
||||||
<Button key={attachment.attachment_id} title="Скачать вложение" secondary onPress={() => void downloadAttachment(attachment.attachment_id)} />,
|
</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>
|
</View>
|
||||||
{error && <ErrorNotice error={error} />}
|
</ScreenShell>
|
||||||
</View>}
|
);
|
||||||
</ScrollView>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, useRouter } from "expo-router";
|
import { Link, useRouter } from "expo-router";
|
||||||
import React, { useEffect, useRef } from "react";
|
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 { useApp } from "../../src/app-context";
|
||||||
|
import { AppHeader } from "../../src/components/AppHeader";
|
||||||
|
import { ScreenShell } from "../../src/components/ScreenShell";
|
||||||
import { dialogApi } from "../../src/services";
|
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() {
|
export default function DialogsScreen() {
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
@@ -20,17 +23,27 @@ export default function DialogsScreen() {
|
|||||||
if (chat.data) router.replace(`/dialogs/${chat.data.dialog_id}`);
|
if (chat.data) router.replace(`/dialogs/${chat.data.dialog_id}`);
|
||||||
}, [chat.data, router]);
|
}, [chat.data, router]);
|
||||||
|
|
||||||
if (app.authStatus !== "authenticated") return <ScrollView contentContainerStyle={styles.page}>
|
if (app.authStatus !== "authenticated") {
|
||||||
<Header status={app.authStatus} realtime={app.realtimeState} />
|
return (
|
||||||
<Text accessibilityRole="header" style={styles.title}>Чат</Text>
|
<ScreenShell>
|
||||||
<Text style={styles.text}>Чат с компанией доступен после авторизации. Отправьте сообщение на главной странице, чтобы войти.</Text>
|
<AppHeader guestLabel="Гость" />
|
||||||
<Link href="/" style={styles.link}>На главную</Link>
|
<View style={{ flex: 1, padding: spacing.lg, justifyContent: "center", gap: spacing.md }}>
|
||||||
</ScrollView>;
|
<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}>
|
return (
|
||||||
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} />
|
<ScreenShell>
|
||||||
<Text accessibilityRole="header" style={styles.title}>Чат</Text>
|
<AppHeader />
|
||||||
{chat.isLoading && <Loading />}
|
<View style={{ flex: 1, padding: spacing.lg, justifyContent: "center" }}>
|
||||||
{chat.error && <ErrorNotice error={chat.error} retry={() => void chat.refetch()} />}
|
<Text accessibilityRole="header" style={styles.title}>Открываем чат…</Text>
|
||||||
</ScrollView>;
|
{chat.isLoading && <Loading />}
|
||||||
|
{chat.error && <ErrorNotice error={chat.error} retry={() => void chat.refetch()} />}
|
||||||
|
</View>
|
||||||
|
</ScreenShell>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,45 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import { Linking, ScrollView, Switch, Text, View } from "react-native";
|
import { Linking, ScrollView, Switch, Text, View } from "react-native";
|
||||||
import { z } from "zod";
|
|
||||||
import { useApp } from "../src/app-context";
|
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 type { Consents } from "../src/types";
|
||||||
import { Button, ErrorNotice, Field, Header, Loading, styles } from "../src/ui";
|
import { Button, ErrorNotice, 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() {
|
export default function HomeScreen() {
|
||||||
const { authStatus, realtimeState, authorize, signOut } = useApp();
|
const { authStatus, authorize } = useApp();
|
||||||
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
||||||
const content = useQuery({ queryKey: ["public-content"], queryFn: publicApi.content });
|
const content = useQuery({ queryKey: ["public-content"], queryFn: publicApi.content });
|
||||||
const [consentOpen, setConsentOpen] = useState(false);
|
const [consentOpen, setConsentOpen] = useState(false);
|
||||||
const [required, setRequired] = useState({ personal: false, agreement: false, marketing: 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 [sendError, setSendError] = useState<unknown>();
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const router = useRouter();
|
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);
|
setSending(true);
|
||||||
setSendError(undefined);
|
setSendError(undefined);
|
||||||
try {
|
try {
|
||||||
const dialog = await dialogApi.create(crypto.randomUUID());
|
const dialog = await dialogApi.create(intent.dialogKey);
|
||||||
await dialogApi.sendText(dialog.dialog_id, text, crypto.randomUUID());
|
await dialogApi.sendText(dialog.dialog_id, intent.text, intent.messageKey);
|
||||||
reset();
|
setMessage("");
|
||||||
|
setPending(null);
|
||||||
|
clearPendingTextIntent();
|
||||||
router.push(`/dialogs/${dialog.dialog_id}`);
|
router.push(`/dialogs/${dialog.dialog_id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSendError(error);
|
setSendError(error);
|
||||||
@@ -43,12 +49,66 @@ export default function HomeScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const send = async (text: string) => {
|
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") {
|
if (authStatus !== "authenticated") {
|
||||||
setPending(text);
|
setPending(intent);
|
||||||
|
savePendingTextIntent(intent);
|
||||||
setConsentOpen(true);
|
setConsentOpen(true);
|
||||||
return;
|
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 () => {
|
const accept = async () => {
|
||||||
@@ -68,57 +128,80 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const welcome = content.data?.texts.welcome;
|
||||||
const questions = content.data?.popular_questions ?? [];
|
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}>
|
return (
|
||||||
<Text accessibilityRole="header" style={styles.heading}>Популярные вопросы</Text>
|
<ScreenShell>
|
||||||
<View style={styles.row}>
|
<AppHeader guestLabel={authStatus === "authenticated" ? undefined : "Гость"} />
|
||||||
{questions.map((question, index) => {
|
<View style={{ flex: 1 }}>
|
||||||
const text = question.text;
|
<HanLogo />
|
||||||
return <Button key={question.id ?? index} title={text} secondary onPress={() => { setValue("text", text); void send(text); }} />;
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: spacing }}>
|
||||||
})}
|
{(config.isLoading || content.isLoading) && <Loading />}
|
||||||
{!questions.length && <Text style={styles.muted}>Популярные вопросы пока не опубликованы.</Text>}
|
{(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>
|
|
||||||
|
|
||||||
<View style={styles.card}>
|
{consentOpen && (
|
||||||
<Text accessibilityRole="header" style={styles.heading}>Написать оператору</Text>
|
<View accessibilityViewIsModal style={styles.modalBackdrop}>
|
||||||
<Controller control={control} name="text" render={({ field }) =>
|
<View style={styles.modal}>
|
||||||
<Field label="Сообщение" multiline value={field.value} onChangeText={field.onChange} error={errors.text?.message} />
|
<Text accessibilityRole="header" style={styles.heading}>Согласия перед входом</Text>
|
||||||
} />
|
<Text style={styles.text}>Для отправки сообщения или файла необходимо войти по номеру телефона. Код вводится только на защищённой странице авторизации.</Text>
|
||||||
<Button title={sending ? "Отправляем…" : "Отправить"} disabled={sending} onPress={() => void handleSubmit(({ text }) => send(text))()} />
|
{(["personal_data", "user_agreement", "marketing"] as const).map((key) => {
|
||||||
{sendError && <ErrorNotice error={sendError} />}
|
const item = config.data?.consents?.[key];
|
||||||
</View>
|
if (!item) return null;
|
||||||
|
return item.document_url ? (
|
||||||
{consentOpen && <View accessibilityViewIsModal style={styles.modalBackdrop}>
|
<Text key={key} accessibilityRole="link" style={styles.link} onPress={() => void Linking.openURL(item.document_url!)}>
|
||||||
<View style={styles.modal}>
|
{key === "personal_data" ? "Политика персональных данных" : key === "user_agreement" ? "Пользовательское соглашение" : "Согласие на рекламу"} · версия {item.version}
|
||||||
<Text accessibilityRole="header" style={styles.heading}>Согласия перед входом</Text>
|
</Text>
|
||||||
<Text style={styles.text}>Для отправки сообщения необходимо войти по номеру телефона. Код вводится только на защищённой странице авторизации.</Text>
|
) : null;
|
||||||
{(["personal_data", "user_agreement", "marketing"] as const).map((key) => {
|
})}
|
||||||
const item = config.data?.consents?.[key];
|
<ConsentRow label="Обработка персональных данных (обязательно)" value={required.personal} onChange={(personal) => setRequired({ ...required, personal })} />
|
||||||
if (!item) return null;
|
<ConsentRow label="Пользовательское соглашение (обязательно)" value={required.agreement} onChange={(agreement) => setRequired({ ...required, agreement })} />
|
||||||
return item.document_url ? <Text key={key} accessibilityRole="link" style={styles.link} onPress={() => void Linking.openURL(item.document_url!)}>
|
<ConsentRow label="Рекламные коммуникации (необязательно)" value={required.marketing} onChange={(marketing) => setRequired({ ...required, marketing })} />
|
||||||
{key === "personal_data" ? "Политика персональных данных" : key === "user_agreement" ? "Пользовательское соглашение" : "Согласие на рекламу"} · версия {item.version}
|
<View style={styles.row}>
|
||||||
</Text> : null;
|
<Button title="Продолжить" disabled={!required.personal || !required.agreement} onPress={() => void accept()} />
|
||||||
})}
|
<Button
|
||||||
<ConsentRow label="Обработка персональных данных (обязательно)" value={required.personal} onChange={(personal) => setRequired({ ...required, personal })} />
|
title="Отмена"
|
||||||
<ConsentRow label="Пользовательское соглашение (обязательно)" value={required.agreement} onChange={(agreement) => setRequired({ ...required, agreement })} />
|
secondary
|
||||||
<ConsentRow label="Рекламные коммуникации (необязательно)" value={required.marketing} onChange={(marketing) => setRequired({ ...required, marketing })} />
|
onPress={() => {
|
||||||
<View style={styles.row}>
|
setConsentOpen(false);
|
||||||
<Button title="Продолжить" disabled={!required.personal || !required.agreement} onPress={() => void accept()} />
|
setPending(null);
|
||||||
<Button title="Отмена" secondary onPress={() => { setConsentOpen(false); setPending(null); }} />
|
clearPendingTextIntent();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
)}
|
||||||
</View>}
|
</ScreenShell>
|
||||||
</ScrollView>;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const spacing = 16;
|
||||||
|
|
||||||
function ConsentRow({ label, value, onChange }: { label: string; value: boolean; onChange: (value: boolean) => void }) {
|
function ConsentRow({ label, value, onChange }: { label: string; value: boolean; onChange: (value: boolean) => void }) {
|
||||||
return <View style={[styles.row, { justifyContent: "space-between" }]}>
|
return <View style={[styles.row, { justifyContent: "space-between" }]}>
|
||||||
<Text style={[styles.text, { flex: 1 }]}>{label}</Text>
|
<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 { useQuery } from "@tanstack/react-query";
|
||||||
import { Link } from "expo-router";
|
import { Link, useRouter } from "expo-router";
|
||||||
import React, { useState } from "react";
|
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 { 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 { 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() {
|
export default function ProfileScreen() {
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
|
const router = useRouter();
|
||||||
const enabled = app.authStatus === "authenticated";
|
const enabled = app.authStatus === "authenticated";
|
||||||
const profile = useQuery({ queryKey: ["profile"], queryFn: profileApi.me, enabled });
|
const profile = useQuery({ queryKey: ["profile"], queryFn: profileApi.me, enabled });
|
||||||
const documents = useQuery({ queryKey: ["documents"], queryFn: profileApi.documents, enabled });
|
const documents = useQuery({ queryKey: ["documents"], queryFn: profileApi.documents, enabled });
|
||||||
@@ -20,44 +27,112 @@ export default function ProfileScreen() {
|
|||||||
} catch (error) { setDownloadError(error); }
|
} catch (error) { setDownloadError(error); }
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!enabled) return <ScrollView contentContainerStyle={styles.page}>
|
if (!enabled) {
|
||||||
<Header status={app.authStatus} realtime={app.realtimeState} />
|
return (
|
||||||
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
|
<ScreenShell>
|
||||||
<Text style={styles.text}>Профиль доступен после авторизации.</Text>
|
<AppHeader guestLabel="Гость" />
|
||||||
<Link href="/" style={styles.link}>Перейти в чат для входа</Link>
|
<View style={{ flex: 1, padding: spacing.lg, justifyContent: "center", gap: spacing.md }}>
|
||||||
</ScrollView>;
|
<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;
|
const personal = profile.data?.profile.personal_data;
|
||||||
return <ScrollView contentContainerStyle={styles.page}>
|
const fullName = personal?.full_name ?? "Пользователь";
|
||||||
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} />
|
|
||||||
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
|
return (
|
||||||
{(profile.isLoading || documents.isLoading) && <Loading />}
|
<ScreenShell>
|
||||||
{(profile.error || documents.error) && <ErrorNotice error={profile.error ?? documents.error} retry={() => { void profile.refetch(); void documents.refetch(); }} />}
|
<ScrollView contentContainerStyle={{ paddingBottom: spacing.xl }}>
|
||||||
<View style={styles.card}>
|
<View style={stylesLocal.topBar}>
|
||||||
<Text accessibilityRole="header" style={styles.heading}>Личные данные</Text>
|
<Pressable accessibilityRole="button" accessibilityLabel="Назад" onPress={() => router.back()} style={({ pressed }) => [stylesLocal.backButton, pressed && stylesLocal.pressed]}>
|
||||||
<Row label="ФИО" value={personal?.full_name} />
|
<Feather name="arrow-left" size={20} color={colors.foreground} />
|
||||||
<Row label="Гражданство" value={personal?.citizenship} />
|
</Pressable>
|
||||||
<Row label="Телефон в РФ" value={personal?.russian_phone} />
|
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
|
||||||
<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>
|
</View>
|
||||||
<Button title="Скачать" secondary onPress={() => void download(document.document_id)} />
|
|
||||||
</View>)}
|
<View style={stylesLocal.avatarBlock}>
|
||||||
{downloadError && <ErrorNotice error={downloadError} />}
|
<View style={stylesLocal.avatar}>
|
||||||
</View>
|
<Feather name="user" size={40} color={colors.primaryForeground} />
|
||||||
</ScrollView>;
|
</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 }) {
|
const stylesLocal = StyleSheet.create({
|
||||||
return <View><Text style={styles.muted}>{label}</Text><Text style={styles.text}>{value || "Не указано"}</Text></View>;
|
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 },
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/metro-runtime": "57.0.3",
|
"@expo/metro-runtime": "57.0.3",
|
||||||
|
"@expo/vector-icons": "15.0.3",
|
||||||
"@hookform/resolvers": "5.4.0",
|
"@hookform/resolvers": "5.4.0",
|
||||||
"@tanstack/react-query": "5.101.2",
|
"@tanstack/react-query": "5.101.2",
|
||||||
"expo": "57.0.4",
|
"expo": "57.0.4",
|
||||||
|
|||||||
@@ -55,10 +55,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (!getAccessToken()) await completeAuthorization(code, state);
|
if (!getAccessToken()) await completeAuthorization(code, state);
|
||||||
await authApi.bootstrap(consents);
|
await authApi.bootstrap(consents);
|
||||||
await authApi.startSession("first_launch");
|
await authApi.startSession("first_launch");
|
||||||
if (typeof window !== "undefined") window.sessionStorage.removeItem("han.pending-consents");
|
|
||||||
setAuthStatus("authenticated");
|
setAuthStatus("authenticated");
|
||||||
router.replace("/");
|
}, []);
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const authorize = useCallback(async (consents: Consents) => {
|
const authorize = useCallback(async (consents: Consents) => {
|
||||||
setAuthStatus("authorizing");
|
setAuthStatus("authorizing");
|
||||||
@@ -70,6 +68,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await finishCallback(result.params.code, result.params.state, consents);
|
await finishCallback(result.params.code, result.params.state, consents);
|
||||||
|
if (typeof window !== "undefined") window.sessionStorage.removeItem("han.pending-consents");
|
||||||
return true;
|
return true;
|
||||||
}, [finishCallback]);
|
}, [finishCallback]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { colors, radii, spacing } from "../theme";
|
||||||
|
|
||||||
|
type Item = { title: string; value?: string; action?: string; icon?: keyof typeof Feather.glyphMap };
|
||||||
|
|
||||||
|
export function AccordionSection({
|
||||||
|
title,
|
||||||
|
defaultOpen = false,
|
||||||
|
items,
|
||||||
|
onItemPress,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
items: Item[];
|
||||||
|
onItemPress?: (index: number) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => setOpen((value) => !value)}
|
||||||
|
style={({ pressed }) => [styles.trigger, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
<Text style={styles.triggerText}>{title}</Text>
|
||||||
|
<Feather name={open ? "chevron-up" : "chevron-down"} size={20} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
{open && (
|
||||||
|
<View style={styles.content}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Pressable
|
||||||
|
key={`${item.title}-${index}`}
|
||||||
|
accessibilityRole={onItemPress ? "button" : "text"}
|
||||||
|
disabled={!onItemPress}
|
||||||
|
onPress={() => onItemPress?.(index)}
|
||||||
|
style={({ pressed }) => [styles.item, index > 0 && styles.itemBorder, pressed && onItemPress && styles.pressed]}
|
||||||
|
>
|
||||||
|
{item.icon && (
|
||||||
|
<View style={styles.iconWrap}>
|
||||||
|
<Feather name={item.icon} size={18} color={colors.mutedForeground} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.itemBody}>
|
||||||
|
<Text style={styles.itemTitle}>{item.title}</Text>
|
||||||
|
{item.value ? <Text style={styles.itemValue}>{item.value}</Text> : null}
|
||||||
|
</View>
|
||||||
|
{item.action && <Feather name="chevron-right" size={20} color={colors.mutedForeground} />}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
section: { backgroundColor: colors.card, borderWidth: 1, borderColor: colors.border, borderRadius: radii.lg, overflow: "hidden", marginBottom: spacing.md },
|
||||||
|
trigger: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
|
||||||
|
triggerText: { fontSize: 14, fontWeight: "500", color: colors.foreground },
|
||||||
|
content: { borderTopWidth: 1, borderTopColor: colors.border },
|
||||||
|
item: { flexDirection: "row", alignItems: "center", gap: spacing.md, padding: spacing.md },
|
||||||
|
itemBorder: { borderTopWidth: 1, borderTopColor: colors.border },
|
||||||
|
iconWrap: { width: 36, height: 36, borderRadius: radii.full, backgroundColor: colors.muted, alignItems: "center", justifyContent: "center" },
|
||||||
|
itemBody: { flex: 1 },
|
||||||
|
itemTitle: { fontSize: 14, fontWeight: "500", color: colors.foreground },
|
||||||
|
itemValue: { fontSize: 12, color: colors.mutedForeground, marginTop: 2 },
|
||||||
|
pressed: { backgroundColor: colors.accent },
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
import { Link } from "expo-router";
|
||||||
|
import React from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { colors, radii, spacing } from "../theme";
|
||||||
|
|
||||||
|
export function AppHeader({ guestLabel }: { guestLabel?: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Link href="/dialogs" asChild>
|
||||||
|
<Pressable accessibilityRole="link" style={({ pressed }) => [styles.historyLink, pressed && styles.pressed]}>
|
||||||
|
<Feather name="clock" size={20} color={colors.foreground} />
|
||||||
|
<Text style={styles.historyText}>История</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<View style={styles.right}>
|
||||||
|
{guestLabel && <Text style={styles.guestBadge}>{guestLabel}</Text>}
|
||||||
|
<Link href="/profile" asChild>
|
||||||
|
<Pressable accessibilityRole="link" style={({ pressed }) => [styles.avatar, pressed && styles.pressed]}>
|
||||||
|
<Feather name="user" size={20} color={colors.mutedForeground} />
|
||||||
|
<View style={styles.dot} />
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
historyLink: { flexDirection: "row", alignItems: "center", gap: spacing.sm },
|
||||||
|
historyText: { fontSize: 14, color: colors.foreground },
|
||||||
|
right: { flexDirection: "row", alignItems: "center", gap: spacing.sm },
|
||||||
|
guestBadge: { fontSize: 12, color: colors.mutedForeground },
|
||||||
|
avatar: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: radii.full,
|
||||||
|
backgroundColor: colors.muted,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 2,
|
||||||
|
right: 2,
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: radii.full,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.background,
|
||||||
|
},
|
||||||
|
pressed: { opacity: 0.7 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { Animated, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { colors, radii, spacing } from "../theme";
|
||||||
|
|
||||||
|
export function AuthLoadingView() {
|
||||||
|
const pulse = useRef(new Animated.Value(0)).current;
|
||||||
|
const dots = useRef([
|
||||||
|
new Animated.Value(0),
|
||||||
|
new Animated.Value(0),
|
||||||
|
new Animated.Value(0),
|
||||||
|
]).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pulseAnimation = Animated.loop(
|
||||||
|
Animated.timing(pulse, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 1800,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const dotAnimations = dots.map((dot, index) => Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.delay(index * 200),
|
||||||
|
Animated.timing(dot, { toValue: -8, duration: 280, useNativeDriver: true }),
|
||||||
|
Animated.timing(dot, { toValue: 0, duration: 280, useNativeDriver: true }),
|
||||||
|
Animated.delay((2 - index) * 200 + 240),
|
||||||
|
]),
|
||||||
|
));
|
||||||
|
|
||||||
|
pulseAnimation.start();
|
||||||
|
dotAnimations.forEach((animation) => animation.start());
|
||||||
|
return () => {
|
||||||
|
pulseAnimation.stop();
|
||||||
|
dotAnimations.forEach((animation) => animation.stop());
|
||||||
|
};
|
||||||
|
}, [dots, pulse]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.screen}>
|
||||||
|
<View style={styles.center}>
|
||||||
|
<View style={styles.logoArea}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.pulseRing,
|
||||||
|
{
|
||||||
|
opacity: pulse.interpolate({ inputRange: [0, 1], outputRange: [0.35, 0] }),
|
||||||
|
transform: [{ scale: pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 1.45] }) }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View style={styles.innerRing} />
|
||||||
|
<View style={styles.logo}>
|
||||||
|
<Text style={styles.logoText}>HAN</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.dots}>
|
||||||
|
{dots.map((dot, index) => (
|
||||||
|
<Animated.View key={index} style={[styles.dot, { transform: [{ translateY: dot }] }]} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text accessibilityRole="header" style={styles.title}>Выполняем вход</Text>
|
||||||
|
<Text style={styles.subtitle}>Проверяем данные...</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.footer}>
|
||||||
|
HAN — ваш персональный консультант по вопросам миграции в России
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
screen: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 560,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 48,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
center: { alignItems: "center" },
|
||||||
|
logoArea: {
|
||||||
|
width: 116,
|
||||||
|
height: 116,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
pulseRing: {
|
||||||
|
position: "absolute",
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
borderRadius: radii.full,
|
||||||
|
},
|
||||||
|
innerRing: {
|
||||||
|
position: "absolute",
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: "rgba(3, 2, 19, 0.12)",
|
||||||
|
borderRadius: radii.full,
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: radii.full,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
shadowColor: colors.primary,
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 14,
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
},
|
||||||
|
logoText: { color: colors.primaryForeground, fontSize: 20, fontWeight: "700", letterSpacing: 1.5 },
|
||||||
|
dots: { flexDirection: "row", gap: 6, height: 24, alignItems: "center", marginBottom: 28 },
|
||||||
|
dot: { width: 8, height: 8, borderRadius: radii.full, backgroundColor: colors.primary },
|
||||||
|
title: { color: colors.foreground, fontSize: 20, fontWeight: "600", marginBottom: spacing.sm },
|
||||||
|
subtitle: { color: colors.mutedForeground, fontSize: 14 },
|
||||||
|
footer: {
|
||||||
|
position: "absolute",
|
||||||
|
right: 32,
|
||||||
|
bottom: 48,
|
||||||
|
left: 32,
|
||||||
|
color: colors.mutedForeground,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 18,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||||
|
import { colors, radii, spacing } from "../theme";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
sending?: boolean;
|
||||||
|
onAttach?: () => void;
|
||||||
|
placeholder?: string;
|
||||||
|
hint?: string;
|
||||||
|
inputLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChatInputBar({
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
onSubmit,
|
||||||
|
disabled,
|
||||||
|
sending,
|
||||||
|
onAttach,
|
||||||
|
placeholder = "Напишите ваш вопрос...",
|
||||||
|
hint = "Напишите сообщение или прикрепите документ",
|
||||||
|
inputLabel = "Сообщение",
|
||||||
|
}: Props) {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const canSend = Boolean(value.trim()) && !disabled && !sending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<View style={[styles.inputBox, focused && styles.inputBoxFocused]}>
|
||||||
|
<TextInput
|
||||||
|
accessibilityLabel={inputLabel}
|
||||||
|
editable={!disabled && !sending}
|
||||||
|
multiline
|
||||||
|
onBlur={() => { if (!value.trim()) setFocused(false); }}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
onKeyPress={(event) => {
|
||||||
|
if (event.nativeEvent.key !== "Enter") return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (canSend) onSubmit();
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={colors.mutedForeground}
|
||||||
|
returnKeyType="send"
|
||||||
|
style={[styles.input, focused && styles.inputExpanded]}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{onAttach && (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Прикрепить файл"
|
||||||
|
disabled={disabled || sending}
|
||||||
|
onPress={onAttach}
|
||||||
|
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
<Feather name="paperclip" size={20} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Отправить"
|
||||||
|
disabled={!canSend}
|
||||||
|
onPress={onSubmit}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.sendButton,
|
||||||
|
!canSend && styles.sendButtonDisabled,
|
||||||
|
pressed && canSend && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Feather name="send" size={16} color={colors.primaryForeground} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{hint ? <Text style={styles.hint}>{hint}</Text> : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm, paddingBottom: spacing.lg, borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.background },
|
||||||
|
inputBox: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: spacing.sm,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: "rgba(3, 2, 19, 0.2)",
|
||||||
|
borderRadius: radii.xl,
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
inputBoxFocused: { borderColor: colors.primary },
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 80,
|
||||||
|
maxHeight: 256,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.foreground,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
inputExpanded: { minHeight: 144 },
|
||||||
|
actions: { alignItems: "center", justifyContent: "flex-end", gap: spacing.sm },
|
||||||
|
iconButton: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
|
||||||
|
sendButton: { width: 36, height: 36, borderRadius: radii.full, backgroundColor: colors.primary, alignItems: "center", justifyContent: "center" },
|
||||||
|
sendButtonDisabled: { opacity: 0.4 },
|
||||||
|
hint: { fontSize: 12, color: colors.mutedForeground, textAlign: "center", marginTop: spacing.sm },
|
||||||
|
pressed: { opacity: 0.7 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import React from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { colors, radii, spacing } from "../theme";
|
||||||
|
|
||||||
|
export function ChatScreenHeader({ title = "HAN Помощник", subtitle = "Онлайн" }: { title?: string; subtitle?: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Назад"
|
||||||
|
onPress={() => router.replace("/")}
|
||||||
|
style={({ pressed }) => [styles.backButton, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
<Feather name="arrow-left" size={20} color={colors.foreground} />
|
||||||
|
</Pressable>
|
||||||
|
<View style={styles.info}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<Text style={styles.subtitle}>{subtitle}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
backButton: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
|
||||||
|
info: { flex: 1 },
|
||||||
|
title: { fontSize: 16, fontWeight: "500", color: colors.foreground },
|
||||||
|
subtitle: { fontSize: 12, color: colors.mutedForeground },
|
||||||
|
pressed: { backgroundColor: colors.muted },
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
import { colors, radii, spacing } from "../theme";
|
||||||
|
|
||||||
|
export function HanLogo({ subtitle }: { subtitle?: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.logoBox}>
|
||||||
|
<Text style={styles.logoText}>HAN</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.textBlock}>
|
||||||
|
<Text accessibilityRole="header" style={styles.title}>Привет! Я HAN</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
{subtitle ?? "Помощник по документам и жизни в России"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flexDirection: "row", alignItems: "center", gap: spacing.md, paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
|
||||||
|
logoBox: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: radii.xl,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
logoText: { color: colors.primaryForeground, fontSize: 14, fontWeight: "700", letterSpacing: 0.5 },
|
||||||
|
textBlock: { flex: 1 },
|
||||||
|
title: { fontSize: 16, fontWeight: "500", color: colors.foreground, marginBottom: 2 },
|
||||||
|
subtitle: { fontSize: 12, color: colors.mutedForeground, lineHeight: 16 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { ActivityIndicator, Image, Linking, Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
import type { Attachment, Message } from "../types";
|
||||||
|
import { colors, radii, spacing } from "../theme";
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
accepted: "Принято",
|
||||||
|
delivered: "Доставлено",
|
||||||
|
failed: "Ошибка",
|
||||||
|
rejected: "Отклонено",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
message: Message;
|
||||||
|
getAttachmentUrl?: (attachmentId: string) => Promise<string>;
|
||||||
|
onAttachmentError?: (error: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MessageBubble({ message, getAttachmentUrl, onAttachmentError }: Props) {
|
||||||
|
const isClient = message.sender_type === "client";
|
||||||
|
const time = new Date(message.created_at).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.row, isClient ? styles.rowClient : styles.rowCompany]}>
|
||||||
|
<View style={[styles.bubble, isClient ? styles.bubbleClient : styles.bubbleCompany]}>
|
||||||
|
{message.content_kind === "text" ? (
|
||||||
|
<Text style={[styles.text, isClient && styles.textClient]}>{message.text}</Text>
|
||||||
|
) : message.attachments.length ? (
|
||||||
|
<View style={styles.attachments}>
|
||||||
|
{message.attachments.map((attachment) => (
|
||||||
|
<AttachmentPreview
|
||||||
|
key={attachment.attachment_id}
|
||||||
|
attachment={attachment}
|
||||||
|
getUrl={getAttachmentUrl}
|
||||||
|
isClient={isClient}
|
||||||
|
onError={onAttachmentError}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.fileFallback}>
|
||||||
|
<Feather name="file" size={28} color={isClient ? colors.primaryForeground : colors.primary} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={[styles.time, isClient ? styles.timeClient : styles.timeMuted]}>
|
||||||
|
{time}
|
||||||
|
{isClient ? ` · ${statusLabel[message.delivery_status] ?? message.delivery_status}` : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentPreview({ attachment, getUrl, isClient, onError }: {
|
||||||
|
attachment: Attachment;
|
||||||
|
getUrl?: (attachmentId: string) => Promise<string>;
|
||||||
|
isClient: boolean;
|
||||||
|
onError?: (error: unknown) => void;
|
||||||
|
}) {
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string>();
|
||||||
|
const [previewFailed, setPreviewFailed] = useState(false);
|
||||||
|
const isImage = attachment.mime_type.startsWith("image/");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isImage || !getUrl) return;
|
||||||
|
let active = true;
|
||||||
|
void getUrl(attachment.attachment_id)
|
||||||
|
.then((url) => { if (active) setPreviewUrl(url); })
|
||||||
|
.catch(() => { if (active) setPreviewFailed(true); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [attachment.attachment_id, getUrl, isImage]);
|
||||||
|
|
||||||
|
const open = async () => {
|
||||||
|
if (!getUrl) return;
|
||||||
|
try {
|
||||||
|
const url = previewUrl ?? await getUrl(attachment.attachment_id);
|
||||||
|
if (typeof window !== "undefined") window.location.assign(url);
|
||||||
|
else await Linking.openURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
onError?.(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Скачать файл ${attachment.file_name}`}
|
||||||
|
onPress={() => void open()}
|
||||||
|
style={({ pressed }) => [styles.attachmentButton, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
{isImage && !previewFailed ? (
|
||||||
|
previewUrl ? (
|
||||||
|
<Image
|
||||||
|
accessibilityLabel={attachment.file_name}
|
||||||
|
resizeMode="cover"
|
||||||
|
source={{ uri: previewUrl }}
|
||||||
|
style={styles.previewImage}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.previewLoading}>
|
||||||
|
<ActivityIndicator color={isClient ? colors.primaryForeground : colors.primary} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<View style={[styles.fileCard, isClient && styles.fileCardClient]}>
|
||||||
|
<Feather name="file-text" size={30} color={isClient ? colors.primaryForeground : colors.primary} />
|
||||||
|
<Text numberOfLines={2} style={[styles.fileName, isClient && styles.textClient]}>
|
||||||
|
{attachment.file_name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: { flexDirection: "row", marginBottom: spacing.lg },
|
||||||
|
rowClient: { justifyContent: "flex-end" },
|
||||||
|
rowCompany: { justifyContent: "flex-start" },
|
||||||
|
bubble: { maxWidth: "75%", borderRadius: radii.xl, paddingHorizontal: spacing.lg, paddingVertical: 10 },
|
||||||
|
bubbleClient: { backgroundColor: colors.primary },
|
||||||
|
bubbleCompany: { backgroundColor: colors.muted },
|
||||||
|
text: { fontSize: 14, color: colors.foreground, lineHeight: 20 },
|
||||||
|
textClient: { color: colors.primaryForeground },
|
||||||
|
time: { fontSize: 12, marginTop: 4 },
|
||||||
|
timeClient: { color: "rgba(255,255,255,0.7)" },
|
||||||
|
timeMuted: { color: colors.mutedForeground },
|
||||||
|
attachments: { gap: spacing.sm },
|
||||||
|
attachmentButton: { borderRadius: radii.lg, overflow: "hidden" },
|
||||||
|
previewImage: { width: 190, height: 128, borderRadius: radii.lg, backgroundColor: colors.inputBackground },
|
||||||
|
previewLoading: { width: 190, height: 128, alignItems: "center", justifyContent: "center" },
|
||||||
|
fileCard: {
|
||||||
|
width: 190,
|
||||||
|
minHeight: 72,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
},
|
||||||
|
fileCardClient: { backgroundColor: "rgba(255,255,255,0.14)" },
|
||||||
|
fileName: { flex: 1, fontSize: 13, lineHeight: 18, color: colors.foreground },
|
||||||
|
fileFallback: { width: 72, height: 72, alignItems: "center", justifyContent: "center" },
|
||||||
|
pressed: { opacity: 0.75 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
import React from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { colors, radii, spacing } from "../theme";
|
||||||
|
|
||||||
|
const icons = ["map-pin", "credit-card", "briefcase", "alert-circle"] as const;
|
||||||
|
|
||||||
|
type Question = { id?: string; text: string };
|
||||||
|
|
||||||
|
export function PopularQuestionsList({ questions, onSelect }: { questions: Question[]; onSelect: (text: string) => void }) {
|
||||||
|
if (!questions.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.heading}>Популярные вопросы</Text>
|
||||||
|
<View style={styles.list}>
|
||||||
|
{questions.map((question, index) => (
|
||||||
|
<Pressable
|
||||||
|
key={question.id ?? index}
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => onSelect(question.text)}
|
||||||
|
style={({ pressed }) => [styles.item, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
<View style={styles.iconWrap}>
|
||||||
|
<Feather name={icons[index % icons.length]} size={16} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.itemText}>{question.text}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md, borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.background },
|
||||||
|
heading: { fontSize: 14, fontWeight: "500", color: colors.mutedForeground, marginBottom: 10, paddingHorizontal: 4 },
|
||||||
|
list: { gap: spacing.sm },
|
||||||
|
item: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
iconWrap: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: radii.full,
|
||||||
|
backgroundColor: "rgba(3, 2, 19, 0.1)",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
itemText: { flex: 1, fontSize: 14, color: colors.foreground },
|
||||||
|
pressed: { backgroundColor: colors.accent },
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
import React from "react";
|
||||||
|
import { Linking, Pressable, StyleSheet, Text } from "react-native";
|
||||||
|
import { colors, radii, spacing } from "../theme";
|
||||||
|
|
||||||
|
export function QuickActions({ phone }: { phone?: string }) {
|
||||||
|
const call = () => {
|
||||||
|
if (phone) void Linking.openURL(`tel:${phone}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={!phone}
|
||||||
|
onPress={call}
|
||||||
|
style={({ pressed }) => [styles.button, pressed && styles.pressed, !phone && styles.disabled]}
|
||||||
|
>
|
||||||
|
<Feather name="headphones" size={16} color={colors.secondaryForeground} />
|
||||||
|
<Text style={styles.text}>Связь с оператором</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
button: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginHorizontal: spacing.lg,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: radii.lg,
|
||||||
|
backgroundColor: colors.secondary,
|
||||||
|
},
|
||||||
|
text: { fontSize: 14, color: colors.secondaryForeground },
|
||||||
|
pressed: { opacity: 0.8 },
|
||||||
|
disabled: { opacity: 0.5 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, StyleSheet } from "react-native";
|
||||||
|
import { colors, layout } from "../theme";
|
||||||
|
|
||||||
|
export function ScreenShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.outer}>
|
||||||
|
<View style={styles.inner}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
outer: { flex: 1, backgroundColor: colors.background, alignItems: "center" },
|
||||||
|
inner: { flex: 1, width: "100%", maxWidth: layout.maxWidth },
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export type PendingTextIntent = {
|
||||||
|
text: string;
|
||||||
|
dialogKey: string;
|
||||||
|
messageKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = "han.pending-message";
|
||||||
|
|
||||||
|
export function createPendingTextIntent(text: string): PendingTextIntent {
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
dialogKey: crypto.randomUUID(),
|
||||||
|
messageKey: crypto.randomUUID(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePendingTextIntent(intent: PendingTextIntent) {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(intent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPendingTextIntent(): PendingTextIntent | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const raw = window.sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(raw) as Partial<PendingTextIntent>;
|
||||||
|
if (
|
||||||
|
typeof value.text !== "string"
|
||||||
|
|| typeof value.dialogKey !== "string"
|
||||||
|
|| typeof value.messageKey !== "string"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: value.text,
|
||||||
|
dialogKey: value.dialogKey,
|
||||||
|
messageKey: value.messageKey,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPendingTextIntent() {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,5 +3,10 @@ import type { Message } from "./types";
|
|||||||
export function reconcileMessages(current: Message[], incoming: Message[]) {
|
export function reconcileMessages(current: Message[], incoming: Message[]) {
|
||||||
const byId = new Map(current.map((message) => [message.message_id, message]));
|
const byId = new Map(current.map((message) => [message.message_id, message]));
|
||||||
for (const message of incoming) byId.set(message.message_id, { ...byId.get(message.message_id), ...message });
|
for (const message of incoming) byId.set(message.message_id, { ...byId.get(message.message_id), ...message });
|
||||||
return [...byId.values()].sort((a, b) => a.created_at.localeCompare(b.created_at));
|
return [...byId.values()].sort((a, b) => {
|
||||||
|
const left = Date.parse(a.created_at);
|
||||||
|
const right = Date.parse(b.created_at);
|
||||||
|
if (Number.isFinite(left) && Number.isFinite(right)) return left - right;
|
||||||
|
return a.created_at.localeCompare(b.created_at);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export const colors = {
|
||||||
|
background: "#ffffff",
|
||||||
|
foreground: "#252525",
|
||||||
|
primary: "#030213",
|
||||||
|
primaryForeground: "#ffffff",
|
||||||
|
secondary: "#f3f3f5",
|
||||||
|
secondaryForeground: "#030213",
|
||||||
|
muted: "#ececf0",
|
||||||
|
mutedForeground: "#717182",
|
||||||
|
accent: "#e9ebef",
|
||||||
|
destructive: "#d4183d",
|
||||||
|
border: "rgba(0, 0, 0, 0.1)",
|
||||||
|
inputBackground: "#f3f3f5",
|
||||||
|
card: "#ffffff",
|
||||||
|
success: "#16a34a",
|
||||||
|
warning: "#ca8a04",
|
||||||
|
info: "#2563eb",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const radii = {
|
||||||
|
sm: 6,
|
||||||
|
md: 8,
|
||||||
|
lg: 10,
|
||||||
|
xl: 16,
|
||||||
|
full: 9999,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const spacing = {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 12,
|
||||||
|
lg: 16,
|
||||||
|
xl: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const layout = {
|
||||||
|
maxWidth: 390,
|
||||||
|
};
|
||||||
@@ -1,46 +1,33 @@
|
|||||||
import { Link } from "expo-router";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||||
import { ApiError } from "./api";
|
import { ApiError } from "./api";
|
||||||
|
import { colors, radii, spacing } from "./theme";
|
||||||
|
|
||||||
export const styles = StyleSheet.create({
|
export const styles = StyleSheet.create({
|
||||||
page: { flex: 1, width: "100%", maxWidth: 920, alignSelf: "center", padding: 20, gap: 16 },
|
page: { flexGrow: 1, backgroundColor: colors.background },
|
||||||
header: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 12, paddingBottom: 12, borderBottomWidth: 1, borderColor: "#d7dee8" },
|
scrollContent: { padding: spacing.lg, gap: spacing.lg },
|
||||||
title: { fontSize: 28, lineHeight: 34, fontWeight: "700", color: "#12233f" },
|
title: { fontSize: 20, fontWeight: "500", color: colors.foreground },
|
||||||
heading: { fontSize: 20, lineHeight: 26, fontWeight: "700", color: "#12233f" },
|
heading: { fontSize: 16, fontWeight: "500", color: colors.foreground },
|
||||||
text: { fontSize: 16, lineHeight: 23, color: "#233653" },
|
text: { fontSize: 14, lineHeight: 20, color: colors.foreground },
|
||||||
muted: { fontSize: 14, lineHeight: 20, color: "#5f6f85" },
|
muted: { fontSize: 12, lineHeight: 18, color: colors.mutedForeground },
|
||||||
card: { padding: 16, gap: 10, borderWidth: 1, borderColor: "#d7dee8", borderRadius: 12, backgroundColor: "#fff" },
|
card: { padding: spacing.lg, gap: spacing.md, borderWidth: 1, borderColor: colors.border, borderRadius: radii.lg, backgroundColor: colors.card },
|
||||||
input: { minHeight: 48, borderWidth: 1, borderColor: "#8493a8", borderRadius: 8, padding: 12, fontSize: 16, backgroundColor: "#fff" },
|
input: { minHeight: 48, borderWidth: 1, borderColor: colors.border, borderRadius: radii.md, padding: spacing.md, fontSize: 16, backgroundColor: colors.inputBackground, color: colors.foreground },
|
||||||
textarea: { minHeight: 104, textAlignVertical: "top" },
|
textarea: { minHeight: 104, textAlignVertical: "top" },
|
||||||
row: { flexDirection: "row", flexWrap: "wrap", gap: 10, alignItems: "center" },
|
row: { flexDirection: "row", flexWrap: "wrap", gap: spacing.sm, alignItems: "center" },
|
||||||
button: { minHeight: 44, justifyContent: "center", paddingHorizontal: 18, borderRadius: 8, backgroundColor: "#185abd" },
|
button: { minHeight: 44, justifyContent: "center", paddingHorizontal: 18, borderRadius: radii.lg, backgroundColor: colors.primary },
|
||||||
buttonSecondary: { backgroundColor: "#e8eef8" },
|
buttonSecondary: { backgroundColor: colors.secondary },
|
||||||
buttonDanger: { backgroundColor: "#b42318" },
|
buttonDanger: { backgroundColor: colors.destructive },
|
||||||
buttonDisabled: { opacity: 0.5 },
|
buttonDisabled: { opacity: 0.5 },
|
||||||
buttonText: { color: "#fff", fontWeight: "700", fontSize: 15 },
|
buttonText: { color: colors.primaryForeground, fontWeight: "500", fontSize: 14, textAlign: "center" },
|
||||||
buttonTextSecondary: { color: "#173b70" },
|
buttonTextSecondary: { color: colors.secondaryForeground },
|
||||||
link: { color: "#075db7", fontSize: 16, textDecorationLine: "underline", paddingVertical: 10 },
|
link: { color: colors.primary, fontSize: 14, textDecorationLine: "underline", paddingVertical: spacing.sm },
|
||||||
badge: { borderRadius: 20, backgroundColor: "#edf2f8", color: "#263b58", paddingHorizontal: 10, paddingVertical: 5, fontSize: 13 },
|
badge: { borderRadius: radii.full, backgroundColor: colors.muted, color: colors.foreground, paddingHorizontal: 10, paddingVertical: 5, fontSize: 12, alignSelf: "flex-start" },
|
||||||
error: { borderLeftWidth: 4, borderColor: "#b42318", backgroundColor: "#fff1f0", padding: 12, color: "#7a271a" },
|
error: { borderLeftWidth: 4, borderColor: colors.destructive, backgroundColor: "#fef2f2", padding: spacing.md, color: "#7f1d1d", fontSize: 14 },
|
||||||
success: { borderLeftWidth: 4, borderColor: "#16803c", backgroundColor: "#edfdf2", padding: 12, color: "#14532d" },
|
success: { borderLeftWidth: 4, borderColor: colors.success, backgroundColor: "#f0fdf4", padding: spacing.md, color: "#14532d", fontSize: 14 },
|
||||||
modalBackdrop: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 10, backgroundColor: "rgba(10,25,45,.45)", alignItems: "center", justifyContent: "center", padding: 20 },
|
modalBackdrop: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 10, backgroundColor: "rgba(3, 2, 19, 0.45)", alignItems: "center", justifyContent: "center", padding: spacing.lg },
|
||||||
modal: { width: "100%", maxWidth: 560, borderRadius: 14, backgroundColor: "#fff", padding: 20, gap: 14 },
|
modal: { width: "100%", maxWidth: 360, borderRadius: radii.xl, backgroundColor: colors.card, padding: spacing.lg, gap: spacing.md, borderWidth: 1, borderColor: colors.border },
|
||||||
messageClient: { alignSelf: "flex-end", maxWidth: "82%", backgroundColor: "#e4efff", padding: 12, borderRadius: 12 },
|
|
||||||
messageCompany: { alignSelf: "flex-start", maxWidth: "82%", backgroundColor: "#f0f2f5", padding: 12, borderRadius: 12 },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Header({ status, realtime, onLogout }: { status: string; realtime: string; onLogout?: () => void }) {
|
|
||||||
return <View style={styles.header}>
|
|
||||||
<Link href="/" style={styles.link}>HAN Chat</Link>
|
|
||||||
<Link href="/dialogs" style={styles.link}>Чат</Link>
|
|
||||||
<Link href="/profile" style={styles.link}>Профиль</Link>
|
|
||||||
<Link href="/diagnostics" style={styles.link}>Диагностика</Link>
|
|
||||||
<Text style={styles.badge}>{status === "authenticated" ? "Авторизован" : "Гость"} · {realtime}</Text>
|
|
||||||
{onLogout && <Button title="Выйти" secondary onPress={onLogout} />}
|
|
||||||
</View>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Button({ title, onPress, disabled, secondary, danger }: {
|
export function Button({ title, onPress, disabled, secondary, danger }: {
|
||||||
title: string; onPress: () => void; disabled?: boolean; secondary?: boolean; danger?: boolean;
|
title: string; onPress: () => void; disabled?: boolean; secondary?: boolean; danger?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -50,7 +37,7 @@ export function Button({ title, onPress, disabled, secondary, danger }: {
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={({ focused }) => [
|
style={({ focused }) => [
|
||||||
styles.button, secondary && styles.buttonSecondary, danger && styles.buttonDanger,
|
styles.button, secondary && styles.buttonSecondary, danger && styles.buttonDanger,
|
||||||
disabled && styles.buttonDisabled, focused && { borderWidth: 3, borderColor: "#ffbf47" },
|
disabled && styles.buttonDisabled, focused && { borderWidth: 2, borderColor: colors.primary },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.buttonText, secondary && styles.buttonTextSecondary]}>{title}</Text>
|
<Text style={[styles.buttonText, secondary && styles.buttonTextSecondary]}>{title}</Text>
|
||||||
@@ -60,18 +47,18 @@ export function Button({ title, onPress, disabled, secondary, danger }: {
|
|||||||
export function Field(props: React.ComponentProps<typeof TextInput> & { label: string; error?: string }) {
|
export function Field(props: React.ComponentProps<typeof TextInput> & { label: string; error?: string }) {
|
||||||
return <View style={{ gap: 6 }}>
|
return <View style={{ gap: 6 }}>
|
||||||
<Text style={styles.text}>{props.label}</Text>
|
<Text style={styles.text}>{props.label}</Text>
|
||||||
<TextInput accessibilityLabel={props.label} {...props} style={[styles.input, props.multiline && styles.textarea, props.style]} />
|
<TextInput accessibilityLabel={props.label} {...props} style={[styles.input, props.multiline && styles.textarea, props.style]} placeholderTextColor={colors.mutedForeground} />
|
||||||
{props.error && <Text accessibilityRole="alert" style={styles.error}>{props.error}</Text>}
|
{props.error && <Text accessibilityRole="alert" style={styles.error}>{props.error}</Text>}
|
||||||
</View>;
|
</View>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Loading() {
|
export function Loading() {
|
||||||
return <View accessibilityRole="progressbar" style={styles.row}><ActivityIndicator /><Text style={styles.muted}>Загрузка…</Text></View>;
|
return <View accessibilityRole="progressbar" style={styles.row}><ActivityIndicator color={colors.primary} /><Text style={styles.muted}>Загрузка…</Text></View>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorNotice({ error, retry }: { error: unknown; retry?: () => void }) {
|
export function ErrorNotice({ error, retry }: { error: unknown; retry?: () => void }) {
|
||||||
const requestId = error instanceof ApiError ? error.requestId : undefined;
|
const requestId = error instanceof ApiError ? error.requestId : undefined;
|
||||||
return <View style={{ gap: 8 }}>
|
return <View style={{ gap: spacing.sm }}>
|
||||||
<Text accessibilityRole="alert" style={styles.error}>
|
<Text accessibilityRole="alert" style={styles.error}>
|
||||||
{error instanceof ApiError ? error.message : error instanceof Error ? error.message : "Произошла ошибка"}
|
{error instanceof ApiError ? error.message : error instanceof Error ? error.message : "Произошла ошибка"}
|
||||||
{requestId ? `\nКод обращения: ${requestId}` : ""}
|
{requestId ? `\nКод обращения: ${requestId}` : ""}
|
||||||
|
|||||||
@@ -26,12 +26,25 @@ test.beforeEach(async ({ page }) => {
|
|||||||
|
|
||||||
test("гостевой экран загружает публичный контент", async ({ page }) => {
|
test("гостевой экран загружает публичный контент", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.getByRole("heading", { name: "Помощь мигрантам" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Привет! Я HAN" })).toBeVisible();
|
||||||
await expect(page.getByText("Добро пожаловать в HAN Chat")).toBeVisible();
|
await expect(page.getByText("Добро пожаловать в HAN Chat")).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Как оформить визу?" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Как оформить визу?" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Голосовое сообщение" })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Прикрепить файл" })).toBeVisible();
|
||||||
|
await expect(page.getByText("Продление патента через 14 дней")).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Связь с оператором" })).toBeEnabled();
|
||||||
|
await expect(page.getByText("+74950000000")).toHaveCount(0);
|
||||||
await expect(page.getByText(/Гость/)).toBeVisible();
|
await expect(page.getByText(/Гость/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("История открывает текущий единый чат", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("link", { name: "История" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dialogs$/);
|
||||||
|
await expect(page.getByRole("heading", { name: "Чат" })).toBeVisible();
|
||||||
|
await expect(page.getByText(/доступен после авторизации/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("первое сообщение требует обязательные согласия", async ({ page }) => {
|
test("первое сообщение требует обязательные согласия", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByLabel("Сообщение").fill("Здравствуйте");
|
await page.getByLabel("Сообщение").fill("Здравствуйте");
|
||||||
@@ -40,6 +53,21 @@ test("первое сообщение требует обязательные с
|
|||||||
await expect(page.getByRole("button", { name: "Продолжить" })).toBeDisabled();
|
await expect(page.getByRole("button", { name: "Продолжить" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Enter отправляет введённое сообщение", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByLabel("Сообщение").fill("Отправка с клавиатуры");
|
||||||
|
await page.getByLabel("Сообщение").press("Enter");
|
||||||
|
await expect(page.getByRole("heading", { name: "Согласия перед входом" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("сообщение ограничено 4000 символами", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByLabel("Сообщение").fill("а".repeat(4001));
|
||||||
|
await page.getByRole("button", { name: "Отправить" }).click();
|
||||||
|
await expect(page.getByRole("alert")).toContainText("Максимум — 4000 символов");
|
||||||
|
await expect(page.getByRole("heading", { name: "Согласия перед входом" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("профиль гостя не делает защищённый запрос", async ({ page }) => {
|
test("профиль гостя не делает защищённый запрос", async ({ page }) => {
|
||||||
let protectedCalls = 0;
|
let protectedCalls = 0;
|
||||||
await page.route("**/api/v1/me", (route) => { protectedCalls++; return route.abort(); });
|
await page.route("**/api/v1/me", (route) => { protectedCalls++; return route.abort(); });
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { reconcileMessages } from "../../src/reconcile";
|
|||||||
import { sessionMemory } from "../../src/session";
|
import { sessionMemory } from "../../src/session";
|
||||||
import { SingleFlight } from "../../src/single-flight";
|
import { SingleFlight } from "../../src/single-flight";
|
||||||
import { websocketJwtProtocol } from "../../src/realtime";
|
import { websocketJwtProtocol } from "../../src/realtime";
|
||||||
|
import {
|
||||||
|
clearPendingTextIntent,
|
||||||
|
createPendingTextIntent,
|
||||||
|
loadPendingTextIntent,
|
||||||
|
savePendingTextIntent,
|
||||||
|
} from "../../src/pending-intent";
|
||||||
import type { Message } from "../../src/types";
|
import type { Message } from "../../src/types";
|
||||||
|
|
||||||
const message = (id: string, createdAt: string, status: Message["delivery_status"] = "accepted"): Message => ({
|
const message = (id: string, createdAt: string, status: Message["delivery_status"] = "accepted"): Message => ({
|
||||||
@@ -26,6 +32,15 @@ describe("reconcileMessages", () => {
|
|||||||
expect(result.map((item) => item.message_id)).toEqual(["1", "2", "3"]);
|
expect(result.map((item) => item.message_id)).toEqual(["1", "2", "3"]);
|
||||||
expect(result[1]?.delivery_status).toBe("delivered");
|
expect(result[1]?.delivery_status).toBe("delivered");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("сортирует realtime-сообщения по абсолютному времени при разных часовых поясах", () => {
|
||||||
|
const result = reconcileMessages(
|
||||||
|
[message("client", "2026-07-21T15:48:00+03:00")],
|
||||||
|
[message("company", "2026-07-21T12:49:00Z")],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.map((item) => item.message_id)).toEqual(["client", "company"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("UX session memory", () => {
|
describe("UX session memory", () => {
|
||||||
@@ -38,6 +53,24 @@ describe("UX session memory", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("pending message intent", () => {
|
||||||
|
it("сохраняет ключи повтора и очищается после завершения", () => {
|
||||||
|
const intent = createPendingTextIntent("Сообщение после входа");
|
||||||
|
savePendingTextIntent(intent);
|
||||||
|
|
||||||
|
expect(loadPendingTextIntent()).toEqual(intent);
|
||||||
|
|
||||||
|
clearPendingTextIntent();
|
||||||
|
expect(loadPendingTextIntent()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("игнорирует повреждённое значение", () => {
|
||||||
|
sessionStorage.setItem("han.pending-message", "{\"text\":42}");
|
||||||
|
expect(loadPendingTextIntent()).toBeNull();
|
||||||
|
clearPendingTextIntent();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("SingleFlight", () => {
|
describe("SingleFlight", () => {
|
||||||
it("объединяет параллельные refresh операции", async () => {
|
it("объединяет параллельные refresh операции", async () => {
|
||||||
const flight = new SingleFlight<number>();
|
const flight = new SingleFlight<number>();
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
phoneTitle=Вход по номеру телефона
|
phoneTitle=Вход в HAN
|
||||||
|
authBrandSubtitle=Персональный консультант мигранта
|
||||||
|
phoneWelcome=Добро пожаловать
|
||||||
|
phoneIntro=Введите номер телефона — отправим код подтверждения
|
||||||
phoneLabel=Номер телефона
|
phoneLabel=Номер телефона
|
||||||
phoneHelp=Укажите российский или международный номер. Мы используем его только для входа.
|
phoneCountry=Россия · Код страны +7
|
||||||
|
phoneLegalPrefix=Нажимая «Получить код», вы соглашаетесь с
|
||||||
|
termsOfUse=условиями использования
|
||||||
|
phoneLegalAnd=и
|
||||||
|
privacyPolicy=политикой конфиденциальности
|
||||||
sendOtp=Получить код
|
sendOtp=Получить код
|
||||||
otpTitle=Подтверждение телефона
|
otpTitle=Подтверждение телефона
|
||||||
otpLabel=Код подтверждения
|
otpHeading=Введите код
|
||||||
otpSent=Код подтверждения подготовлен для номера {0}
|
otpSent=Отправили SMS на номер {0}
|
||||||
verifyOtp=Войти
|
otpDigit=Цифра кода {0}
|
||||||
|
otpResendCountdown=Отправить повторно через
|
||||||
|
otpResend=Отправить код снова
|
||||||
|
authBack=Назад
|
||||||
|
verifyOtp=Подтвердить
|
||||||
mockMode=Тестовый режим отправки кода
|
mockMode=Тестовый режим отправки кода
|
||||||
phoneInvalid=Проверьте формат номера телефона.
|
phoneInvalid=Проверьте формат номера телефона.
|
||||||
otpInvalid=Код неверен, истёк или уже использован.
|
otpInvalid=Код неверен, истёк или уже использован.
|
||||||
|
|||||||
@@ -1,16 +1,50 @@
|
|||||||
<#import "template.ftl" as layout>
|
<#import "template.ftl" as layout>
|
||||||
<@layout.registrationLayout displayMessage=true; section>
|
<@layout.registrationLayout displayMessage=false; section>
|
||||||
<#if section = "header">${msg("otpTitle")}
|
<#if section = "header">${msg("otpTitle")}
|
||||||
<#elseif section = "form">
|
<#elseif section = "form">
|
||||||
<form id="kc-otp-form" action="${url.loginAction}" method="post">
|
<link rel="stylesheet" href="${url.resourcesPath}/css/han-login.css?v=4"/>
|
||||||
<p class="han-help">${msg("otpSent", maskedPhone!"***")}</p>
|
<div class="han-auth-screen han-otp-screen">
|
||||||
<p class="han-test-mode">${msg("mockMode")}</p>
|
<button class="han-back-button" type="button" onclick="window.history.back()">
|
||||||
<div class="form-group">
|
<span aria-hidden="true">←</span>
|
||||||
<label for="otp">${msg("otpLabel")}</label>
|
<span>${msg("authBack")}</span>
|
||||||
<input id="otp" name="otp" type="password" inputmode="numeric" autocomplete="one-time-code"
|
</button>
|
||||||
minlength="4" maxlength="12" required autofocus/>
|
|
||||||
|
<div class="han-auth-heading han-otp-heading">
|
||||||
|
<h1>${msg("otpHeading")}</h1>
|
||||||
|
<p>${msg("otpSent", maskedPhone!"***")}</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">${msg("verifyOtp")}</button>
|
|
||||||
</form>
|
<form id="kc-otp-form" action="${url.loginAction}" method="post">
|
||||||
|
<input id="otp" name="otp" type="hidden" value=""/>
|
||||||
|
<div id="han-otp-inputs" class="han-otp-inputs <#if message?has_content>han-shake</#if>">
|
||||||
|
<#list 0..5 as index>
|
||||||
|
<input class="han-otp-digit" type="text" inputmode="numeric" maxlength="1"
|
||||||
|
aria-label="${msg("otpDigit", index + 1)}"
|
||||||
|
<#if index == 0>autocomplete="one-time-code" autofocus</#if>
|
||||||
|
aria-invalid="<#if message?has_content>true<#else>false</#if>"/>
|
||||||
|
</#list>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<#if message?has_content>
|
||||||
|
<div class="han-error han-otp-error" role="alert">
|
||||||
|
<span class="han-error-icon">!</span>
|
||||||
|
<span>${kcSanitize(message.summary)?no_esc}</span>
|
||||||
|
</div>
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<div class="han-resend">
|
||||||
|
<p id="han-resend-countdown">${msg("otpResendCountdown")} <strong>0:59</strong></p>
|
||||||
|
<button id="han-resend-button" type="button" hidden onclick="window.history.back()">
|
||||||
|
<span aria-hidden="true">↻</span>
|
||||||
|
<span>${msg("otpResend")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="han-otp-submit" class="han-primary-button" type="submit" disabled>
|
||||||
|
${msg("verifyOtp")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script src="${url.resourcesPath}/js/han-login.js?v=3"></script>
|
||||||
</#if>
|
</#if>
|
||||||
</@layout.registrationLayout>
|
</@layout.registrationLayout>
|
||||||
|
|||||||
@@ -1,16 +1,51 @@
|
|||||||
<#import "template.ftl" as layout>
|
<#import "template.ftl" as layout>
|
||||||
<@layout.registrationLayout displayMessage=true; section>
|
<@layout.registrationLayout displayMessage=false; section>
|
||||||
<#if section = "header">${msg("phoneTitle")}
|
<#if section = "header">${msg("phoneTitle")}
|
||||||
<#elseif section = "form">
|
<#elseif section = "form">
|
||||||
<form id="kc-phone-form" action="${url.loginAction}" method="post">
|
<link rel="stylesheet" href="${url.resourcesPath}/css/han-login.css?v=4"/>
|
||||||
<div class="form-group">
|
<div class="han-auth-screen han-phone-screen">
|
||||||
<label for="phone">${msg("phoneLabel")}</label>
|
<div class="han-auth-main">
|
||||||
<input id="phone" name="phone" type="tel" inputmode="tel" autocomplete="tel"
|
<div class="han-wordmark" aria-label="HAN">
|
||||||
placeholder="+7 900 123-45-67" required autofocus
|
<span class="han-wordmark-text">HAN</span>
|
||||||
aria-invalid="<#if messagesPerField.existsError('phone')>true<#else>false</#if>"/>
|
<span class="han-wordmark-dot"></span>
|
||||||
|
</div>
|
||||||
|
<p class="han-brand-subtitle">${msg("authBrandSubtitle")}</p>
|
||||||
|
|
||||||
|
<div class="han-auth-heading">
|
||||||
|
<h1>${msg("phoneWelcome")}</h1>
|
||||||
|
<p>${msg("phoneIntro")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="kc-phone-form" action="${url.loginAction}" method="post">
|
||||||
|
<div class="han-field">
|
||||||
|
<label for="phone">${msg("phoneLabel")}</label>
|
||||||
|
<input id="phone" name="phone" type="tel" inputmode="numeric" autocomplete="tel"
|
||||||
|
placeholder="+7 (___) ___ __ __" required autofocus
|
||||||
|
aria-invalid="<#if message?has_content>true<#else>false</#if>"/>
|
||||||
|
<p class="han-field-hint">${msg("phoneCountry")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<#if message?has_content>
|
||||||
|
<div class="han-error" role="alert">
|
||||||
|
<span class="han-error-icon">!</span>
|
||||||
|
<span>${kcSanitize(message.summary)?no_esc}</span>
|
||||||
|
</div>
|
||||||
|
</#if>
|
||||||
|
|
||||||
|
<button id="han-phone-submit" class="han-primary-button" type="submit" disabled>
|
||||||
|
<span>${msg("sendOtp")}</span>
|
||||||
|
<span class="han-button-arrow" aria-hidden="true">→</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<p class="han-help">${msg("phoneHelp")}</p>
|
|
||||||
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">${msg("sendOtp")}</button>
|
<p class="han-legal">
|
||||||
</form>
|
${msg("phoneLegalPrefix")}
|
||||||
|
<span>${msg("termsOfUse")}</span>
|
||||||
|
${msg("phoneLegalAnd")}
|
||||||
|
<span>${msg("privacyPolicy")}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<script src="${url.resourcesPath}/js/han-login.js?v=3"></script>
|
||||||
</#if>
|
</#if>
|
||||||
</@layout.registrationLayout>
|
</@layout.registrationLayout>
|
||||||
|
|||||||
@@ -1,18 +1,409 @@
|
|||||||
:root {
|
:root {
|
||||||
--han-primary: #246bfd;
|
--han-primary: #030213;
|
||||||
--han-text: #172033;
|
--han-text: #252525;
|
||||||
|
--han-muted: #717182;
|
||||||
|
--han-border: rgba(0, 0, 0, 0.12);
|
||||||
|
--han-surface: #ffffff;
|
||||||
|
--han-input: #f3f3f5;
|
||||||
|
--han-error: #d4183d;
|
||||||
}
|
}
|
||||||
|
|
||||||
body { color: var(--han-text); }
|
* {
|
||||||
.pf-c-button.pf-m-primary { background: var(--han-primary); border-radius: 10px; min-height: 44px; }
|
|
||||||
input[type="tel"], input[name="otp"] {
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
|
||||||
min-height: 44px;
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid #9aa6bd;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
.han-help { color: #596780; font-size: .9rem; }
|
|
||||||
.han-test-mode { color: #7a4b00; background: #fff3cd; padding: 8px 10px; border-radius: 8px; }
|
html,
|
||||||
|
body,
|
||||||
|
body.login-pf {
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--han-surface);
|
||||||
|
color: var(--han-text);
|
||||||
|
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-pf-page,
|
||||||
|
.pf-v5-c-login,
|
||||||
|
.pf-c-login {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--han-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-pf-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-v5-c-login__container,
|
||||||
|
.pf-c-login__container {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
grid-template-areas: "main";
|
||||||
|
grid-template-columns: minmax(0, 390px);
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-pf-page .card-pf,
|
||||||
|
.pf-v5-c-login__main,
|
||||||
|
.pf-c-login__main {
|
||||||
|
grid-area: main;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 390px;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--han-surface);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#kc-header,
|
||||||
|
#kc-header-wrapper,
|
||||||
|
#kc-page-title {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#kc-content,
|
||||||
|
#kc-content-wrapper,
|
||||||
|
#kc-form,
|
||||||
|
#kc-form-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-auth-screen {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 56px 24px 32px;
|
||||||
|
background: var(--han-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-phone-screen {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-auth-main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-wordmark {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-wordmark-text {
|
||||||
|
color: var(--han-text);
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -1.5px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-wordmark-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--han-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-brand-subtitle {
|
||||||
|
margin: 0 0 48px;
|
||||||
|
color: var(--han-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-auth-heading {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-auth-heading h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--han-text);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-auth-heading p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--han-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-auth-heading p strong,
|
||||||
|
.han-auth-heading p span {
|
||||||
|
color: var(--han-text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-field {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-field label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--han-text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-field input {
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid var(--han-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
outline: none;
|
||||||
|
background: var(--han-input);
|
||||||
|
color: var(--han-text);
|
||||||
|
font-size: 16px;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-field input::placeholder {
|
||||||
|
color: rgba(113, 113, 130, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-field input:focus {
|
||||||
|
border-color: rgba(3, 2, 19, 0.5);
|
||||||
|
box-shadow: 0 0 0 3px rgba(3, 2, 19, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-field input[aria-invalid="true"] {
|
||||||
|
border-color: var(--han-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-field-hint {
|
||||||
|
margin: 6px 4px 0;
|
||||||
|
color: var(--han-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-primary-button {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 56px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--han-primary);
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.15s ease, transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-primary-button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-primary-button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-primary-button:focus-visible,
|
||||||
|
.han-back-button:focus-visible,
|
||||||
|
.han-resend button:focus-visible {
|
||||||
|
outline: 3px solid rgba(3, 2, 19, 0.18);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-primary-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-button-arrow {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-legal {
|
||||||
|
margin: 32px 0 0;
|
||||||
|
color: var(--han-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-legal span {
|
||||||
|
color: rgba(37, 37, 37, 0.7);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid rgba(212, 24, 61, 0.25);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(212, 24, 61, 0.08);
|
||||||
|
color: var(--han-error);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-error-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
flex: 0 0 17px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1px;
|
||||||
|
border: 1.5px solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-otp-screen {
|
||||||
|
padding-top: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-back-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-self: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 0 40px -4px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--han-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-back-button:hover {
|
||||||
|
color: var(--han-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-otp-heading {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-otp-inputs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-otp-digit {
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 56px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--han-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
outline: none;
|
||||||
|
background: var(--han-input);
|
||||||
|
color: var(--han-text);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-otp-digit:focus {
|
||||||
|
border-color: rgba(3, 2, 19, 0.55);
|
||||||
|
box-shadow: 0 0 0 3px rgba(3, 2, 19, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-otp-digit.han-filled {
|
||||||
|
background: rgba(3, 2, 19, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-otp-digit[aria-invalid="true"] {
|
||||||
|
border-color: var(--han-error);
|
||||||
|
background: rgba(212, 24, 61, 0.05);
|
||||||
|
color: var(--han-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-otp-error {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-resend {
|
||||||
|
display: flex;
|
||||||
|
min-height: 40px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
color: var(--han-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-resend p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-resend strong {
|
||||||
|
color: var(--han-text);
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-resend button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--han-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-shake {
|
||||||
|
animation: han-shake 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes han-shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
15% { transform: translateX(-6px); }
|
||||||
|
30% { transform: translateX(6px); }
|
||||||
|
45% { transform: translateX(-5px); }
|
||||||
|
60% { transform: translateX(5px); }
|
||||||
|
75% { transform: translateX(-3px); }
|
||||||
|
90% { transform: translateX(3px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
.han-auth-screen {
|
||||||
|
padding-right: 18px;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.han-otp-inputs {
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
(function () {
|
||||||
|
function initPhoneForm() {
|
||||||
|
var input = document.getElementById("phone");
|
||||||
|
var submit = document.getElementById("han-phone-submit");
|
||||||
|
if (!input || !submit) return;
|
||||||
|
|
||||||
|
function formatPhone(value) {
|
||||||
|
var digits = value.replace(/\D/g, "");
|
||||||
|
if (!digits) return "";
|
||||||
|
if (digits.indexOf("8") === 0) digits = "7" + digits.slice(1);
|
||||||
|
else if (digits.indexOf("7") !== 0) digits = "7" + digits;
|
||||||
|
digits = digits.slice(0, 11);
|
||||||
|
|
||||||
|
var result = "+7";
|
||||||
|
if (digits.length > 1) result += " (" + digits.slice(1, 4);
|
||||||
|
if (digits.length >= 4) result += ") " + digits.slice(4, 7);
|
||||||
|
if (digits.length >= 7) result += " " + digits.slice(7, 9);
|
||||||
|
if (digits.length >= 9) result += " " + digits.slice(9, 11);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePhone() {
|
||||||
|
input.value = formatPhone(input.value);
|
||||||
|
submit.disabled = input.value.replace(/\D/g, "").length !== 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener("input", updatePhone);
|
||||||
|
updatePhone();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initOtpForm() {
|
||||||
|
var fields = Array.prototype.slice.call(document.querySelectorAll(".han-otp-digit"));
|
||||||
|
var hidden = document.getElementById("otp");
|
||||||
|
var submit = document.getElementById("han-otp-submit");
|
||||||
|
if (!fields.length || !hidden || !submit) return;
|
||||||
|
|
||||||
|
function syncOtp() {
|
||||||
|
fields.forEach(function (field) {
|
||||||
|
field.classList.toggle("han-filled", Boolean(field.value));
|
||||||
|
});
|
||||||
|
hidden.value = fields.map(function (field) { return field.value; }).join("");
|
||||||
|
submit.disabled = hidden.value.length !== fields.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.forEach(function (field, index) {
|
||||||
|
field.addEventListener("input", function () {
|
||||||
|
var entered = field.value.replace(/\D/g, "");
|
||||||
|
if (entered.length > 1) {
|
||||||
|
entered.slice(0, fields.length).split("").forEach(function (digit, digitIndex) {
|
||||||
|
fields[digitIndex].value = digit;
|
||||||
|
});
|
||||||
|
fields[Math.min(entered.length, fields.length) - 1].focus();
|
||||||
|
} else {
|
||||||
|
field.value = entered.slice(-1);
|
||||||
|
if (field.value && index < fields.length - 1) fields[index + 1].focus();
|
||||||
|
}
|
||||||
|
syncOtp();
|
||||||
|
});
|
||||||
|
|
||||||
|
field.addEventListener("keydown", function (event) {
|
||||||
|
if (event.key === "Backspace" && !field.value && index > 0) {
|
||||||
|
fields[index - 1].value = "";
|
||||||
|
fields[index - 1].focus();
|
||||||
|
syncOtp();
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowLeft" && index > 0) fields[index - 1].focus();
|
||||||
|
if (event.key === "ArrowRight" && index < fields.length - 1) fields[index + 1].focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
field.addEventListener("paste", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
var pasted = event.clipboardData.getData("text").replace(/\D/g, "").slice(0, fields.length);
|
||||||
|
fields.forEach(function (otpField) { otpField.value = ""; });
|
||||||
|
pasted.split("").forEach(function (digit, digitIndex) {
|
||||||
|
fields[digitIndex].value = digit;
|
||||||
|
});
|
||||||
|
if (pasted.length) fields[Math.min(pasted.length, fields.length) - 1].focus();
|
||||||
|
syncOtp();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
syncOtp();
|
||||||
|
|
||||||
|
var seconds = 59;
|
||||||
|
var countdown = document.getElementById("han-resend-countdown");
|
||||||
|
var countdownValue = countdown && countdown.querySelector("strong");
|
||||||
|
var resend = document.getElementById("han-resend-button");
|
||||||
|
if (!countdown || !countdownValue || !resend) return;
|
||||||
|
|
||||||
|
var timer = window.setInterval(function () {
|
||||||
|
seconds -= 1;
|
||||||
|
countdownValue.textContent = "0:" + String(seconds).padStart(2, "0");
|
||||||
|
if (seconds <= 0) {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
countdown.hidden = true;
|
||||||
|
resend.hidden = false;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
initPhoneForm();
|
||||||
|
initOtpForm();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router';
|
||||||
|
|
||||||
|
export function AuthLoading() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const phone = (location.state as { phone?: string })?.phone ?? '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Имитируем авторизацию — через 2.8 секунды переходим на главную
|
||||||
|
const t = setTimeout(() => navigate('/'), 2800);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-background items-center justify-center px-6">
|
||||||
|
{/* Анимированный логотип */}
|
||||||
|
<div className="mb-12 flex flex-col items-center">
|
||||||
|
<div className="relative mb-6">
|
||||||
|
{/* Пульсирующие кольца */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="w-20 h-20 rounded-full border-2 border-primary/20 animate-ping"
|
||||||
|
style={{ animationDuration: '1.8s' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 rounded-full border-2 border-primary/30 animate-ping"
|
||||||
|
style={{ animationDuration: '1.8s', animationDelay: '0.3s' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Центральный круг с логотипом */}
|
||||||
|
<div className="relative w-20 h-20 rounded-full bg-primary flex items-center justify-center shadow-lg">
|
||||||
|
<span className="text-primary-foreground font-bold text-xl tracking-wider">HAN</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Индикатор загрузки */}
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-2 h-2 rounded-full bg-primary"
|
||||||
|
style={{
|
||||||
|
animation: 'bounce 1.2s ease-in-out infinite',
|
||||||
|
animationDelay: `${i * 0.2}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Текст */}
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">Выполняем вход</h2>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
Проверяем данные{phone ? ` для ${phone}` : ''}...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Подсказка снизу */}
|
||||||
|
<div className="absolute bottom-16 text-center px-8">
|
||||||
|
<p className="text-xs text-muted-foreground/70 leading-relaxed">
|
||||||
|
HAN — ваш персональный консультант по вопросам миграции в России
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 80%, 100% { transform: translateY(0); opacity: 0.5; }
|
||||||
|
40% { transform: translateY(-8px); opacity: 1; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useState, useRef, useEffect, KeyboardEvent, ClipboardEvent } from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router';
|
||||||
|
import { ArrowLeft, RefreshCw, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
const CODE_LENGTH = 6;
|
||||||
|
// Правильный код для демо
|
||||||
|
const DEMO_CODE = '123456';
|
||||||
|
|
||||||
|
export function AuthOtp() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const phone = (location.state as { phone?: string })?.phone ?? '+7 (___) ___ __ __';
|
||||||
|
|
||||||
|
const [digits, setDigits] = useState<string[]>(Array(CODE_LENGTH).fill(''));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [shake, setShake] = useState(false);
|
||||||
|
const [countdown, setCountdown] = useState(59);
|
||||||
|
const [canResend, setCanResend] = useState(false);
|
||||||
|
|
||||||
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRefs.current[0]?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown === 0) { setCanResend(true); return; }
|
||||||
|
const t = setTimeout(() => setCountdown(c => c - 1), 1000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [countdown]);
|
||||||
|
|
||||||
|
function handleChange(index: number, value: string) {
|
||||||
|
const digit = value.replace(/\D/g, '').slice(-1);
|
||||||
|
const next = [...digits];
|
||||||
|
next[index] = digit;
|
||||||
|
setDigits(next);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (digit && index < CODE_LENGTH - 1) {
|
||||||
|
inputRefs.current[index + 1]?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автоматическая проверка когда введены все цифры
|
||||||
|
if (next.every(d => d !== '')) {
|
||||||
|
verifyCode(next.join(''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(index: number, e: KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (e.key === 'Backspace') {
|
||||||
|
if (digits[index]) {
|
||||||
|
const next = [...digits];
|
||||||
|
next[index] = '';
|
||||||
|
setDigits(next);
|
||||||
|
setError(null);
|
||||||
|
} else if (index > 0) {
|
||||||
|
inputRefs.current[index - 1]?.focus();
|
||||||
|
const next = [...digits];
|
||||||
|
next[index - 1] = '';
|
||||||
|
setDigits(next);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowLeft' && index > 0) inputRefs.current[index - 1]?.focus();
|
||||||
|
if (e.key === 'ArrowRight' && index < CODE_LENGTH - 1) inputRefs.current[index + 1]?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePaste(e: ClipboardEvent<HTMLInputElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, CODE_LENGTH);
|
||||||
|
if (!pasted) return;
|
||||||
|
const next = Array(CODE_LENGTH).fill('');
|
||||||
|
pasted.split('').forEach((d, i) => { next[i] = d; });
|
||||||
|
setDigits(next);
|
||||||
|
setError(null);
|
||||||
|
const focusIdx = Math.min(pasted.length, CODE_LENGTH - 1);
|
||||||
|
inputRefs.current[focusIdx]?.focus();
|
||||||
|
if (next.every(d => d !== '')) verifyCode(next.join(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyCode(code: string) {
|
||||||
|
if (code === DEMO_CODE) {
|
||||||
|
navigate('/auth/loading', { state: { phone } });
|
||||||
|
} else {
|
||||||
|
setError('Неверный код. Проверьте и попробуйте снова');
|
||||||
|
setShake(true);
|
||||||
|
setTimeout(() => setShake(false), 500);
|
||||||
|
// Очищаем поля после ошибки
|
||||||
|
setTimeout(() => {
|
||||||
|
setDigits(Array(CODE_LENGTH).fill(''));
|
||||||
|
inputRefs.current[0]?.focus();
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResend() {
|
||||||
|
if (!canResend) return;
|
||||||
|
setDigits(Array(CODE_LENGTH).fill(''));
|
||||||
|
setError(null);
|
||||||
|
setCountdown(59);
|
||||||
|
setCanResend(false);
|
||||||
|
inputRefs.current[0]?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isComplete = digits.every(d => d !== '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-background">
|
||||||
|
<div className="flex flex-col px-6 pt-14 pb-8 h-full">
|
||||||
|
{/* Назад */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/auth/phone')}
|
||||||
|
className="flex items-center gap-1.5 text-muted-foreground text-sm mb-10 -ml-1 self-start hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Заголовок */}
|
||||||
|
<div className="mb-10">
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground mb-2">
|
||||||
|
Введите код
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||||
|
Отправили SMS на номер{' '}
|
||||||
|
<span className="text-foreground font-medium">{phone}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Поля ввода OTP */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div
|
||||||
|
className={`flex gap-2.5 justify-center ${shake ? 'animate-shake' : ''}`}
|
||||||
|
style={shake ? { animation: 'shake 0.4s ease' } : undefined}
|
||||||
|
>
|
||||||
|
{digits.map((digit, i) => (
|
||||||
|
<input
|
||||||
|
key={i}
|
||||||
|
ref={el => { inputRefs.current[i] = el; }}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={1}
|
||||||
|
value={digit}
|
||||||
|
onChange={e => handleChange(i, e.target.value)}
|
||||||
|
onKeyDown={e => handleKeyDown(i, e)}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
className={`
|
||||||
|
w-12 h-14 text-center text-xl font-semibold rounded-xl border transition-all
|
||||||
|
focus:outline-none focus:ring-2
|
||||||
|
${error
|
||||||
|
? 'border-destructive bg-destructive/5 text-destructive focus:ring-destructive/20'
|
||||||
|
: digit
|
||||||
|
? 'border-primary/50 bg-primary/5 text-foreground focus:ring-primary/30 focus:border-primary/60'
|
||||||
|
: 'border-border bg-input-background text-foreground focus:ring-primary/30 focus:border-primary/50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Блок ошибки */}
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 flex items-start gap-2.5 bg-destructive/8 border border-destructive/25 rounded-xl px-4 py-3">
|
||||||
|
<AlertCircle className="w-4 h-4 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-destructive">{error}</p>
|
||||||
|
<p className="text-xs text-destructive/75 mt-0.5">
|
||||||
|
Для демо введите код: <span className="font-mono font-semibold">123456</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Повторная отправка */}
|
||||||
|
<div className="flex items-center justify-center mb-8">
|
||||||
|
{canResend ? (
|
||||||
|
<button
|
||||||
|
onClick={handleResend}
|
||||||
|
className="flex items-center gap-1.5 text-sm font-medium text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
Отправить код снова
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Отправить повторно через{' '}
|
||||||
|
<span className="text-foreground font-medium tabular-nums">
|
||||||
|
0:{countdown.toString().padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопка подтверждения */}
|
||||||
|
<button
|
||||||
|
disabled={!isComplete || !!error}
|
||||||
|
onClick={() => isComplete && !error && verifyCode(digits.join(''))}
|
||||||
|
className="w-full h-14 bg-primary text-primary-foreground rounded-xl font-medium flex items-center justify-center transition-all disabled:opacity-40 disabled:cursor-not-allowed hover:bg-primary/90 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
Подтвердить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
15% { transform: translateX(-6px); }
|
||||||
|
30% { transform: translateX(6px); }
|
||||||
|
45% { transform: translateX(-5px); }
|
||||||
|
60% { transform: translateX(5px); }
|
||||||
|
75% { transform: translateX(-3px); }
|
||||||
|
90% { transform: translateX(3px); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
export function AuthPhone() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
|
||||||
|
function formatPhone(raw: string) {
|
||||||
|
const digits = raw.replace(/\D/g, '').slice(0, 11);
|
||||||
|
if (digits.length === 0) return '';
|
||||||
|
let result = '+7';
|
||||||
|
if (digits.length > 1) result += ' (' + digits.slice(1, 4);
|
||||||
|
if (digits.length >= 4) result += ') ' + digits.slice(4, 7);
|
||||||
|
if (digits.length >= 7) result += ' ' + digits.slice(7, 9);
|
||||||
|
if (digits.length >= 9) result += ' ' + digits.slice(9, 11);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const raw = e.target.value.replace(/\D/g, '');
|
||||||
|
// Если начинается с 8, заменяем на 7
|
||||||
|
const normalized = raw.startsWith('8') ? '7' + raw.slice(1) : raw.startsWith('7') ? raw : '7' + raw;
|
||||||
|
setPhone(formatPhone(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = phone.replace(/\D/g, '').length === 11;
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isValid) navigate('/auth/otp', { state: { phone } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-background">
|
||||||
|
<div className="flex-1 flex flex-col justify-between px-6 pt-16 pb-8">
|
||||||
|
{/* Верхняя часть */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{/* Логотип */}
|
||||||
|
<div className="mb-12">
|
||||||
|
<div className="flex items-end gap-1 mb-3">
|
||||||
|
<span className="text-4xl font-bold tracking-tight text-foreground">HAN</span>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-primary mb-2" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||||
|
Персональный консультант мигранта
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Заголовок */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground mb-2">
|
||||||
|
Добро пожаловать
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||||
|
Введите номер телефона — отправим код подтверждения
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Форма */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">Номер телефона</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={phone}
|
||||||
|
onChange={handleInput}
|
||||||
|
placeholder="+7 (___) ___ __ __"
|
||||||
|
className="w-full h-14 px-4 rounded-xl border border-border bg-input-background text-foreground text-base placeholder:text-muted-foreground/60 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||||
|
autoComplete="tel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground px-1">
|
||||||
|
Россия · Код страны +7
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid}
|
||||||
|
className="w-full h-14 bg-primary text-primary-foreground rounded-xl font-medium flex items-center justify-center gap-2.5 transition-all disabled:opacity-40 disabled:cursor-not-allowed hover:bg-primary/90 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
Получить код
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Низ страницы */}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
Нажимая «Получить код», вы соглашаетесь с{' '}
|
||||||
|
<span className="text-foreground/70 underline underline-offset-2">условиями использования</span>
|
||||||
|
{' '}и{' '}
|
||||||
|
<span className="text-foreground/70 underline underline-offset-2">политикой конфиденциальности</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,12 +4,13 @@ import { Header } from '../components/Header';
|
|||||||
export function Root() {
|
export function Root() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isChat = location.pathname.startsWith('/chat/');
|
const isChat = location.pathname.startsWith('/chat/');
|
||||||
|
const isAuth = location.pathname.startsWith('/auth/');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full flex flex-col bg-background">
|
<div className="size-full flex flex-col bg-background">
|
||||||
<div className="w-full max-w-[390px] mx-auto h-full flex flex-col">
|
<div className="w-full max-w-[390px] mx-auto h-full flex flex-col">
|
||||||
{/* Хедер показываем на всех страницах кроме чата */}
|
{/* Хедер скрываем на чате и экранах авторизации */}
|
||||||
{!isChat && <Header />}
|
{!isChat && !isAuth && <Header />}
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { Profile } from './pages/Profile';
|
|||||||
import { Chat } from './pages/Chat';
|
import { Chat } from './pages/Chat';
|
||||||
import { Calendar } from './pages/Calendar';
|
import { Calendar } from './pages/Calendar';
|
||||||
import { NotificationDetail } from './pages/NotificationDetail';
|
import { NotificationDetail } from './pages/NotificationDetail';
|
||||||
|
import { AuthPhone } from './pages/AuthPhone';
|
||||||
|
import { AuthOtp } from './pages/AuthOtp';
|
||||||
|
import { AuthLoading } from './pages/AuthLoading';
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -18,6 +21,9 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'chat/:id', Component: Chat },
|
{ path: 'chat/:id', Component: Chat },
|
||||||
{ path: 'calendar', Component: Calendar },
|
{ path: 'calendar', Component: Calendar },
|
||||||
{ path: 'notification/:id', Component: NotificationDetail },
|
{ path: 'notification/:id', Component: NotificationDetail },
|
||||||
|
{ path: 'auth/phone', Component: AuthPhone },
|
||||||
|
{ path: 'auth/otp', Component: AuthOtp },
|
||||||
|
{ path: 'auth/loading', Component: AuthLoading },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user