145 lines
5.2 KiB
TypeScript
145 lines
5.2 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
import { FlatList, StyleSheet, useWindowDimensions, View } from "react-native";
|
|
import { notificationApi, notificationKeys } from "../notification-api";
|
|
import { useNotificationAction } from "../notification-actions";
|
|
import { typeMap } from "../notification-presenter";
|
|
import { layout, spacing } from "../theme";
|
|
import type { NotificationItem } from "../types";
|
|
import { ErrorNotice, Loading } 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 [hiddenGuestIds, setHiddenGuestIds] = useState<Set<string>>(() => new Set());
|
|
const window = useWindowDimensions();
|
|
const cardWidth = Math.max(0, Math.min(window.width, layout.maxWidth) - spacing.lg * 2);
|
|
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 ?? []).filter((item) => authenticated || !hiddenGuestIds.has(item.id));
|
|
|
|
const goTo = (index: number) => {
|
|
if (data.length < 2) return;
|
|
const next = (index + data.length) % data.length;
|
|
setActiveIndex(next);
|
|
list.current?.scrollToIndex({ index: next, animated: true });
|
|
};
|
|
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (activeIndex < data.length) return;
|
|
const next = Math.max(data.length - 1, 0);
|
|
setActiveIndex(next);
|
|
list.current?.scrollToIndex({ index: next, animated: false });
|
|
}, [activeIndex, 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}>
|
|
<FlatList
|
|
ref={list}
|
|
horizontal
|
|
data={data}
|
|
decelerationRate="fast"
|
|
pagingEnabled
|
|
snapToInterval={cardWidth}
|
|
snapToAlignment="start"
|
|
getItemLayout={(_, index) => ({ length: cardWidth, offset: cardWidth * index, index })}
|
|
keyExtractor={(item) => item.id}
|
|
style={local.carousel}
|
|
onMomentumScrollEnd={(event) => {
|
|
if (cardWidth > 0) {
|
|
setActiveIndex(Math.min(data.length - 1, Math.round(event.nativeEvent.contentOffset.x / cardWidth)));
|
|
}
|
|
}}
|
|
renderItem={({ item }) => (
|
|
<NotificationCard
|
|
disabled={hide.isPending}
|
|
item={item}
|
|
type={byCode.get(item.notification_type)}
|
|
onCta={() => void action(item, byCode.get(item.notification_type))}
|
|
onNext={() => goTo(activeIndex + 1)}
|
|
onPrevious={() => goTo(activeIndex - 1)}
|
|
position={activeIndex + 1}
|
|
total={data.length}
|
|
width={cardWidth}
|
|
onHide={() => {
|
|
if (authenticated) {
|
|
hide.mutate(item.id);
|
|
} else {
|
|
setHiddenGuestIds((current) => new Set(current).add(item.id));
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
showsHorizontalScrollIndicator={false}
|
|
/>
|
|
{Boolean(actionError) && <View style={local.error}><ErrorNotice error={actionError} /></View>}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const local = StyleSheet.create({
|
|
section: { paddingVertical: spacing.md },
|
|
carousel: { marginHorizontal: spacing.lg },
|
|
state: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
|
|
error: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
|
|
});
|