Добавлены уведомления

This commit is contained in:
mi
2026-07-27 17:36:53 +03:00
parent a072005164
commit 958fba5f3e
149 changed files with 6371 additions and 110 deletions
@@ -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 },
});