Добавлены уведомления
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>
|
||||
|
||||
Reference in New Issue
Block a user