Добавлены уведомления
This commit is contained in:
@@ -1,11 +1,21 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
|
||||
import { AppProvider } from "../src/app-context";
|
||||
import { colors } from "../src/theme";
|
||||
|
||||
export default function RootLayout() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const capture = (event: Event) => {
|
||||
event.preventDefault();
|
||||
(window as typeof window & { __hanInstallPrompt?: Event }).__hanInstallPrompt = event;
|
||||
};
|
||||
window.addEventListener("beforeinstallprompt", capture);
|
||||
return () => window.removeEventListener("beforeinstallprompt", capture);
|
||||
}, []);
|
||||
|
||||
return <SafeAreaProvider>
|
||||
<AppProvider>
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useState } from "react";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useApp } from "../src/app-context";
|
||||
import { AppHeader } from "../src/components/AppHeader";
|
||||
import { ChatInputBar } from "../src/components/ChatInputBar";
|
||||
import { ConsentModal } from "../src/components/ConsentModal";
|
||||
import { HanLogo } from "../src/components/HanLogo";
|
||||
import { NotificationCarousel } from "../src/components/NotificationCarousel";
|
||||
import { PopularQuestionsList } from "../src/components/PopularQuestionsList";
|
||||
import { QuickActions } from "../src/components/QuickActions";
|
||||
import { ScreenShell } from "../src/components/ScreenShell";
|
||||
@@ -29,8 +30,18 @@ export default function HomeScreen() {
|
||||
const [message, setMessage] = useState("");
|
||||
const [sendError, setSendError] = useState<unknown>();
|
||||
const [sending, setSending] = useState(false);
|
||||
const [afterNotificationAuth, setAfterNotificationAuth] = useState<(() => Promise<void>) | undefined>();
|
||||
const { authorize: authorizeParam } = useLocalSearchParams<{ authorize?: string }>();
|
||||
const handledAuthorizeParam = useRef(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (authorizeParam === "1" && !handledAuthorizeParam.current && authStatus !== "authenticated") {
|
||||
handledAuthorizeParam.current = true;
|
||||
setConsentOpen(true);
|
||||
}
|
||||
}, [authStatus, authorizeParam]);
|
||||
|
||||
const sendAuthenticated = async (intent: PendingTextIntent) => {
|
||||
setSending(true);
|
||||
setSendError(undefined);
|
||||
@@ -126,8 +137,11 @@ export default function HomeScreen() {
|
||||
try {
|
||||
const authorized = await authorize(consents);
|
||||
if (authorized && pending) await sendAuthenticated(pending);
|
||||
else if (authorized && afterNotificationAuth) await afterNotificationAuth();
|
||||
} catch (error) {
|
||||
setSendError(error);
|
||||
} finally {
|
||||
setAfterNotificationAuth(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -149,6 +163,15 @@ export default function HomeScreen() {
|
||||
{welcome ? (
|
||||
<Text style={[styles.muted, { paddingHorizontal: 16, marginBottom: 8 }]}>{welcome}</Text>
|
||||
) : null}
|
||||
<NotificationCarousel
|
||||
authenticated={authStatus === "authenticated"}
|
||||
autoplay={config.data?.notification?.carousel_autoplay_enabled ?? false}
|
||||
autoplayIntervalMs={config.data?.notification?.carousel_autoplay_interval_ms ?? 5000}
|
||||
requireAuth={(afterAuth) => {
|
||||
setAfterNotificationAuth(afterAuth ? () => afterAuth : undefined);
|
||||
setConsentOpen(true);
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
<PopularQuestionsList questions={questions} onSelect={(text) => { setMessage(text); void send(text); }} />
|
||||
<ChatInputBar
|
||||
@@ -160,7 +183,7 @@ export default function HomeScreen() {
|
||||
value={message}
|
||||
/>
|
||||
<QuickActions phone={config.data?.operator.call_phone} />
|
||||
{sendError && (
|
||||
{Boolean(sendError) && (
|
||||
<View style={{ paddingHorizontal: 16, paddingBottom: 8 }}>
|
||||
<ErrorNotice error={sendError} />
|
||||
</View>
|
||||
@@ -174,6 +197,7 @@ export default function HomeScreen() {
|
||||
onCancel={() => {
|
||||
setConsentOpen(false);
|
||||
setPending(null);
|
||||
setAfterNotificationAuth(undefined);
|
||||
clearPendingTextIntent();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Platform, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { ApiError } from "../../src/api";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { ScreenShell } from "../../src/components/ScreenShell";
|
||||
import { notificationApi, notificationKeys, uploadDraftApi } from "../../src/notification-api";
|
||||
import { formatNotificationPrice, openNewTab, typeMap } from "../../src/notification-presenter";
|
||||
import { publicApi } from "../../src/services";
|
||||
import { colors, radii, spacing } from "../../src/theme";
|
||||
import type { NotificationButton, UploadDraft } from "../../src/types";
|
||||
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
|
||||
|
||||
export default function NotificationDetailScreen() {
|
||||
const { id = "" } = useLocalSearchParams<{ id: string }>();
|
||||
const { authStatus } = useApp();
|
||||
const authenticated = authStatus === "authenticated";
|
||||
const router = useRouter();
|
||||
const client = useQueryClient();
|
||||
const readSent = useRef(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const detail = useQuery({
|
||||
queryKey: notificationKeys.detail(id),
|
||||
queryFn: () => notificationApi.detail(id),
|
||||
enabled: authenticated && Boolean(id),
|
||||
retry: (count, reason) => !(reason instanceof ApiError && reason.status === 404) && count < 1,
|
||||
});
|
||||
const catalog = useQuery({
|
||||
queryKey: notificationKeys.catalog,
|
||||
queryFn: notificationApi.catalog,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
||||
const byCode = useMemo(() => typeMap(catalog.data ?? []), [catalog.data]);
|
||||
const type = detail.data ? byCode.get(detail.data.notification_type) : undefined;
|
||||
const canUpload = Boolean(detail.data?.details?.send_documents);
|
||||
const drafts = useQuery({
|
||||
queryKey: ["uploads", "notification", id],
|
||||
queryFn: () => uploadDraftApi.list(id),
|
||||
enabled: authenticated && Boolean(id) && canUpload,
|
||||
});
|
||||
const pending = drafts.data ?? detail.data?.details?.pending_documents ?? [];
|
||||
const closed = detail.data?.lifecycle_status === "closed";
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail.data || readSent.current || detail.data.is_read !== false) return;
|
||||
readSent.current = true;
|
||||
void notificationApi.read(id).then((state) => {
|
||||
client.setQueryData(notificationKeys.detail(id), { ...detail.data, ...state });
|
||||
client.setQueryData(notificationKeys.counter, { unread_count: state.unread_count });
|
||||
}).catch(setError);
|
||||
}, [client, detail.data, id]);
|
||||
|
||||
const pressButton = useMutation({
|
||||
mutationFn: (button: NotificationButton) => notificationApi.button(id, button.code),
|
||||
onSuccess: async (state) => {
|
||||
client.setQueryData(notificationKeys.counter, { unread_count: state.unread_count });
|
||||
await client.invalidateQueries({ queryKey: ["notifications"] });
|
||||
router.replace("/notifications");
|
||||
},
|
||||
onError: setError,
|
||||
});
|
||||
const removeDraft = useMutation({
|
||||
mutationFn: uploadDraftApi.remove,
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ["uploads", "notification", id] }),
|
||||
onError: setError,
|
||||
});
|
||||
|
||||
const chooseFile = () => {
|
||||
if (Platform.OS !== "web") {
|
||||
setError(new Error("Выбор файла в этой тестовой сборке доступен только в web."));
|
||||
return;
|
||||
}
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.accept = (config.data?.attachments.allowed_mime_types ?? []).join(",");
|
||||
input.onchange = () => {
|
||||
const files = Array.from(input.files ?? []);
|
||||
if (files.length) void uploadFiles(files);
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
const limits = config.data?.attachments;
|
||||
const maxBytes = (limits?.max_size_mb ?? 5) * 1024 * 1024;
|
||||
const allowed = limits?.allowed_mime_types ?? [];
|
||||
if (pending.length + files.length > 10) {
|
||||
setError(new Error("К одному уведомлению можно приложить не более 10 файлов."));
|
||||
return;
|
||||
}
|
||||
const invalid = files.find((file) => file.size > maxBytes || (allowed.length > 0 && !allowed.includes(file.type)));
|
||||
if (invalid) {
|
||||
setError(new Error(`Файл «${invalid.name}» имеет недопустимый тип или размер.`));
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
try {
|
||||
for (const file of files) await uploadDraftApi.upload(id, file);
|
||||
await drafts.refetch();
|
||||
await detail.refetch();
|
||||
} catch (reason) {
|
||||
setError(reason);
|
||||
}
|
||||
};
|
||||
|
||||
const download = async (documentId: string) => {
|
||||
setError(undefined);
|
||||
try {
|
||||
const result = await notificationApi.documentUrl(id, documentId);
|
||||
openNewTab(result.download_url);
|
||||
await Promise.all([detail.refetch(), client.invalidateQueries({ queryKey: ["notifications"] })]);
|
||||
} catch (reason) {
|
||||
setError(reason);
|
||||
}
|
||||
};
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<ScreenShell>
|
||||
<DetailHeader title="Уведомление" onBack={() => router.replace("/notifications")} />
|
||||
<View style={local.center}>
|
||||
<Text style={styles.text}>Для просмотра уведомления требуется авторизация.</Text>
|
||||
<Button title="Перейти в Центр" onPress={() => router.replace("/notifications")} />
|
||||
</View>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const unavailable = detail.error instanceof ApiError && detail.error.status === 404;
|
||||
const notification = detail.data;
|
||||
const details = notification?.details;
|
||||
const price = formatNotificationPrice(notification?.price);
|
||||
const oldPrice = formatNotificationPrice(notification?.old_price);
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
<DetailHeader title={type?.label ?? "Уведомление"} onBack={() => router.back()} />
|
||||
<ScrollView contentContainerStyle={local.content}>
|
||||
{(detail.isLoading || catalog.isLoading) && <Loading />}
|
||||
{unavailable ? (
|
||||
<View style={local.empty}>
|
||||
<Feather name="slash" size={32} color={colors.mutedForeground} />
|
||||
<Text style={styles.title}>Уведомление недоступно</Text>
|
||||
<Text style={styles.muted}>Возможно, оно уже закрыто или было удалено.</Text>
|
||||
</View>
|
||||
) : (detail.error || catalog.error) ? (
|
||||
<ErrorNotice error={detail.error ?? catalog.error} retry={() => { void detail.refetch(); void catalog.refetch(); }} />
|
||||
) : notification ? (
|
||||
<>
|
||||
{closed && <Text style={styles.error}>Уведомление больше не актуально. Действия недоступны.</Text>}
|
||||
{details?.deadline ? (
|
||||
<View style={local.deadline}>
|
||||
<Feather name="clock" size={16} color={colors.warning} />
|
||||
<Text style={styles.text}>Срок: {new Date(details.deadline).toLocaleString("ru-RU")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<Text accessibilityRole="header" style={styles.title}>{details?.details_header ?? notification.header}</Text>
|
||||
{details?.details_text || notification.text ? <Text style={styles.text}>{details?.details_text ?? notification.text}</Text> : null}
|
||||
{price ? (
|
||||
<View style={local.priceRow}>
|
||||
<Text style={local.price}>{price}</Text>
|
||||
{oldPrice ? <Text style={local.oldPrice}>{oldPrice}</Text> : null}
|
||||
</View>
|
||||
) : null}
|
||||
{details?.todo_header ? <Text style={styles.heading}>{details.todo_header}</Text> : null}
|
||||
{details?.todo_plan?.map((step) => (
|
||||
<View key={`${step.number}-${step.text}`} style={local.step}>
|
||||
<View style={local.stepNumber}><Text style={local.stepNumberText}>{step.number}</Text></View>
|
||||
<Text style={[styles.text, { flex: 1 }]}>{step.text}</Text>
|
||||
</View>
|
||||
))}
|
||||
{details?.documents?.length ? (
|
||||
<View style={local.block}>
|
||||
<Text style={styles.heading}>Документы</Text>
|
||||
{details.documents.map((document) => (
|
||||
<Pressable key={document.document_id} onPress={() => void download(document.document_id)} style={local.file}>
|
||||
<Feather name="file-text" size={20} color={colors.info} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.text}>{document.title}</Text>
|
||||
<Text style={styles.muted}>{formatBytes(document.size_bytes)}</Text>
|
||||
</View>
|
||||
<Feather name="download" size={18} color={colors.foreground} />
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{canUpload ? (
|
||||
<View style={local.block}>
|
||||
<Text style={styles.heading}>Приложить документы</Text>
|
||||
<Text style={styles.muted}>Черновики сохраняются, пока вы не отправите или не удалите их.</Text>
|
||||
{drafts.isLoading && <Loading />}
|
||||
{pending.map((draft) => (
|
||||
<DraftRow
|
||||
key={draft.draft_id}
|
||||
draft={draft}
|
||||
disabled={removeDraft.isPending || closed}
|
||||
onRemove={() => removeDraft.mutate(draft.draft_id)}
|
||||
/>
|
||||
))}
|
||||
<Button title="Добавить файлы" secondary disabled={closed || pending.length >= 10} onPress={chooseFile} />
|
||||
</View>
|
||||
) : null}
|
||||
<View style={local.buttons}>
|
||||
{type?.button_primary ? (
|
||||
<Button
|
||||
title={type.button_primary.label}
|
||||
disabled={closed || pressButton.isPending || (type.button_primary.code === "send_docs" && !pending.some((draft) => draft.scan_status === "clean"))}
|
||||
onPress={() => pressButton.mutate(type.button_primary!)}
|
||||
/>
|
||||
) : null}
|
||||
{type?.button_secondary ? (
|
||||
<Button
|
||||
secondary
|
||||
title={type.button_secondary.label}
|
||||
disabled={closed || pressButton.isPending}
|
||||
onPress={() => pressButton.mutate(type.button_secondary!)}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{error && <ErrorNotice error={error} />}
|
||||
</>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailHeader({ title, onBack }: { title: string; onBack: () => void }) {
|
||||
return (
|
||||
<View style={local.header}>
|
||||
<Pressable accessibilityLabel="Назад" accessibilityRole="button" onPress={onBack} style={local.back}>
|
||||
<Feather name="arrow-left" size={20} color={colors.foreground} />
|
||||
</Pressable>
|
||||
<Text numberOfLines={1} style={[styles.heading, { flex: 1 }]}>{title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftRow({ draft, disabled, onRemove }: { draft: UploadDraft; disabled: boolean; onRemove: () => void }) {
|
||||
const title = draft.title;
|
||||
const status = {
|
||||
pending: "Проверяется",
|
||||
clean: "Готов к отправке",
|
||||
infected: "Файл отклонён",
|
||||
failed: "Ошибка проверки",
|
||||
}[draft.scan_status];
|
||||
return (
|
||||
<View style={local.file}>
|
||||
<Feather name={draft.scan_status === "clean" ? "check-circle" : "file"} size={20} color={draft.scan_status === "clean" ? colors.success : colors.warning} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.text}>{title}</Text>
|
||||
<Text style={styles.muted}>{status} · {formatBytes(draft.size_bytes)}</Text>
|
||||
</View>
|
||||
<Pressable disabled={disabled} accessibilityLabel={`Удалить ${title}`} onPress={onRemove}>
|
||||
<Feather name="trash-2" size={18} color={colors.destructive} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} КБ`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
|
||||
}
|
||||
|
||||
const local = StyleSheet.create({
|
||||
header: { flexDirection: "row", alignItems: "center", gap: spacing.md, padding: spacing.lg, borderBottomWidth: 1, borderBottomColor: colors.border },
|
||||
back: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
|
||||
content: { padding: spacing.lg, gap: spacing.md, paddingBottom: 40 },
|
||||
center: { flex: 1, justifyContent: "center", gap: spacing.md, padding: spacing.lg },
|
||||
empty: { alignItems: "center", gap: spacing.sm, paddingVertical: 48 },
|
||||
deadline: { flexDirection: "row", alignItems: "center", gap: spacing.sm, borderRadius: radii.md, backgroundColor: "#fff7df", padding: spacing.md },
|
||||
priceRow: { flexDirection: "row", alignItems: "baseline", gap: spacing.sm },
|
||||
price: { fontSize: 22, fontWeight: "700", color: colors.foreground },
|
||||
oldPrice: { fontSize: 14, color: colors.mutedForeground, textDecorationLine: "line-through" },
|
||||
step: { flexDirection: "row", alignItems: "flex-start", gap: spacing.md },
|
||||
stepNumber: { width: 28, height: 28, borderRadius: radii.full, alignItems: "center", justifyContent: "center", backgroundColor: colors.primary },
|
||||
stepNumberText: { color: colors.primaryForeground, fontSize: 13, fontWeight: "700" },
|
||||
block: { gap: spacing.sm, paddingVertical: spacing.sm },
|
||||
file: { flexDirection: "row", alignItems: "center", gap: spacing.md, borderWidth: 1, borderColor: colors.border, borderRadius: radii.md, padding: spacing.md, backgroundColor: colors.card },
|
||||
buttons: { gap: spacing.sm, paddingTop: spacing.sm },
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { NotificationCard } from "../../src/components/NotificationCard";
|
||||
import { ScreenShell } from "../../src/components/ScreenShell";
|
||||
import { useNotificationAction } from "../../src/notification-actions";
|
||||
import { notificationApi, notificationKeys } from "../../src/notification-api";
|
||||
import { typeMap } from "../../src/notification-presenter";
|
||||
import { colors, radii, spacing } from "../../src/theme";
|
||||
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
|
||||
|
||||
export default function NotificationCenterScreen() {
|
||||
const { authStatus } = useApp();
|
||||
const authenticated = authStatus === "authenticated";
|
||||
const router = useRouter();
|
||||
const [actionError, setActionError] = useState<unknown>();
|
||||
const catalog = useQuery({
|
||||
queryKey: notificationKeys.catalog,
|
||||
queryFn: notificationApi.catalog,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const notifications = useQuery({
|
||||
queryKey: notificationKeys.center,
|
||||
queryFn: () => notificationApi.list("center"),
|
||||
enabled: authenticated,
|
||||
});
|
||||
const byCode = useMemo(() => typeMap(catalog.data ?? []), [catalog.data]);
|
||||
const action = useNotificationAction({
|
||||
authenticated,
|
||||
onError: setActionError,
|
||||
requireAuth: () => router.replace({ pathname: "/", params: { authorize: "1" } }),
|
||||
});
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
<View style={local.header}>
|
||||
<Pressable accessibilityLabel="Назад" accessibilityRole="button" onPress={() => router.back()} style={local.back}>
|
||||
<Feather name="arrow-left" size={20} color={colors.foreground} />
|
||||
</Pressable>
|
||||
<Text accessibilityRole="header" style={styles.title}>Центр уведомлений</Text>
|
||||
</View>
|
||||
|
||||
{!authenticated ? (
|
||||
<View style={local.gate}>
|
||||
<View style={local.bell}>
|
||||
<Feather name="bell" size={34} color={colors.primaryForeground} />
|
||||
</View>
|
||||
<Text style={styles.title}>Уведомления доступны после входа</Text>
|
||||
<Text style={[styles.text, local.centerText]}>
|
||||
Авторизуйтесь, чтобы видеть важные напоминания, документы и статусы услуг.
|
||||
</Text>
|
||||
<Button
|
||||
title="Авторизоваться"
|
||||
onPress={() => router.replace({ pathname: "/", params: { authorize: "1" } })}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={local.content}>
|
||||
{(notifications.isLoading || catalog.isLoading) && <Loading />}
|
||||
{(notifications.error || catalog.error) && (
|
||||
<ErrorNotice
|
||||
error={notifications.error ?? catalog.error}
|
||||
retry={() => { void notifications.refetch(); void catalog.refetch(); }}
|
||||
/>
|
||||
)}
|
||||
{!notifications.isLoading && !notifications.error && notifications.data?.length === 0 && (
|
||||
<View style={local.empty}>
|
||||
<Feather name="check-circle" size={32} color={colors.success} />
|
||||
<Text style={styles.heading}>Новых уведомлений нет</Text>
|
||||
<Text style={styles.muted}>Здесь появятся важные сообщения и задачи.</Text>
|
||||
</View>
|
||||
)}
|
||||
{notifications.data?.map((item) => (
|
||||
<NotificationCard
|
||||
key={item.id}
|
||||
compact
|
||||
item={item}
|
||||
type={byCode.get(item.notification_type)}
|
||||
onCta={() => void action(item, byCode.get(item.notification_type))}
|
||||
/>
|
||||
))}
|
||||
{Boolean(actionError) && <ErrorNotice error={actionError} />}
|
||||
</ScrollView>
|
||||
)}
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const local = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
padding: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
back: { width: 36, height: 36, borderRadius: radii.full, alignItems: "center", justifyContent: "center" },
|
||||
gate: { flex: 1, justifyContent: "center", alignItems: "center", gap: spacing.md, padding: spacing.xl },
|
||||
bell: { width: 68, height: 68, borderRadius: radii.full, alignItems: "center", justifyContent: "center", backgroundColor: colors.primary },
|
||||
centerText: { textAlign: "center" },
|
||||
content: { padding: spacing.lg, gap: spacing.md, paddingBottom: 40 },
|
||||
empty: { alignItems: "center", gap: spacing.sm, paddingVertical: 48 },
|
||||
});
|
||||
@@ -109,7 +109,7 @@ export default function ProfileScreen() {
|
||||
)}
|
||||
|
||||
<Button title="Выйти из аккаунта" danger onPress={() => void app.signOut()} />
|
||||
{downloadError && <ErrorNotice error={downloadError} />}
|
||||
{Boolean(downloadError) && <ErrorNotice error={downloadError} />}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { AppState, Platform } from "react-native";
|
||||
import { beginAuthorization, clearTokens, completeAuthorization, configureAuthFailure, getAccessToken, logout, refreshTokens } from "./auth";
|
||||
import { sessionMemory } from "./api";
|
||||
import { authApi, publicApi } from "./services";
|
||||
import type { Consents } from "./types";
|
||||
import { notificationApi, notificationKeys } from "./notification-api";
|
||||
import { NotificationRealtimeClient } from "./realtime";
|
||||
import type { Consents, NotificationItem, NotificationRealtimeEvent } from "./types";
|
||||
|
||||
type AuthStatus = "guest" | "authorizing" | "bootstrapping" | "authenticated";
|
||||
type AppContextValue = {
|
||||
@@ -105,7 +107,14 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
signOut: async () => { await logout(); toGuest(); router.replace("/"); },
|
||||
}), [authStatus, realtimeState, authorize, finishCallback, ensureSession, toGuest, router]);
|
||||
|
||||
return <QueryClientProvider client={queryClient}><Context.Provider value={value}>{children}</Context.Provider></QueryClientProvider>;
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Context.Provider value={value}>
|
||||
<NotificationRealtimeBridge authenticated={authStatus === "authenticated"} onState={setRealtimeState} />
|
||||
{children}
|
||||
</Context.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useApp() {
|
||||
@@ -118,3 +127,47 @@ export async function resetAuthForTests() {
|
||||
await clearTokens();
|
||||
queryClient.clear();
|
||||
}
|
||||
|
||||
function NotificationRealtimeBridge({
|
||||
authenticated,
|
||||
onState,
|
||||
}: {
|
||||
authenticated: boolean;
|
||||
onState: (state: string) => void;
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated) return;
|
||||
|
||||
const updateFromEvent = (event: NotificationRealtimeEvent) => {
|
||||
client.setQueryData(notificationKeys.counter, { unread_count: event.unread_count });
|
||||
if (event.type === "notification.updated") {
|
||||
client.setQueryData(notificationKeys.detail(event.notification_id), (old: NotificationItem | undefined) =>
|
||||
old ? { ...old, ...event } : old);
|
||||
}
|
||||
if (event.type === "notification.closed") {
|
||||
client.setQueryData(notificationKeys.detail(event.notification_id), (old: NotificationItem | undefined) =>
|
||||
old ? { ...old, lifecycle_status: "closed" as const } : old);
|
||||
}
|
||||
void client.invalidateQueries({ queryKey: ["notifications"] });
|
||||
};
|
||||
|
||||
const reconcile = async () => {
|
||||
const [home, center, counter] = await Promise.all([
|
||||
notificationApi.list("home"),
|
||||
notificationApi.list("center"),
|
||||
notificationApi.counter(),
|
||||
]);
|
||||
client.setQueryData(notificationKeys.home(true), home);
|
||||
client.setQueryData(notificationKeys.center, center);
|
||||
client.setQueryData(notificationKeys.counter, counter);
|
||||
};
|
||||
|
||||
const realtime = new NotificationRealtimeClient(updateFromEvent, reconcile, onState);
|
||||
realtime.start();
|
||||
return () => realtime.stop();
|
||||
}, [authenticated, client, onState]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "expo-router";
|
||||
import React from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { useApp } from "../app-context";
|
||||
import { notificationApi, notificationKeys } from "../notification-api";
|
||||
import { colors, radii, spacing } from "../theme";
|
||||
|
||||
export function AppHeader({ guestLabel }: { guestLabel?: string }) {
|
||||
export function AppHeader({ guestLabel }: { guestLabel?: string | undefined }) {
|
||||
const { authStatus } = useApp();
|
||||
const authenticated = authStatus === "authenticated";
|
||||
const counter = useQuery({
|
||||
queryKey: notificationKeys.counter,
|
||||
queryFn: notificationApi.counter,
|
||||
enabled: authenticated,
|
||||
});
|
||||
const unread = counter.data?.unread_count ?? 0;
|
||||
|
||||
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>
|
||||
<Link href="/notifications" asChild>
|
||||
<Pressable accessibilityRole="link" style={({ pressed }) => [styles.centerLink, pressed && styles.pressed]}>
|
||||
<View>
|
||||
<Feather name="bell" size={20} color={colors.foreground} />
|
||||
{authenticated && unread > 0 && (
|
||||
<View accessibilityLabel={`${unread} непрочитанных уведомлений`} style={styles.notificationBadge}>
|
||||
<Text style={styles.notificationBadgeText}>{unread > 99 ? "99+" : unread}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.centerText}>Центр</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
|
||||
@@ -38,8 +57,23 @@ const styles = StyleSheet.create({
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
historyLink: { flexDirection: "row", alignItems: "center", gap: spacing.sm },
|
||||
historyText: { fontSize: 14, color: colors.foreground },
|
||||
centerLink: { flexDirection: "row", alignItems: "center", gap: spacing.sm },
|
||||
centerText: { fontSize: 14, color: colors.foreground },
|
||||
notificationBadge: {
|
||||
position: "absolute",
|
||||
top: -9,
|
||||
right: -12,
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
borderRadius: radii.full,
|
||||
paddingHorizontal: 4,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: colors.destructive,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background,
|
||||
},
|
||||
notificationBadgeText: { color: colors.primaryForeground, fontSize: 9, fontWeight: "700" },
|
||||
right: { flexDirection: "row", alignItems: "center", gap: spacing.sm },
|
||||
guestBadge: { fontSize: 12, color: colors.mutedForeground },
|
||||
avatar: {
|
||||
|
||||
@@ -13,8 +13,8 @@ const statusLabel: Record<string, string> = {
|
||||
|
||||
type Props = {
|
||||
message: Message;
|
||||
getAttachmentUrl?: (attachmentId: string) => Promise<string>;
|
||||
onAttachmentError?: (error: unknown) => void;
|
||||
getAttachmentUrl?: ((attachmentId: string) => Promise<string>) | undefined;
|
||||
onAttachmentError?: ((error: unknown) => void) | undefined;
|
||||
};
|
||||
|
||||
export function MessageBubble({ message, getAttachmentUrl, onAttachmentError }: Props) {
|
||||
@@ -54,9 +54,9 @@ export function MessageBubble({ message, getAttachmentUrl, onAttachmentError }:
|
||||
|
||||
function AttachmentPreview({ attachment, getUrl, isClient, onError }: {
|
||||
attachment: Attachment;
|
||||
getUrl?: (attachmentId: string) => Promise<string>;
|
||||
getUrl?: ((attachmentId: string) => Promise<string>) | undefined;
|
||||
isClient: boolean;
|
||||
onError?: (error: unknown) => void;
|
||||
onError?: ((error: unknown) => void) | undefined;
|
||||
}) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string>();
|
||||
const [previewFailed, setPreviewFailed] = useState(false);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { formatNotificationPrice, notificationIcon, notificationPalette } from "../notification-presenter";
|
||||
import { radii, spacing } from "../theme";
|
||||
import type { NotificationItem, NotificationType } from "../types";
|
||||
|
||||
export function NotificationCard({
|
||||
item,
|
||||
type,
|
||||
onCta,
|
||||
onHide,
|
||||
compact = false,
|
||||
disabled = false,
|
||||
}: {
|
||||
item: NotificationItem;
|
||||
type: NotificationType | undefined;
|
||||
onCta: () => void;
|
||||
onHide?: () => void;
|
||||
compact?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const palette = notificationPalette(type?.color_token);
|
||||
const price = formatNotificationPrice(item.price);
|
||||
const oldPrice = formatNotificationPrice(item.old_price);
|
||||
const deadline = item.details?.deadline;
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
local.card,
|
||||
compact && local.compact,
|
||||
{ backgroundColor: palette.background, borderColor: `${palette.accent}40` },
|
||||
]}>
|
||||
<View style={local.headerRow}>
|
||||
<View style={local.labelRow}>
|
||||
<Feather name={notificationIcon(type?.icon_code)} size={18} color={palette.accent} />
|
||||
<Text style={[local.label, { color: palette.foreground }]}>{type?.label ?? "Уведомление"}</Text>
|
||||
{type?.countable && item.is_read === false && <View accessibilityLabel="Непрочитано" style={[local.unread, { backgroundColor: palette.accent }]} />}
|
||||
</View>
|
||||
{onHide && (
|
||||
<Pressable accessibilityLabel="Скрыть уведомление" accessibilityRole="button" hitSlop={10} onPress={onHide}>
|
||||
<Feather name="x" size={18} color={palette.foreground} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text style={[local.title, { color: palette.foreground }]}>{item.header}</Text>
|
||||
{item.text ? <Text numberOfLines={compact ? 2 : 3} style={[local.text, { color: palette.foreground }]}>{item.text}</Text> : null}
|
||||
{deadline ? (
|
||||
<View style={local.deadline}>
|
||||
<Feather name="clock" size={14} color={palette.foreground} />
|
||||
<Text style={[local.meta, { color: palette.foreground }]}>до {new Date(deadline).toLocaleDateString("ru-RU")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{price ? (
|
||||
<View style={local.priceRow}>
|
||||
<Text style={[local.price, { color: palette.foreground }]}>{price}</Text>
|
||||
{oldPrice ? <Text style={[local.oldPrice, { color: palette.foreground }]}>{oldPrice}</Text> : null}
|
||||
</View>
|
||||
) : null}
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onCta}
|
||||
style={({ pressed }) => [local.cta, { backgroundColor: palette.accent }, pressed && local.pressed, disabled && local.disabled]}
|
||||
>
|
||||
<Text style={local.ctaText}>{type?.cta_text ?? "Подробнее →"}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const local = StyleSheet.create({
|
||||
card: {
|
||||
width: 326,
|
||||
minHeight: 190,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.xl,
|
||||
padding: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
compact: { width: "100%", minHeight: 0 },
|
||||
headerRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
|
||||
labelRow: { flexDirection: "row", alignItems: "center", gap: spacing.sm, flexShrink: 1 },
|
||||
label: { fontSize: 12, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.4 },
|
||||
unread: { width: 8, height: 8, borderRadius: radii.full },
|
||||
title: { fontSize: 18, lineHeight: 23, fontWeight: "600" },
|
||||
text: { fontSize: 14, lineHeight: 20, opacity: 0.88 },
|
||||
deadline: { flexDirection: "row", alignItems: "center", gap: spacing.xs },
|
||||
meta: { fontSize: 12 },
|
||||
priceRow: { flexDirection: "row", alignItems: "baseline", gap: spacing.sm },
|
||||
price: { fontSize: 18, fontWeight: "700" },
|
||||
oldPrice: { fontSize: 13, textDecorationLine: "line-through", opacity: 0.65 },
|
||||
cta: { alignSelf: "flex-start", minHeight: 38, justifyContent: "center", borderRadius: radii.md, paddingHorizontal: spacing.md, marginTop: "auto" },
|
||||
ctaText: { color: "#ffffff", fontSize: 14, fontWeight: "600" },
|
||||
pressed: { opacity: 0.78 },
|
||||
disabled: { opacity: 0.5 },
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FlatList, StyleSheet, Text, View } from "react-native";
|
||||
import { notificationApi, notificationKeys } from "../notification-api";
|
||||
import { useNotificationAction } from "../notification-actions";
|
||||
import { typeMap } from "../notification-presenter";
|
||||
import { colors, spacing } from "../theme";
|
||||
import type { NotificationItem } from "../types";
|
||||
import { ErrorNotice, Loading, styles } from "../ui";
|
||||
import { NotificationCard } from "./NotificationCard";
|
||||
|
||||
export function NotificationCarousel({
|
||||
authenticated,
|
||||
autoplay = false,
|
||||
autoplayIntervalMs = 5000,
|
||||
requireAuth,
|
||||
}: {
|
||||
authenticated: boolean;
|
||||
autoplay?: boolean;
|
||||
autoplayIntervalMs?: number;
|
||||
requireAuth: (afterAuth?: () => Promise<void>) => void;
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const list = useRef<FlatList<NotificationItem>>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [actionError, setActionError] = useState<unknown>();
|
||||
const catalog = useQuery({
|
||||
queryKey: notificationKeys.catalog,
|
||||
queryFn: notificationApi.catalog,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const notifications = useQuery({
|
||||
queryKey: notificationKeys.home(authenticated),
|
||||
queryFn: authenticated ? () => notificationApi.list("home") : notificationApi.guestHome,
|
||||
});
|
||||
const byCode = useMemo(() => typeMap(catalog.data ?? []), [catalog.data]);
|
||||
const action = useNotificationAction({ authenticated, requireAuth, onError: setActionError });
|
||||
const hide = useMutation({
|
||||
mutationFn: notificationApi.hide,
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: notificationKeys.home(true) }),
|
||||
client.invalidateQueries({ queryKey: notificationKeys.center }),
|
||||
]);
|
||||
},
|
||||
onError: setActionError,
|
||||
});
|
||||
const data = notifications.data ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoplay || data.length < 2) return;
|
||||
const timer = setInterval(() => {
|
||||
setActiveIndex((current) => {
|
||||
const next = (current + 1) % data.length;
|
||||
list.current?.scrollToIndex({ index: next, animated: true });
|
||||
return next;
|
||||
});
|
||||
}, Math.max(1000, autoplayIntervalMs));
|
||||
return () => clearInterval(timer);
|
||||
}, [autoplay, autoplayIntervalMs, data.length]);
|
||||
|
||||
if (notifications.isLoading || catalog.isLoading) {
|
||||
return <View style={local.state}><Loading /></View>;
|
||||
}
|
||||
if (notifications.error || catalog.error) {
|
||||
return (
|
||||
<View style={local.state}>
|
||||
<ErrorNotice
|
||||
error={notifications.error ?? catalog.error}
|
||||
retry={() => { void notifications.refetch(); void catalog.refetch(); }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!data.length) return null;
|
||||
|
||||
return (
|
||||
<View style={local.section}>
|
||||
<Text accessibilityRole="header" style={[styles.heading, local.heading]}>Важное для вас</Text>
|
||||
<FlatList
|
||||
ref={list}
|
||||
horizontal
|
||||
data={data}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={local.content}
|
||||
ItemSeparatorComponent={() => <View style={{ width: spacing.md }} />}
|
||||
onMomentumScrollEnd={(event) => {
|
||||
const width = event.nativeEvent.layoutMeasurement.width;
|
||||
if (width > 0) setActiveIndex(Math.round(event.nativeEvent.contentOffset.x / width));
|
||||
}}
|
||||
renderItem={({ item }) => (
|
||||
<NotificationCard
|
||||
disabled={hide.isPending}
|
||||
item={item}
|
||||
type={byCode.get(item.notification_type)}
|
||||
onCta={() => void action(item, byCode.get(item.notification_type))}
|
||||
{...(authenticated ? { onHide: () => hide.mutate(item.id) } : {})}
|
||||
/>
|
||||
)}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
{data.length > 1 && (
|
||||
<View style={local.dots} accessibilityLabel={`${activeIndex + 1} из ${data.length}`}>
|
||||
{data.map((item, index) => <View key={item.id} style={[local.dot, index === activeIndex && local.dotActive]} />)}
|
||||
</View>
|
||||
)}
|
||||
{Boolean(actionError) && <View style={local.error}><ErrorNotice error={actionError} /></View>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const local = StyleSheet.create({
|
||||
section: { paddingVertical: spacing.md },
|
||||
heading: { paddingHorizontal: spacing.lg, marginBottom: spacing.sm },
|
||||
content: { paddingHorizontal: spacing.lg },
|
||||
state: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
|
||||
dots: { flexDirection: "row", justifyContent: "center", gap: 6, marginTop: spacing.sm },
|
||||
dot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.muted },
|
||||
dotActive: { width: 16, backgroundColor: colors.primary },
|
||||
error: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import { Linking, Pressable, StyleSheet, Text } from "react-native";
|
||||
import { colors, radii, spacing } from "../theme";
|
||||
|
||||
export function QuickActions({ phone }: { phone?: string }) {
|
||||
export function QuickActions({ phone }: { phone?: string | undefined }) {
|
||||
const call = () => {
|
||||
if (phone) void Linking.openURL(`tel:${phone}`);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { dialogApi } from "./services";
|
||||
import { notificationApi } from "./notification-api";
|
||||
import { actionDialogId, actionUrl, openNewTab } from "./notification-presenter";
|
||||
import {
|
||||
clearPendingTextIntent,
|
||||
createPendingTextIntent,
|
||||
savePendingTextIntent,
|
||||
type PendingTextIntent,
|
||||
} from "./pending-intent";
|
||||
import type { NotificationItem, NotificationType } from "./types";
|
||||
|
||||
type InstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
};
|
||||
|
||||
export function useNotificationAction({
|
||||
authenticated,
|
||||
requireAuth,
|
||||
onError,
|
||||
}: {
|
||||
authenticated: boolean;
|
||||
requireAuth?: (afterAuth?: () => Promise<void>) => void;
|
||||
onError: (error: unknown) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const client = useQueryClient();
|
||||
|
||||
const sendGuestOffer = useCallback(async (intent: PendingTextIntent) => {
|
||||
const dialog = await dialogApi.create(intent.dialogKey);
|
||||
await dialogApi.sendText(dialog.dialog_id, intent.text, intent.messageKey);
|
||||
clearPendingTextIntent();
|
||||
router.push(`/dialogs/${dialog.dialog_id}`);
|
||||
}, [router]);
|
||||
|
||||
return useCallback(async (item: NotificationItem, type?: NotificationType) => {
|
||||
if (!type) return;
|
||||
onError(undefined);
|
||||
try {
|
||||
if (!authenticated) {
|
||||
if (type.cta_action === "install_app_prompt") {
|
||||
await promptInstallOrOpenInstruction(item.instruction_url);
|
||||
return;
|
||||
}
|
||||
if (type.cta_action === "send_chat_message" && item.chat_message_text) {
|
||||
const intent = createPendingTextIntent(item.chat_message_text);
|
||||
savePendingTextIntent(intent);
|
||||
requireAuth?.(() => sendGuestOffer(intent));
|
||||
return;
|
||||
}
|
||||
requireAuth?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const state = await notificationApi.cta(item.id);
|
||||
client.setQueryData(notificationApiStateKey(item.id), (old: NotificationItem | undefined) =>
|
||||
old ? { ...old, ...state } : old);
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: ["notifications"] }),
|
||||
client.invalidateQueries({ queryKey: ["notifications", "counter"] }),
|
||||
]);
|
||||
|
||||
if (type.cta_action === "open_detail") {
|
||||
router.push(`/notification/${item.id}`);
|
||||
return;
|
||||
}
|
||||
const url = actionUrl(state);
|
||||
if (url) openNewTab(url);
|
||||
const dialogId = actionDialogId(state);
|
||||
if (dialogId) router.push(`/dialogs/${dialogId}`);
|
||||
else if (type.cta_action === "send_chat_message") router.push("/dialogs");
|
||||
} catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}, [authenticated, client, onError, requireAuth, router, sendGuestOffer]);
|
||||
}
|
||||
|
||||
function notificationApiStateKey(id: string) {
|
||||
return ["notifications", "detail", id] as const;
|
||||
}
|
||||
|
||||
async function promptInstallOrOpenInstruction(instructionUrl?: string | null) {
|
||||
const event = typeof window !== "undefined"
|
||||
? (window as typeof window & { __hanInstallPrompt?: InstallPromptEvent }).__hanInstallPrompt
|
||||
: undefined;
|
||||
if (event) {
|
||||
await event.prompt();
|
||||
const choice = await event.userChoice;
|
||||
if (choice.outcome === "accepted") return;
|
||||
}
|
||||
if (!instructionUrl) throw new Error("Инструкция по установке недоступна");
|
||||
openNewTab(instructionUrl);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { apiRequest, json } from "./api";
|
||||
import type {
|
||||
NotificationActionState,
|
||||
NotificationCounter,
|
||||
NotificationItem,
|
||||
NotificationList,
|
||||
NotificationType,
|
||||
UploadDraft,
|
||||
} from "./types";
|
||||
|
||||
type CatalogResponse = { items: NotificationType[] };
|
||||
type UploadListResponse = { items: UploadDraft[] };
|
||||
|
||||
export const notificationKeys = {
|
||||
catalog: ["notification-types"] as const,
|
||||
home: (authenticated: boolean) => ["notifications", authenticated ? "P" : "G", "home"] as const,
|
||||
center: ["notifications", "P", "center"] as const,
|
||||
counter: ["notifications", "counter"] as const,
|
||||
detail: (id: string) => ["notifications", "detail", id] as const,
|
||||
};
|
||||
|
||||
export const notificationApi = {
|
||||
catalog: async () => (await apiRequest<CatalogResponse>("/api/v1/public/notification-types")).items,
|
||||
guestHome: async () => (await apiRequest<NotificationList>("/api/v1/public/notifications")).items,
|
||||
list: async (place: "home" | "center") =>
|
||||
(await apiRequest<NotificationList>(
|
||||
`/api/v1/notifications?place=${place}`,
|
||||
{ protected: true },
|
||||
)).items,
|
||||
counter: () =>
|
||||
apiRequest<NotificationCounter>("/api/v1/notifications/counter", { protected: true }),
|
||||
detail: (id: string) =>
|
||||
apiRequest<NotificationItem>(`/api/v1/notifications/${encodeURIComponent(id)}`, { protected: true }),
|
||||
read: (id: string) =>
|
||||
apiRequest<NotificationActionState>(`/api/v1/notifications/${encodeURIComponent(id)}/read`, {
|
||||
method: "POST", protected: true, body: "{}",
|
||||
}),
|
||||
hide: (id: string) =>
|
||||
apiRequest<NotificationActionState>(`/api/v1/notifications/${encodeURIComponent(id)}/hide`, {
|
||||
method: "POST", protected: true, body: "{}",
|
||||
}),
|
||||
cta: (id: string) =>
|
||||
apiRequest<NotificationActionState>(`/api/v1/notifications/${encodeURIComponent(id)}/cta`, {
|
||||
method: "POST", protected: true, body: "{}",
|
||||
}),
|
||||
button: (id: string, code: string) =>
|
||||
apiRequest<NotificationActionState>(
|
||||
`/api/v1/notifications/${encodeURIComponent(id)}/buttons/${encodeURIComponent(code)}`,
|
||||
{ method: "POST", protected: true, body: "{}" },
|
||||
),
|
||||
documentUrl: (notificationId: string, documentId: string) =>
|
||||
apiRequest<{ download_url: string; expires_at: string }>(
|
||||
`/api/v1/notifications/${encodeURIComponent(notificationId)}/documents/${encodeURIComponent(documentId)}/download-url`,
|
||||
{ protected: true },
|
||||
),
|
||||
};
|
||||
|
||||
export const uploadDraftApi = {
|
||||
list: async (notificationId: string) =>
|
||||
(await apiRequest<UploadListResponse>(
|
||||
`/api/v1/uploads?context_type=notification&context_id=${encodeURIComponent(notificationId)}`,
|
||||
{ protected: true },
|
||||
)).items,
|
||||
remove: (draftId: string) =>
|
||||
apiRequest<void>(`/api/v1/uploads/${encodeURIComponent(draftId)}`, {
|
||||
method: "DELETE", protected: true,
|
||||
}),
|
||||
upload: async (notificationId: string, file: File) => {
|
||||
const checksum = await sha256(file);
|
||||
const draft = await apiRequest<{
|
||||
draft_id: string;
|
||||
upload_url: string;
|
||||
upload_headers: Record<string, string>;
|
||||
expires_at: string;
|
||||
}>("/api/v1/uploads/init", {
|
||||
method: "POST",
|
||||
protected: true,
|
||||
body: json({
|
||||
context_type: "notification",
|
||||
context_id: notificationId,
|
||||
file_name: file.name,
|
||||
mime_type: file.type,
|
||||
size_bytes: file.size,
|
||||
}),
|
||||
});
|
||||
const upload = await fetch(draft.upload_url, {
|
||||
method: "PUT",
|
||||
headers: draft.upload_headers ?? { "Content-Type": file.type },
|
||||
body: file,
|
||||
});
|
||||
if (!upload.ok) throw new Error("Не удалось загрузить файл в хранилище");
|
||||
await apiRequest<UploadDraft>(`/api/v1/uploads/${encodeURIComponent(draft.draft_id)}/complete`, {
|
||||
method: "POST",
|
||||
protected: true,
|
||||
body: json({ checksum }),
|
||||
});
|
||||
return draft.draft_id;
|
||||
},
|
||||
};
|
||||
|
||||
async function sha256(file: Blob) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
||||
const hex = Array.from(
|
||||
new Uint8Array(digest),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
return `sha256:${hex}`;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import type { NotificationActionState, NotificationType } from "./types";
|
||||
|
||||
export type NotificationPalette = {
|
||||
background: string;
|
||||
foreground: string;
|
||||
accent: string;
|
||||
};
|
||||
|
||||
const neutral: NotificationPalette = {
|
||||
background: "#f3f3f5",
|
||||
foreground: "#252525",
|
||||
accent: "#030213",
|
||||
};
|
||||
|
||||
const palettes: Record<string, NotificationPalette> = {
|
||||
critical: { background: "#feecef", foreground: "#7f1d1d", accent: "#d4183d" },
|
||||
warning: { background: "#fff7df", foreground: "#713f12", accent: "#ca8a04" },
|
||||
success: { background: "#eaf8ee", foreground: "#14532d", accent: "#16a34a" },
|
||||
info: { background: "#eaf2ff", foreground: "#1e3a8a", accent: "#2563eb" },
|
||||
promo: { background: "#f4edff", foreground: "#4c1d95", accent: "#7c3aed" },
|
||||
neutral,
|
||||
};
|
||||
|
||||
const icons: Record<string, keyof typeof Feather.glyphMap> = {
|
||||
alert: "alert-circle",
|
||||
urgent: "alert-triangle",
|
||||
payment: "credit-card",
|
||||
documents: "file-text",
|
||||
document: "file-text",
|
||||
status: "activity",
|
||||
reminder: "clock",
|
||||
news: "bell",
|
||||
promo: "gift",
|
||||
ads: "star",
|
||||
authorize: "log-in",
|
||||
install: "download",
|
||||
info: "info",
|
||||
};
|
||||
|
||||
export function notificationPalette(token?: string | null): NotificationPalette {
|
||||
return token ? (palettes[token] ?? neutral) : neutral;
|
||||
}
|
||||
|
||||
export function notificationIcon(code?: string | null): keyof typeof Feather.glyphMap {
|
||||
return code ? (icons[code] ?? "bell") : "bell";
|
||||
}
|
||||
|
||||
export function typeMap(catalog: NotificationType[]) {
|
||||
return new Map(catalog.map((type) => [type.code, type]));
|
||||
}
|
||||
|
||||
export function formatNotificationPrice(value?: number | string | null) {
|
||||
if (value === null || value === undefined) return null;
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return null;
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
maximumFractionDigits: number % 1 === 0 ? 0 : 2,
|
||||
}).format(number);
|
||||
}
|
||||
|
||||
export function actionUrl(state: NotificationActionState) {
|
||||
return state.result?.action === "open_url" ? state.result.url : undefined;
|
||||
}
|
||||
|
||||
export function actionDialogId(state: NotificationActionState) {
|
||||
return state.result?.action === "chat_message_sent"
|
||||
? state.result.message.dialog_id
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function openNewTab(url: string) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
void import("react-native").then(({ Linking }) => Linking.openURL(url));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { env } from "./config";
|
||||
import { getAccessToken, refreshTokens } from "./auth";
|
||||
import { dialogApi } from "./services";
|
||||
import type { Message } from "./types";
|
||||
import type { Message, NotificationRealtimeEvent } from "./types";
|
||||
export { reconcileMessages } from "./reconcile";
|
||||
|
||||
export type RealtimeState = "idle" | "connecting" | "websocket" | "polling";
|
||||
@@ -10,6 +10,9 @@ export type RealtimeEvent =
|
||||
| { type: "message.status"; dialog_id: string; message_id: string; safety_status: Message["safety_status"]; delivery_status: Message["delivery_status"]; cursor?: string }
|
||||
| { type: "dialog.status"; dialog_id: string; status: string; cursor?: string };
|
||||
|
||||
export const NOTIFICATION_OUTAGE_MS = 30_000;
|
||||
export const NOTIFICATION_POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
const safeCursors = new Map<string, string>();
|
||||
export const getRealtimeDiagnostics = () =>
|
||||
[...safeCursors.entries()].map(([dialogId, cursor]) => ({
|
||||
@@ -26,8 +29,8 @@ export function websocketJwtProtocol(token: string) {
|
||||
|
||||
export class RealtimeClient {
|
||||
private socket?: WebSocket;
|
||||
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
||||
private pollingTimer?: ReturnType<typeof setTimeout>;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private pollingTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private disconnectedAt = 0;
|
||||
private attempt = 0;
|
||||
private stopped = true;
|
||||
@@ -143,3 +146,114 @@ export class RealtimeClient {
|
||||
this.pollingTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class NotificationRealtimeClient {
|
||||
private socket?: WebSocket;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private pollingTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private outageTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private disconnectedAt = 0;
|
||||
private attempt = 0;
|
||||
private stopped = true;
|
||||
private readonly eventIds = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly onEvent: (event: NotificationRealtimeEvent) => void,
|
||||
private readonly reconcile: () => Promise<void>,
|
||||
private readonly onState: (state: RealtimeState) => void,
|
||||
) {}
|
||||
|
||||
start() {
|
||||
if (!this.stopped) return;
|
||||
this.stopped = false;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.pollingTimer) clearTimeout(this.pollingTimer);
|
||||
if (this.outageTimer) clearTimeout(this.outageTimer);
|
||||
this.socket?.close();
|
||||
this.onState("idle");
|
||||
}
|
||||
|
||||
private connect() {
|
||||
if (this.stopped) return;
|
||||
const token = getAccessToken();
|
||||
if (!token) return;
|
||||
this.onState("connecting");
|
||||
const url = env.apiBaseUrl.replace(/^http/, "ws") + "/api/v1/realtime";
|
||||
this.socket = new WebSocket(url, ["han-chat-v1", websocketJwtProtocol(token)]);
|
||||
this.socket.onopen = () => {
|
||||
this.attempt = 0;
|
||||
this.socket?.send(JSON.stringify({ type: "subscribe", dialog_ids: [], notifications: true }));
|
||||
void this.reconcile().then(() => {
|
||||
this.disconnectedAt = 0;
|
||||
this.stopPolling();
|
||||
this.onState("websocket");
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
this.socket.onmessage = ({ data }) => {
|
||||
try {
|
||||
const event = JSON.parse(String(data)) as NotificationRealtimeEvent | { type: string };
|
||||
if (event.type === "ping") {
|
||||
this.socket?.send(JSON.stringify({ type: "pong" }));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.type === "notification.created"
|
||||
|| event.type === "notification.updated"
|
||||
|| event.type === "notification.closed"
|
||||
) {
|
||||
const notificationEvent = event as NotificationRealtimeEvent;
|
||||
if (this.eventIds.has(notificationEvent.event_id)) return;
|
||||
this.eventIds.add(notificationEvent.event_id);
|
||||
if (this.eventIds.size > 100) {
|
||||
const oldest = this.eventIds.values().next().value as string | undefined;
|
||||
if (oldest) this.eventIds.delete(oldest);
|
||||
}
|
||||
this.onEvent(notificationEvent);
|
||||
}
|
||||
} catch { /* malformed and unknown messages are ignored */ }
|
||||
};
|
||||
this.socket.onclose = (event) => {
|
||||
if (this.stopped) return;
|
||||
if (!this.disconnectedAt) {
|
||||
this.disconnectedAt = Date.now();
|
||||
this.outageTimer = setTimeout(() => this.startPolling(), NOTIFICATION_OUTAGE_MS);
|
||||
}
|
||||
if (event.code === 4401 || event.code === 1008) {
|
||||
void refreshTokens().finally(() => this.scheduleReconnect());
|
||||
} else {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
this.socket.onerror = () => this.socket?.close();
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.stopped) return;
|
||||
const base = Math.min(30_000, 1000 * 2 ** this.attempt++);
|
||||
const delay = Math.round(base * (0.8 + Math.random() * 0.4));
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
|
||||
private startPolling() {
|
||||
if (this.stopped || this.pollingTimer || !this.disconnectedAt) return;
|
||||
this.onState("polling");
|
||||
const poll = async () => {
|
||||
if (this.stopped) return;
|
||||
await this.reconcile().catch(() => undefined);
|
||||
this.pollingTimer = setTimeout(poll, NOTIFICATION_POLL_INTERVAL_MS);
|
||||
};
|
||||
void poll();
|
||||
}
|
||||
|
||||
private stopPolling() {
|
||||
if (this.pollingTimer) clearTimeout(this.pollingTimer);
|
||||
if (this.outageTimer) clearTimeout(this.outageTimer);
|
||||
this.pollingTimer = undefined;
|
||||
this.outageTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ export type DocumentItem = {
|
||||
export type PublicConfig = {
|
||||
auth: { phone_enabled: boolean; password_enabled: boolean };
|
||||
operator: { call_phone: string };
|
||||
notification?: {
|
||||
carousel_autoplay_enabled?: boolean;
|
||||
carousel_autoplay_interval_ms?: number;
|
||||
};
|
||||
consents: Record<string, {
|
||||
required: boolean;
|
||||
document_url: string | null;
|
||||
@@ -75,3 +79,134 @@ export type PublicContent = {
|
||||
popular_questions: Array<{ id: string; mnemonic: string; text: string }>;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type NotificationContour = "G" | "P";
|
||||
export type NotificationCtaAction =
|
||||
| "open_detail"
|
||||
| "open_payment_url"
|
||||
| "send_chat_message"
|
||||
| "start_auth"
|
||||
| "install_app_prompt";
|
||||
|
||||
export type NotificationButton = {
|
||||
code: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type NotificationType = {
|
||||
code: string;
|
||||
label: string;
|
||||
color_token: string;
|
||||
icon_code: string | null;
|
||||
cta_text: string;
|
||||
cta_action: NotificationCtaAction;
|
||||
countable: boolean;
|
||||
contour: NotificationContour;
|
||||
button_primary: NotificationButton | null;
|
||||
button_secondary: NotificationButton | null;
|
||||
};
|
||||
|
||||
export type NotificationTodoItem = {
|
||||
number: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type NotificationDocument = {
|
||||
document_id: string;
|
||||
title: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
};
|
||||
|
||||
export type UploadDraft = {
|
||||
draft_id: string;
|
||||
context_type: "notification";
|
||||
context_id: string;
|
||||
title: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
scan_status: "pending" | "clean" | "infected" | "failed";
|
||||
state: "draft" | "submitted" | "discarded";
|
||||
};
|
||||
|
||||
export type NotificationDetails = {
|
||||
deadline?: string | null;
|
||||
details_header?: string | null;
|
||||
details_text?: string | null;
|
||||
todo_header?: string | null;
|
||||
todo_plan?: NotificationTodoItem[] | null;
|
||||
send_documents?: boolean;
|
||||
pending_documents?: UploadDraft[];
|
||||
documents?: NotificationDocument[] | null;
|
||||
};
|
||||
|
||||
export type NotificationItem = {
|
||||
id: string;
|
||||
notification_type: string;
|
||||
notification_datetime: string;
|
||||
header: string;
|
||||
text?: string | null;
|
||||
date_expired?: string | null;
|
||||
price?: number | string | null;
|
||||
old_price?: number | string | null;
|
||||
instruction_url?: string | null;
|
||||
instruction_open_mode?: "new_tab" | null;
|
||||
chat_message_text?: string | null;
|
||||
details?: NotificationDetails | null;
|
||||
priority?: number;
|
||||
lifecycle_status?: "active" | "closed";
|
||||
visibility?: "visible" | "hidden";
|
||||
is_read?: boolean;
|
||||
close_reason?: string | null;
|
||||
countable?: boolean;
|
||||
cta_action?: NotificationCtaAction;
|
||||
};
|
||||
|
||||
export type NotificationList = { items: NotificationItem[] };
|
||||
export type NotificationCounter = { unread_count: number };
|
||||
export type NotificationActionResult =
|
||||
| { action: "open_detail"; notification_id: string }
|
||||
| { action: "open_url"; url: string }
|
||||
| { action: "chat_message_sent"; message: Message };
|
||||
|
||||
export type NotificationActionState = {
|
||||
notification_id: string;
|
||||
lifecycle_status: "active" | "closed";
|
||||
visibility: "visible" | "hidden";
|
||||
is_read: boolean;
|
||||
close_reason: string | null;
|
||||
date_expired: string | null;
|
||||
unread_count: number;
|
||||
result: NotificationActionResult | null;
|
||||
};
|
||||
|
||||
export type NotificationRealtimeEvent =
|
||||
| {
|
||||
type: "notification.created";
|
||||
event_id: string;
|
||||
occurred_at: string;
|
||||
notification: NotificationItem;
|
||||
unread_count: number;
|
||||
}
|
||||
| {
|
||||
type: "notification.updated";
|
||||
event_id: string;
|
||||
occurred_at: string;
|
||||
notification_id: string;
|
||||
unread_count: number;
|
||||
is_read?: boolean;
|
||||
visibility?: "visible" | "hidden";
|
||||
date_expired?: string | null;
|
||||
close_reason?: string | null;
|
||||
}
|
||||
| {
|
||||
type: "notification.closed";
|
||||
event_id: string;
|
||||
occurred_at: string;
|
||||
notification_id: string;
|
||||
close_reason: string;
|
||||
unread_count: number;
|
||||
is_read?: boolean;
|
||||
visibility?: "visible" | "hidden";
|
||||
date_expired?: string | null;
|
||||
};
|
||||
|
||||
@@ -35,9 +35,9 @@ export function Button({ title, onPress, disabled, secondary, danger }: {
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ focused }) => [
|
||||
style={({ pressed }) => [
|
||||
styles.button, secondary && styles.buttonSecondary, danger && styles.buttonDanger,
|
||||
disabled && styles.buttonDisabled, focused && { borderWidth: 2, borderColor: colors.primary },
|
||||
disabled && styles.buttonDisabled, pressed && { opacity: 0.8 },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.buttonText, secondary && styles.buttonTextSecondary]}>{title}</Text>
|
||||
|
||||
@@ -8,6 +8,17 @@ vi.mock("expo-crypto", () => ({
|
||||
randomUUID: vi.fn(() => "123e4567-e89b-42d3-a456-426614174000"),
|
||||
digestStringAsync: vi.fn(async () => "stable-fingerprint"),
|
||||
}));
|
||||
vi.mock("expo-auth-session", () => ({
|
||||
makeRedirectUri: vi.fn(() => "https://example.test/auth/callback"),
|
||||
}));
|
||||
vi.mock("expo-secure-store", () => ({
|
||||
getItemAsync: vi.fn(async () => null),
|
||||
setItemAsync: vi.fn(async () => undefined),
|
||||
deleteItemAsync: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("expo-web-browser", () => ({
|
||||
maybeCompleteAuthSession: vi.fn(),
|
||||
}));
|
||||
vi.mock("react-native", () => ({
|
||||
Platform: { OS: "web", Version: "test", constants: {} },
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../src/auth", () => ({
|
||||
getAccessToken: () => "test.jwt",
|
||||
refreshTokens: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("../../src/config", () => ({
|
||||
env: { apiBaseUrl: "https://example.test" },
|
||||
}));
|
||||
vi.mock("../../src/services", () => ({
|
||||
dialogApi: { messages: vi.fn(async () => ({ items: [], next_cursor: null })) },
|
||||
}));
|
||||
|
||||
import {
|
||||
NOTIFICATION_OUTAGE_MS,
|
||||
NOTIFICATION_POLL_INTERVAL_MS,
|
||||
NotificationRealtimeClient,
|
||||
} from "../../src/realtime";
|
||||
import {
|
||||
actionDialogId,
|
||||
actionUrl,
|
||||
formatNotificationPrice,
|
||||
notificationIcon,
|
||||
notificationPalette,
|
||||
typeMap,
|
||||
} from "../../src/notification-presenter";
|
||||
import type { NotificationType } from "../../src/types";
|
||||
|
||||
class MockWebSocket {
|
||||
static readonly OPEN = 1;
|
||||
static instances: MockWebSocket[] = [];
|
||||
readonly sent: string[] = [];
|
||||
readyState = MockWebSocket.OPEN;
|
||||
onopen?: () => void;
|
||||
onmessage?: (event: { data: string }) => void;
|
||||
onclose?: (event: { code: number }) => void;
|
||||
onerror?: () => void;
|
||||
|
||||
constructor(readonly url: string, readonly protocols: string[]) {
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(value: string) {
|
||||
this.sent.push(value);
|
||||
}
|
||||
|
||||
close() {}
|
||||
}
|
||||
|
||||
describe("notification catalog presentation", () => {
|
||||
it("использует neutral и bell для неизвестных значений", () => {
|
||||
expect(notificationPalette("future-token")).toEqual(notificationPalette("neutral"));
|
||||
expect(notificationIcon("future-icon")).toBe("bell");
|
||||
expect(notificationIcon(null)).toBe("bell");
|
||||
});
|
||||
|
||||
it("рендерит новый вид только по данным каталога", () => {
|
||||
const type: NotificationType = {
|
||||
code: "future_type",
|
||||
label: "Новый вид",
|
||||
color_token: "info",
|
||||
icon_code: "news",
|
||||
cta_text: "Открыть",
|
||||
cta_action: "open_detail",
|
||||
countable: true,
|
||||
contour: "P",
|
||||
button_primary: { code: "gotit", label: "Понятно" },
|
||||
button_secondary: null,
|
||||
};
|
||||
expect(typeMap([type]).get("future_type")).toEqual(type);
|
||||
expect(notificationPalette(type.color_token).accent).toBe("#2563eb");
|
||||
});
|
||||
|
||||
it("форматирует цену в рублях и безопасно игнорирует мусор", () => {
|
||||
expect(formatNotificationPrice("1500")).toContain("1 500");
|
||||
expect(formatNotificationPrice("not-a-number")).toBeNull();
|
||||
});
|
||||
|
||||
it("читает результат CTA из backend state response", () => {
|
||||
const base = {
|
||||
notification_id: "notification-1",
|
||||
lifecycle_status: "active" as const,
|
||||
visibility: "visible" as const,
|
||||
is_read: true,
|
||||
close_reason: null,
|
||||
date_expired: null,
|
||||
unread_count: 0,
|
||||
};
|
||||
expect(actionUrl({ ...base, result: { action: "open_url", url: "https://pay.test" } }))
|
||||
.toBe("https://pay.test");
|
||||
expect(actionDialogId({
|
||||
...base,
|
||||
result: {
|
||||
action: "chat_message_sent",
|
||||
message: {
|
||||
message_id: "message-1",
|
||||
dialog_id: "dialog-1",
|
||||
sender_type: "client",
|
||||
content_kind: "text",
|
||||
text: "Тест",
|
||||
attachments: [],
|
||||
safety_status: "allowed",
|
||||
delivery_status: "delivered",
|
||||
created_at: "2026-07-27T12:00:00Z",
|
||||
},
|
||||
},
|
||||
})).toBe("dialog-1");
|
||||
expect(actionUrl({ ...base, result: null })).toBeUndefined();
|
||||
expect(actionDialogId({ ...base, result: null })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification realtime degradation", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
MockWebSocket.instances = [];
|
||||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("подписывается с notifications:true", () => {
|
||||
const client = new NotificationRealtimeClient(vi.fn(), vi.fn(async () => undefined), vi.fn());
|
||||
client.start();
|
||||
const socket = MockWebSocket.instances[0]!;
|
||||
socket.onopen?.();
|
||||
expect(JSON.parse(socket.sent[0]!)).toEqual({
|
||||
type: "subscribe",
|
||||
dialog_ids: [],
|
||||
notifications: true,
|
||||
});
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("после 30 секунд включает polling с интервалом 60 секунд", async () => {
|
||||
const reconcile = vi.fn(async () => undefined);
|
||||
const state = vi.fn();
|
||||
const client = new NotificationRealtimeClient(vi.fn(), reconcile, state);
|
||||
client.start();
|
||||
MockWebSocket.instances[0]!.onclose?.({ code: 1006 });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NOTIFICATION_OUTAGE_MS);
|
||||
expect(state).toHaveBeenCalledWith("polling");
|
||||
expect(reconcile).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NOTIFICATION_POLL_INTERVAL_MS);
|
||||
expect(reconcile).toHaveBeenCalledTimes(2);
|
||||
client.stop();
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,7 @@
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] },
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["app", "src", "tests", "app.config.ts", "expo-env.d.ts"]
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: { __DEV__: false },
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["tests/unit/**/*.test.{ts,tsx}"],
|
||||
|
||||
Reference in New Issue
Block a user