refactor(guest): retire legacy guest app and move shared modules
This commit is contained in:
349
resources/js/shared/guest/context/EventBrandingContext.tsx
Normal file
349
resources/js/shared/guest/context/EventBrandingContext.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo } from 'react';
|
||||
import type { EventBranding } from '../types/event-branding';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { getContrastingTextColor, relativeLuminance } from '../lib/color';
|
||||
|
||||
type EventBrandingContextValue = {
|
||||
branding: EventBranding;
|
||||
isCustom: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_EVENT_BRANDING: EventBranding = {
|
||||
primaryColor: '#E94B5A',
|
||||
secondaryColor: '#F7C7CF',
|
||||
backgroundColor: '#FFF6F2',
|
||||
fontFamily: 'Montserrat, Inter, "Helvetica Neue", system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
logoUrl: null,
|
||||
welcomeMessage: null,
|
||||
palette: {
|
||||
primary: '#E94B5A',
|
||||
secondary: '#F7C7CF',
|
||||
background: '#FFF6F2',
|
||||
surface: '#FFFFFF',
|
||||
},
|
||||
typography: {
|
||||
heading: 'Playfair Display, "Times New Roman", serif',
|
||||
body: 'Montserrat, Inter, "Helvetica Neue", system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
sizePreset: 'm',
|
||||
},
|
||||
logo: {
|
||||
mode: 'emoticon',
|
||||
value: null,
|
||||
position: 'left',
|
||||
size: 'm',
|
||||
},
|
||||
buttons: {
|
||||
style: 'filled',
|
||||
radius: 12,
|
||||
},
|
||||
mode: 'auto',
|
||||
};
|
||||
const DEFAULT_PRIMARY = DEFAULT_EVENT_BRANDING.primaryColor.toLowerCase();
|
||||
const DEFAULT_SECONDARY = DEFAULT_EVENT_BRANDING.secondaryColor.toLowerCase();
|
||||
const DEFAULT_BACKGROUND = DEFAULT_EVENT_BRANDING.backgroundColor.toLowerCase();
|
||||
const LIGHT_LUMINANCE_THRESHOLD = 0.65;
|
||||
const DARK_LUMINANCE_THRESHOLD = 0.35;
|
||||
const DARK_FALLBACK_SURFACE = '#0f172a';
|
||||
const LIGHT_FALLBACK_SURFACE = '#ffffff';
|
||||
const FONT_SCALE_MAP: Record<'s' | 'm' | 'l', number> = {
|
||||
s: 0.94,
|
||||
m: 1,
|
||||
l: 1.08,
|
||||
};
|
||||
|
||||
const EventBrandingContext = createContext<EventBrandingContextValue | undefined>(undefined);
|
||||
|
||||
function normaliseHexColor(value: string | null | undefined, fallback: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(trimmed) ? trimmed : fallback;
|
||||
}
|
||||
|
||||
function resolveBranding(input?: EventBranding | null): EventBranding {
|
||||
if (!input) {
|
||||
return DEFAULT_EVENT_BRANDING;
|
||||
}
|
||||
|
||||
const palettePrimary = input.palette?.primary ?? input.primaryColor;
|
||||
const paletteSecondary = input.palette?.secondary ?? input.secondaryColor;
|
||||
const paletteBackground = input.palette?.background ?? input.backgroundColor;
|
||||
const paletteSurface = input.palette?.surface ?? input.backgroundColor;
|
||||
|
||||
const headingFont = input.typography?.heading ?? input.fontFamily ?? null;
|
||||
const bodyFont = input.typography?.body ?? input.fontFamily ?? null;
|
||||
const rawSize = input.typography?.sizePreset ?? 'm';
|
||||
const sizePreset = rawSize === 's' || rawSize === 'm' || rawSize === 'l' ? rawSize : 'm';
|
||||
|
||||
const logoMode = input.logo?.mode ?? (input.logoUrl ? 'upload' : 'emoticon');
|
||||
const logoValue = input.logo?.value ?? input.logoUrl ?? null;
|
||||
|
||||
return {
|
||||
primaryColor: normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
|
||||
secondaryColor: normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
|
||||
backgroundColor: normaliseHexColor(paletteBackground, DEFAULT_EVENT_BRANDING.backgroundColor),
|
||||
fontFamily: bodyFont?.trim() || DEFAULT_EVENT_BRANDING.fontFamily,
|
||||
logoUrl: logoMode === 'upload' ? (logoValue?.trim() || null) : null,
|
||||
palette: {
|
||||
primary: normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
|
||||
secondary: normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
|
||||
background: normaliseHexColor(paletteBackground, DEFAULT_EVENT_BRANDING.backgroundColor),
|
||||
surface: normaliseHexColor(paletteSurface, paletteBackground ?? DEFAULT_EVENT_BRANDING.backgroundColor),
|
||||
},
|
||||
typography: {
|
||||
heading: headingFont?.trim() || DEFAULT_EVENT_BRANDING.typography?.heading || null,
|
||||
body: bodyFont?.trim() || DEFAULT_EVENT_BRANDING.typography?.body || DEFAULT_EVENT_BRANDING.fontFamily,
|
||||
sizePreset,
|
||||
},
|
||||
logo: {
|
||||
mode: logoMode,
|
||||
value: logoMode === 'upload' ? (logoValue?.trim() || null) : (logoValue ?? null),
|
||||
position: input.logo?.position ?? 'left',
|
||||
size: input.logo?.size ?? 'm',
|
||||
},
|
||||
buttons: {
|
||||
style: input.buttons?.style ?? 'filled',
|
||||
radius: typeof input.buttons?.radius === 'number' ? input.buttons.radius : 12,
|
||||
primary: input.buttons?.primary ?? normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
|
||||
secondary: input.buttons?.secondary ?? normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
|
||||
linkColor: input.buttons?.linkColor ?? normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
|
||||
},
|
||||
mode: input.mode ?? 'auto',
|
||||
useDefaultBranding: input.useDefaultBranding ?? undefined,
|
||||
welcomeMessage: input.welcomeMessage ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
type ThemeVariant = 'light' | 'dark';
|
||||
|
||||
function resolveThemeVariant(
|
||||
mode: EventBranding['mode'],
|
||||
backgroundColor: string,
|
||||
appearanceOverride: 'light' | 'dark' | null,
|
||||
): ThemeVariant {
|
||||
const prefersDark = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: false;
|
||||
const backgroundLuminance = relativeLuminance(backgroundColor || DEFAULT_EVENT_BRANDING.backgroundColor);
|
||||
const backgroundPrefers = backgroundLuminance >= LIGHT_LUMINANCE_THRESHOLD
|
||||
? 'light'
|
||||
: backgroundLuminance <= DARK_LUMINANCE_THRESHOLD
|
||||
? 'dark'
|
||||
: null;
|
||||
|
||||
if (appearanceOverride) {
|
||||
return appearanceOverride;
|
||||
}
|
||||
|
||||
if (mode === 'dark') {
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
if (mode === 'light') {
|
||||
return 'light';
|
||||
}
|
||||
|
||||
if (backgroundPrefers) {
|
||||
return backgroundPrefers;
|
||||
}
|
||||
|
||||
return prefersDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function clampToTheme(color: string, theme: ThemeVariant): string {
|
||||
const luminance = relativeLuminance(color);
|
||||
if (theme === 'dark' && luminance >= LIGHT_LUMINANCE_THRESHOLD) {
|
||||
return DARK_FALLBACK_SURFACE;
|
||||
}
|
||||
if (theme === 'light' && luminance <= DARK_LUMINANCE_THRESHOLD) {
|
||||
return LIGHT_FALLBACK_SURFACE;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
function applyCssVariables(branding: EventBranding, theme: ThemeVariant) {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
const background = clampToTheme(branding.backgroundColor, theme);
|
||||
const surfaceCandidate = clampToTheme(branding.palette?.surface ?? background, theme);
|
||||
const backgroundLuminance = relativeLuminance(background);
|
||||
const surfaceLuminance = relativeLuminance(surfaceCandidate);
|
||||
const surface = Math.abs(surfaceLuminance - backgroundLuminance) < 0.06
|
||||
? theme === 'light'
|
||||
? LIGHT_FALLBACK_SURFACE
|
||||
: DARK_FALLBACK_SURFACE
|
||||
: surfaceCandidate;
|
||||
const isLight = theme === 'light';
|
||||
const foreground = isLight ? '#1f2937' : '#f8fafc';
|
||||
const mutedForeground = isLight ? '#6b7280' : '#cbd5e1';
|
||||
const muted = isLight ? '#f6efec' : '#1f2937';
|
||||
const border = isLight ? '#e6d9d6' : '#334155';
|
||||
const input = isLight ? '#eadfda' : '#273247';
|
||||
const primaryForeground = getContrastingTextColor(branding.primaryColor, '#ffffff', '#0f172a');
|
||||
const secondaryForeground = getContrastingTextColor(branding.secondaryColor, '#ffffff', '#0f172a');
|
||||
root.style.setProperty('--guest-primary', branding.primaryColor);
|
||||
root.style.setProperty('--guest-secondary', branding.secondaryColor);
|
||||
root.style.setProperty('--guest-background', background);
|
||||
root.style.setProperty('--guest-surface', surface);
|
||||
root.style.setProperty('--guest-button-radius', `${branding.buttons?.radius ?? 12}px`);
|
||||
root.style.setProperty('--guest-radius', `${branding.buttons?.radius ?? 12}px`);
|
||||
root.style.setProperty('--guest-link', branding.buttons?.linkColor ?? branding.secondaryColor);
|
||||
root.style.setProperty('--guest-button-style', branding.buttons?.style ?? 'filled');
|
||||
root.style.setProperty('--guest-font-scale', String(FONT_SCALE_MAP[branding.typography?.sizePreset ?? 'm'] ?? 1));
|
||||
root.style.setProperty('--foreground', foreground);
|
||||
root.style.setProperty('--card-foreground', foreground);
|
||||
root.style.setProperty('--popover-foreground', foreground);
|
||||
root.style.setProperty('--muted', muted);
|
||||
root.style.setProperty('--muted-foreground', mutedForeground);
|
||||
root.style.setProperty('--border', border);
|
||||
root.style.setProperty('--input', input);
|
||||
root.style.setProperty('--primary', branding.primaryColor);
|
||||
root.style.setProperty('--primary-foreground', primaryForeground);
|
||||
root.style.setProperty('--secondary', branding.secondaryColor);
|
||||
root.style.setProperty('--secondary-foreground', secondaryForeground);
|
||||
root.style.setProperty('--accent', branding.secondaryColor);
|
||||
root.style.setProperty('--accent-foreground', secondaryForeground);
|
||||
root.style.setProperty('--ring', branding.primaryColor);
|
||||
|
||||
const headingFont = branding.typography?.heading ?? branding.fontFamily;
|
||||
const bodyFont = branding.typography?.body ?? branding.fontFamily;
|
||||
|
||||
if (bodyFont) {
|
||||
root.style.setProperty('--guest-font-family', bodyFont);
|
||||
root.style.setProperty('--guest-body-font', bodyFont);
|
||||
} else {
|
||||
root.style.removeProperty('--guest-font-family');
|
||||
root.style.removeProperty('--guest-body-font');
|
||||
}
|
||||
|
||||
if (headingFont) {
|
||||
root.style.setProperty('--guest-heading-font', headingFont);
|
||||
} else {
|
||||
root.style.removeProperty('--guest-heading-font');
|
||||
}
|
||||
}
|
||||
|
||||
function resetCssVariables() {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
root.style.removeProperty('--guest-primary');
|
||||
root.style.removeProperty('--guest-secondary');
|
||||
root.style.removeProperty('--guest-background');
|
||||
root.style.removeProperty('--guest-surface');
|
||||
root.style.removeProperty('--guest-button-radius');
|
||||
root.style.removeProperty('--guest-radius');
|
||||
root.style.removeProperty('--guest-link');
|
||||
root.style.removeProperty('--guest-button-style');
|
||||
root.style.removeProperty('--guest-font-scale');
|
||||
root.style.removeProperty('--guest-font-family');
|
||||
root.style.removeProperty('--guest-body-font');
|
||||
root.style.removeProperty('--guest-heading-font');
|
||||
root.style.removeProperty('--foreground');
|
||||
root.style.removeProperty('--card-foreground');
|
||||
root.style.removeProperty('--popover-foreground');
|
||||
root.style.removeProperty('--muted');
|
||||
root.style.removeProperty('--muted-foreground');
|
||||
root.style.removeProperty('--border');
|
||||
root.style.removeProperty('--input');
|
||||
root.style.removeProperty('--primary');
|
||||
root.style.removeProperty('--primary-foreground');
|
||||
root.style.removeProperty('--secondary');
|
||||
root.style.removeProperty('--secondary-foreground');
|
||||
root.style.removeProperty('--accent');
|
||||
root.style.removeProperty('--accent-foreground');
|
||||
root.style.removeProperty('--ring');
|
||||
}
|
||||
|
||||
function applyThemeMode(
|
||||
mode: EventBranding['mode'],
|
||||
backgroundColor: string,
|
||||
appearanceOverride: 'light' | 'dark' | null,
|
||||
): ThemeVariant {
|
||||
if (typeof document === 'undefined') {
|
||||
return 'light';
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
const theme = resolveThemeVariant(mode, backgroundColor, appearanceOverride);
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
root.style.colorScheme = 'dark';
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
root.classList.remove('dark');
|
||||
root.style.colorScheme = 'light';
|
||||
return 'light';
|
||||
}
|
||||
|
||||
export function EventBrandingProvider({
|
||||
branding,
|
||||
children,
|
||||
}: {
|
||||
branding?: EventBranding | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const resolved = useMemo(() => resolveBranding(branding), [branding]);
|
||||
const { appearance } = useAppearance();
|
||||
const appearanceOverride = appearance === 'light' || appearance === 'dark' ? appearance : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.add('guest-theme');
|
||||
}
|
||||
const previousDark = typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false;
|
||||
const theme = applyThemeMode(resolved.mode ?? 'auto', resolved.backgroundColor, appearanceOverride);
|
||||
applyCssVariables(resolved, theme);
|
||||
|
||||
return () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
if (previousDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
document.documentElement.classList.remove('guest-theme');
|
||||
}
|
||||
resetCssVariables();
|
||||
const fallbackTheme = applyThemeMode(
|
||||
DEFAULT_EVENT_BRANDING.mode ?? 'auto',
|
||||
DEFAULT_EVENT_BRANDING.backgroundColor,
|
||||
appearanceOverride,
|
||||
);
|
||||
applyCssVariables(DEFAULT_EVENT_BRANDING, fallbackTheme);
|
||||
};
|
||||
}, [appearanceOverride, resolved]);
|
||||
|
||||
const value = useMemo<EventBrandingContextValue>(() => ({
|
||||
branding: resolved,
|
||||
isCustom:
|
||||
resolved.primaryColor.toLowerCase() !== DEFAULT_PRIMARY
|
||||
|| resolved.secondaryColor.toLowerCase() !== DEFAULT_SECONDARY
|
||||
|| resolved.backgroundColor.toLowerCase() !== DEFAULT_BACKGROUND,
|
||||
// legacy surface check omitted by intent
|
||||
}), [resolved]);
|
||||
|
||||
return <EventBrandingContext.Provider value={value}>{children}</EventBrandingContext.Provider>;
|
||||
}
|
||||
|
||||
export function useEventBranding(): EventBrandingContextValue {
|
||||
const context = useContext(EventBrandingContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useEventBranding must be used within an EventBrandingProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useOptionalEventBranding(): EventBrandingContextValue | undefined {
|
||||
return useContext(EventBrandingContext);
|
||||
}
|
||||
304
resources/js/shared/guest/context/NotificationCenterContext.tsx
Normal file
304
resources/js/shared/guest/context/NotificationCenterContext.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import React from 'react';
|
||||
import { useUploadQueue } from '../queue/hooks';
|
||||
import type { QueueItem } from '../queue/queue';
|
||||
import {
|
||||
dismissGuestNotification,
|
||||
fetchGuestNotifications,
|
||||
markGuestNotificationRead,
|
||||
type GuestNotificationItem,
|
||||
} from '../services/notificationApi';
|
||||
import { fetchPendingUploadsSummary } from '../services/pendingUploadsApi';
|
||||
import { updateAppBadge } from '../lib/badges';
|
||||
|
||||
export type NotificationCenterValue = {
|
||||
notifications: GuestNotificationItem[];
|
||||
unreadCount: number;
|
||||
queueItems: QueueItem[];
|
||||
queueCount: number;
|
||||
pendingCount: number;
|
||||
loading: boolean;
|
||||
pendingLoading: 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;
|
||||
lastFetchedAt: Date | null;
|
||||
isOffline: boolean;
|
||||
};
|
||||
|
||||
const NotificationCenterContext = React.createContext<NotificationCenterValue | null>(null);
|
||||
|
||||
export function NotificationCenterProvider({ eventToken, children }: { eventToken: string; children: React.ReactNode }) {
|
||||
const { items, loading: queueLoading, refresh: refreshQueue } = useUploadQueue();
|
||||
const [notifications, setNotifications] = React.useState<GuestNotificationItem[]>([]);
|
||||
const [unreadCount, setUnreadCount] = React.useState(0);
|
||||
const [loadingNotifications, setLoadingNotifications] = React.useState(true);
|
||||
const [pendingCount, setPendingCount] = React.useState(0);
|
||||
const [pendingLoading, setPendingLoading] = 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);
|
||||
const [isOffline, setIsOffline] = React.useState<boolean>(typeof navigator !== 'undefined' ? !navigator.onLine : false);
|
||||
|
||||
const queueCount = React.useMemo(
|
||||
() => items.filter((item) => item.status !== 'done').length,
|
||||
[items]
|
||||
);
|
||||
|
||||
const loadNotifications = React.useCallback(
|
||||
async (options: { silent?: boolean } = {}) => {
|
||||
if (!eventToken) {
|
||||
if (!options.silent) {
|
||||
setLoadingNotifications(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetchLockRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchLockRef.current = true;
|
||||
if (!options.silent) {
|
||||
setLoadingNotifications(true);
|
||||
}
|
||||
|
||||
try {
|
||||
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);
|
||||
setLastFetchedAt(new Date());
|
||||
}
|
||||
etagRef.current = result.etag;
|
||||
setIsOffline(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load guest notifications', error);
|
||||
if (!options.silent) {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||
setIsOffline(true);
|
||||
}
|
||||
} finally {
|
||||
fetchLockRef.current = false;
|
||||
if (!options.silent) {
|
||||
setLoadingNotifications(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[eventToken]
|
||||
);
|
||||
|
||||
const loadPendingUploads = React.useCallback(async () => {
|
||||
if (!eventToken) {
|
||||
setPendingLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPendingLoading(true);
|
||||
const result = await fetchPendingUploadsSummary(eventToken, 1);
|
||||
setPendingCount(result.totalCount);
|
||||
} catch (error) {
|
||||
console.error('Failed to load pending uploads', error);
|
||||
setPendingCount(0);
|
||||
} finally {
|
||||
setPendingLoading(false);
|
||||
}
|
||||
}, [eventToken]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
etagRef.current = null;
|
||||
setPendingCount(0);
|
||||
|
||||
if (!eventToken) {
|
||||
setLoadingNotifications(false);
|
||||
setPendingLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingNotifications(true);
|
||||
void loadNotifications();
|
||||
void loadPendingUploads();
|
||||
}, [eventToken, loadNotifications, loadPendingUploads]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!eventToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void loadNotifications({ silent: true });
|
||||
void loadPendingUploads();
|
||||
}, 90000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [eventToken, loadNotifications, loadPendingUploads]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'guest-notification-refresh') {
|
||||
void loadNotifications({ silent: true });
|
||||
}
|
||||
};
|
||||
|
||||
navigator.serviceWorker?.addEventListener('message', handler);
|
||||
|
||||
return () => {
|
||||
navigator.serviceWorker?.removeEventListener('message', handler);
|
||||
};
|
||||
}, [loadNotifications]);
|
||||
|
||||
const markAsRead = React.useCallback(
|
||||
async (id: number) => {
|
||||
if (!eventToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
let decremented = false;
|
||||
setNotifications((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id !== id) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (item.status === 'new') {
|
||||
decremented = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
status: 'read',
|
||||
readAt: new Date().toISOString(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (decremented) {
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
}
|
||||
|
||||
try {
|
||||
await markGuestNotificationRead(eventToken, id);
|
||||
} catch (error) {
|
||||
console.error('Failed to mark notification as read', error);
|
||||
void loadNotifications({ silent: true });
|
||||
}
|
||||
},
|
||||
[eventToken, loadNotifications]
|
||||
);
|
||||
|
||||
const dismiss = React.useCallback(
|
||||
async (id: number) => {
|
||||
if (!eventToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
let decremented = false;
|
||||
setNotifications((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id !== id) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (item.status === 'new') {
|
||||
decremented = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
status: 'dismissed',
|
||||
dismissedAt: new Date().toISOString(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (decremented) {
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
}
|
||||
|
||||
try {
|
||||
await dismissGuestNotification(eventToken, id);
|
||||
} catch (error) {
|
||||
console.error('Failed to dismiss notification', error);
|
||||
void loadNotifications({ silent: true });
|
||||
}
|
||||
},
|
||||
[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(), loadPendingUploads()]);
|
||||
}, [loadNotifications, refreshQueue, loadPendingUploads]);
|
||||
|
||||
const loading = loadingNotifications || queueLoading || pendingLoading;
|
||||
React.useEffect(() => {
|
||||
void updateAppBadge(unreadCount);
|
||||
}, [unreadCount]);
|
||||
|
||||
const value: NotificationCenterValue = {
|
||||
notifications,
|
||||
unreadCount,
|
||||
queueItems: items,
|
||||
queueCount,
|
||||
pendingCount,
|
||||
loading,
|
||||
pendingLoading,
|
||||
refresh,
|
||||
setFilters,
|
||||
markAsRead,
|
||||
dismiss,
|
||||
eventToken,
|
||||
lastFetchedAt,
|
||||
isOffline,
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationCenterContext.Provider value={value}>
|
||||
{children}
|
||||
</NotificationCenterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNotificationCenter(): NotificationCenterValue {
|
||||
const ctx = React.useContext(NotificationCenterContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useNotificationCenter must be used within NotificationCenterProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useOptionalNotificationCenter(): NotificationCenterValue | null {
|
||||
return React.useContext(NotificationCenterContext);
|
||||
}
|
||||
Reference in New Issue
Block a user