refactor(guest): retire legacy guest app and move shared modules
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-06 08:42:53 +01:00
parent b14435df8b
commit 0a08f2704f
191 changed files with 243 additions and 12631 deletions

View 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);
}

View 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);
}