fixed notification system and added a new tenant notifications receipt table to track read status and filter messages by scope.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
import {
|
||||
@@ -147,6 +148,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
const { event, status } = useEventData();
|
||||
const notificationCenter = useOptionalNotificationCenter();
|
||||
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
|
||||
const [statusFilter, setStatusFilter] = React.useState<'new' | 'read' | 'dismissed' | 'all'>('new');
|
||||
const taskProgress = useGuestTaskProgress(eventToken);
|
||||
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const checklistItems = React.useMemo(
|
||||
@@ -277,11 +279,13 @@ type NotificationButtonProps = {
|
||||
type PushState = ReturnType<typeof usePushSubscription>;
|
||||
|
||||
function NotificationButton({ center, eventToken, open, onToggle, panelRef, checklistItems, taskProgress, t }: NotificationButtonProps) {
|
||||
const badgeCount = center.totalCount;
|
||||
const badgeCount = center.unreadCount;
|
||||
const progressRatio = taskProgress
|
||||
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
|
||||
: 0;
|
||||
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all');
|
||||
const [statusFilter, setStatusFilter] = React.useState<'new' | 'read' | 'dismissed' | 'all'>('new');
|
||||
const [scopeFilter, setScopeFilter] = React.useState<'all' | 'uploads' | 'tips' | 'general'>('all');
|
||||
const pushState = usePushSubscription(eventToken);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -300,18 +304,39 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
|
||||
);
|
||||
|
||||
const filteredNotifications = React.useMemo(() => {
|
||||
let base: typeof center.notifications = [];
|
||||
switch (activeTab) {
|
||||
case 'unread':
|
||||
return unreadNotifications;
|
||||
base = unreadNotifications;
|
||||
break;
|
||||
case 'status':
|
||||
return uploadNotifications;
|
||||
base = uploadNotifications;
|
||||
break;
|
||||
default:
|
||||
return center.notifications;
|
||||
base = center.notifications;
|
||||
}
|
||||
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
|
||||
|
||||
if (statusFilter === 'all') return base;
|
||||
return base.filter((item) => item.status === (statusFilter === 'new' ? 'new' : statusFilter));
|
||||
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications, statusFilter]);
|
||||
|
||||
const scopedNotifications = React.useMemo(() => {
|
||||
if (scopeFilter === 'all') {
|
||||
return filteredNotifications;
|
||||
}
|
||||
return filteredNotifications.filter((item) => {
|
||||
if (scopeFilter === 'uploads') {
|
||||
return item.type === 'upload_alert' || item.type === 'photo_activity';
|
||||
}
|
||||
if (scopeFilter === 'tips') {
|
||||
return item.type === 'support_tip' || item.type === 'achievement_major';
|
||||
}
|
||||
return item.type === 'broadcast' || item.type === 'feedback_request';
|
||||
});
|
||||
}, [filteredNotifications, scopeFilter]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative z-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
@@ -320,15 +345,15 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
|
||||
>
|
||||
<Bell className="h-5 w-5" aria-hidden />
|
||||
{badgeCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 rounded-full bg-white px-1.5 text-[10px] font-semibold text-pink-600">
|
||||
<span className="absolute -right-1 -top-1 min-h-[18px] min-w-[18px] rounded-full bg-pink-500 px-1.5 text-[11px] font-semibold leading-[18px] text-white shadow-lg">
|
||||
{badgeCount > 9 ? '9+' : badgeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute right-0 mt-2 w-80 rounded-2xl border border-white/30 bg-white/95 p-4 text-slate-900 shadow-2xl"
|
||||
className="fixed right-4 top-16 z-[2147483000] w-80 rounded-2xl border border-white/30 bg-white/95 p-4 text-slate-900 shadow-2xl"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
@@ -358,6 +383,40 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
|
||||
activeTab={activeTab}
|
||||
onTabChange={(next) => setActiveTab(next as typeof activeTab)}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<label className="sr-only" htmlFor="notification-scope">
|
||||
{t('header.notifications.scopeLabel', 'Bereich filtern')}
|
||||
</label>
|
||||
<select
|
||||
id="notification-scope"
|
||||
value={scopeFilter}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value as typeof scopeFilter;
|
||||
setScopeFilter(next);
|
||||
notificationCenter?.setFilters({ scope: next, status: statusFilter });
|
||||
}}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm"
|
||||
>
|
||||
<option value="all">{t('header.notifications.scope.all', 'Alle')}</option>
|
||||
<option value="uploads">{t('header.notifications.scope.uploads', 'Uploads/Status')}</option>
|
||||
<option value="tips">{t('header.notifications.scope.tips', 'Tipps & Achievements')}</option>
|
||||
<option value="general">{t('header.notifications.scope.general', 'Allgemein')}</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value as typeof statusFilter;
|
||||
setStatusFilter(value);
|
||||
notificationCenter?.setFilters({ status: value, scope: scopeFilter });
|
||||
}}
|
||||
className="mt-2 w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm"
|
||||
>
|
||||
<option value="new">{t('header.notifications.filter.unread', 'Neu')}</option>
|
||||
<option value="read">{t('header.notifications.filter.read', 'Gelesen')}</option>
|
||||
<option value="dismissed">{t('header.notifications.filter.dismissed', 'Ausgeblendet')}</option>
|
||||
<option value="all">{t('header.notifications.filter.all', 'Alle')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<NotificationStatusBar
|
||||
lastFetchedAt={center.lastFetchedAt}
|
||||
isOffline={center.isOffline}
|
||||
@@ -367,7 +426,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
|
||||
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
{center.loading ? (
|
||||
<NotificationSkeleton />
|
||||
) : filteredNotifications.length === 0 ? (
|
||||
) : scopedNotifications.length === 0 ? (
|
||||
<NotificationEmptyState
|
||||
t={t}
|
||||
message={
|
||||
@@ -379,7 +438,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
filteredNotifications.map((item) => (
|
||||
scopedNotifications.map((item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
@@ -444,7 +503,8 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, chec
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>,
|
||||
typeof document !== 'undefined' ? document.body : undefined
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ export type NotificationCenterValue = {
|
||||
totalCount: number;
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
setFilters: (filters: { status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }) => void;
|
||||
markAsRead: (id: number) => Promise<void>;
|
||||
dismiss: (id: number) => Promise<void>;
|
||||
eventToken: string;
|
||||
@@ -30,6 +31,10 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
||||
const [notifications, setNotifications] = React.useState<GuestNotificationItem[]>([]);
|
||||
const [unreadCount, setUnreadCount] = React.useState(0);
|
||||
const [loadingNotifications, setLoadingNotifications] = React.useState(true);
|
||||
const [filters, setFiltersState] = React.useState<{ status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }>({
|
||||
status: 'new',
|
||||
scope: 'all',
|
||||
});
|
||||
const etagRef = React.useRef<string | null>(null);
|
||||
const fetchLockRef = React.useRef(false);
|
||||
const [lastFetchedAt, setLastFetchedAt] = React.useState<Date | null>(null);
|
||||
@@ -59,7 +64,11 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchGuestNotifications(eventToken, etagRef.current);
|
||||
const statusFilter = filters.status && filters.status !== 'all' ? (filters.status === 'new' ? 'unread' : filters.status) : undefined;
|
||||
const result = await fetchGuestNotifications(eventToken, etagRef.current, {
|
||||
status: statusFilter as any,
|
||||
scope: filters.scope,
|
||||
});
|
||||
if (!result.notModified) {
|
||||
setNotifications(result.notifications);
|
||||
setUnreadCount(result.unreadCount);
|
||||
@@ -217,6 +226,11 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
||||
[eventToken, loadNotifications]
|
||||
);
|
||||
|
||||
const setFilters = React.useCallback((next: { status?: 'new' | 'read' | 'dismissed' | 'all'; scope?: 'all' | 'uploads' | 'tips' | 'general' }) => {
|
||||
setFiltersState((prev) => ({ ...prev, ...next }));
|
||||
void loadNotifications({ silent: true });
|
||||
}, [loadNotifications]);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
await Promise.all([loadNotifications(), refreshQueue()]);
|
||||
}, [loadNotifications, refreshQueue]);
|
||||
@@ -232,6 +246,7 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
|
||||
totalCount,
|
||||
loading,
|
||||
refresh,
|
||||
setFilters,
|
||||
markAsRead,
|
||||
dismiss,
|
||||
eventToken,
|
||||
|
||||
@@ -152,7 +152,7 @@ export default function UploadPage() {
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
|
||||
const [immersiveMode, setImmersiveMode] = useState(true);
|
||||
const [immersiveMode, setImmersiveMode] = useState(false);
|
||||
const [showCelebration, setShowCelebration] = useState(false);
|
||||
const [showHeroOverlay, setShowHeroOverlay] = useState(true);
|
||||
|
||||
|
||||
@@ -74,8 +74,16 @@ function mapNotification(payload: GuestNotificationRow): GuestNotificationItem {
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGuestNotifications(eventToken: string, etag?: string | null): Promise<GuestNotificationFetchResult> {
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/notifications`, {
|
||||
export async function fetchGuestNotifications(
|
||||
eventToken: string,
|
||||
etag?: string | null,
|
||||
options?: { status?: 'unread' | 'read' | 'dismissed'; scope?: 'all' | 'uploads' | 'tips' | 'general' }
|
||||
): Promise<GuestNotificationFetchResult> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.status) params.set('status', options.status);
|
||||
if (options?.scope && options.scope !== 'all') params.set('scope', options.scope);
|
||||
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/notifications${params.toString() ? `?${params.toString()}` : ''}`, {
|
||||
method: 'GET',
|
||||
headers: buildHeaders(etag),
|
||||
credentials: 'include',
|
||||
|
||||
Reference in New Issue
Block a user