mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
293506217e
Server-side notice registry with per-user condition evaluation (firstLogin, existingUserBeforeVersion, addonEnabled, dateWindow, role, custom). Notices are sorted by priority then severity, filtered against dismissals stored in a new user_notice_dismissals table, and served via GET /api/system-notices/active + POST /api/system-notices/:id/dismiss. Client renders notices through a host component that partitions by display type (modal / banner / toast). The modal renderer supports multi-page pagination with directional slide transitions, keyboard navigation, and correct dismiss-all semantics on CTA / X / ESC. Dismissals are optimistic with a single background retry. Includes 3.0.0 upgrade notices (v3-photos, v3-journey, v3-features), onboarding welcome modal, and full i18n coverage across 15 languages. The /journey route is addon-gated on both client and server. Also includes: unit + integration test suites, registry integrity test that validates action CTA IDs against client source, and technical documentation in docs/system-notices.md.
269 lines
8.2 KiB
TypeScript
269 lines
8.2 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Info, AlertTriangle, AlertOctagon, X } from 'lucide-react';
|
|
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
|
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
|
|
import { useTranslation } from '../../i18n/index.js';
|
|
import { isRtlLanguage } from '../../i18n/index.js';
|
|
import { runNoticeAction } from './noticeActions.js';
|
|
|
|
const SEVERITY_ICONS: Record<string, React.ElementType> = {
|
|
info: Info,
|
|
warn: AlertTriangle,
|
|
critical: AlertOctagon,
|
|
};
|
|
|
|
const SEVERITY = {
|
|
info: {
|
|
bg: 'bg-white dark:bg-slate-900',
|
|
border: 'border-blue-500 dark:border-blue-400',
|
|
text: 'text-slate-900 dark:text-slate-100',
|
|
icon: 'text-blue-500 dark:text-blue-400',
|
|
ariaLive: 'polite' as const,
|
|
role: 'status' as const,
|
|
},
|
|
warn: {
|
|
bg: 'bg-amber-50 dark:bg-amber-950',
|
|
border: 'border-amber-500 dark:border-amber-400',
|
|
text: 'text-amber-900 dark:text-amber-100',
|
|
icon: 'text-amber-500 dark:text-amber-400',
|
|
ariaLive: 'polite' as const,
|
|
role: 'status' as const,
|
|
},
|
|
critical: {
|
|
bg: 'bg-rose-50 dark:bg-rose-950',
|
|
border: 'border-rose-600 dark:border-rose-400',
|
|
text: 'text-rose-900 dark:text-rose-100',
|
|
icon: 'text-rose-600 dark:text-rose-400',
|
|
ariaLive: 'assertive' as const,
|
|
role: 'alert' as const,
|
|
},
|
|
} as const;
|
|
|
|
interface BannerItemProps {
|
|
notice: SystemNoticeDTO;
|
|
onDismiss: () => void;
|
|
language: string;
|
|
}
|
|
|
|
function CTALink({
|
|
notice,
|
|
label,
|
|
onDismiss,
|
|
}: {
|
|
notice: SystemNoticeDTO;
|
|
label: string;
|
|
onDismiss: () => void;
|
|
}) {
|
|
const navigate = useNavigate();
|
|
|
|
function handleClick() {
|
|
if (!notice.cta) return;
|
|
if (notice.cta.kind === 'nav') {
|
|
navigate(notice.cta.href);
|
|
if (notice.dismissible) onDismiss();
|
|
} else {
|
|
runNoticeAction(notice.cta.actionId, { navigate });
|
|
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
|
|
if (actionCta.dismissOnAction !== false) onDismiss();
|
|
}
|
|
}
|
|
|
|
if (!notice.cta) return null;
|
|
|
|
if (notice.cta.kind === 'nav') {
|
|
return (
|
|
<a
|
|
href={notice.cta.href}
|
|
onClick={e => { e.preventDefault(); handleClick(); }}
|
|
className="underline hover:no-underline font-medium ml-3 shrink-0"
|
|
>
|
|
{label}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
onClick={handleClick}
|
|
className="underline hover:no-underline font-medium ml-3 shrink-0"
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function BannerItem({ notice, onDismiss, language }: BannerItemProps) {
|
|
const { t } = useTranslation();
|
|
const s = SEVERITY[notice.severity] ?? SEVERITY.info;
|
|
const title = t(notice.titleKey);
|
|
const body = t(notice.bodyKey);
|
|
const ctaLabel = notice.cta ? t(notice.cta.labelKey) : null;
|
|
|
|
// Tailwind 3.3+ supports border-s-4 (logical, RTL-aware)
|
|
const accentBorder = 'border-s-4';
|
|
|
|
return (
|
|
<div
|
|
role={s.role}
|
|
aria-live={s.ariaLive}
|
|
aria-atomic="true"
|
|
className={`flex items-start gap-x-3 py-3 px-4 ${accentBorder} ${s.bg} ${s.border} ${s.text}`}
|
|
>
|
|
{React.createElement(
|
|
(SEVERITY_ICONS[notice.severity] ?? Info) as React.ElementType,
|
|
{ size: 20, className: `shrink-0 mt-0.5 ${s.icon}` },
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<span className="font-semibold">{title}</span>
|
|
{body !== title && (
|
|
<span className="ml-2 opacity-80">{body}</span>
|
|
)}
|
|
{ctaLabel && notice.cta && (
|
|
<CTALink notice={notice} label={ctaLabel} onDismiss={onDismiss} />
|
|
)}
|
|
</div>
|
|
{notice.dismissible && (
|
|
<button
|
|
onClick={onDismiss}
|
|
className="shrink-0 p-2 -mr-2 rounded hover:bg-black/5 dark:hover:bg-white/10 transition"
|
|
aria-label={`Dismiss: ${title}`}
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface AnimatedBannerItemProps {
|
|
notice: SystemNoticeDTO;
|
|
onDismiss: () => void;
|
|
language: string;
|
|
}
|
|
|
|
function AnimatedBannerItem({ notice, onDismiss, language }: AnimatedBannerItemProps) {
|
|
const [mounted, setMounted] = useState(false);
|
|
const prefersReducedMotion =
|
|
typeof window !== 'undefined' &&
|
|
(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false);
|
|
|
|
useEffect(() => {
|
|
if (typeof requestAnimationFrame !== 'undefined') {
|
|
const id = requestAnimationFrame(() => setMounted(true));
|
|
return () => cancelAnimationFrame(id);
|
|
}
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
const transition = prefersReducedMotion
|
|
? 'transition-opacity duration-[120ms]'
|
|
: 'transition-all duration-200 ease-out';
|
|
const state = mounted ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2';
|
|
|
|
return (
|
|
<div className={`${transition} ${state}`}>
|
|
<BannerItem notice={notice} onDismiss={onDismiss} language={language} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface BannerRendererProps {
|
|
notices: SystemNoticeDTO[];
|
|
}
|
|
|
|
export function BannerRenderer({ notices }: BannerRendererProps) {
|
|
const { dismiss } = useSystemNoticeStore();
|
|
const { language } = useTranslation();
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Show at most 2 highest-priority banners
|
|
const visible = notices.slice(0, 2);
|
|
|
|
// Report banner stack height for layout reflow
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
const observer = new ResizeObserver(() => {
|
|
document.documentElement.style.setProperty('--banner-stack-h', el.offsetHeight + 'px');
|
|
});
|
|
observer.observe(el);
|
|
return () => {
|
|
observer.disconnect();
|
|
document.documentElement.style.setProperty('--banner-stack-h', '0px');
|
|
};
|
|
}, []);
|
|
|
|
if (visible.length === 0) return null;
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="fixed left-0 right-0 z-40"
|
|
style={{ top: 'var(--nav-h, 0px)' }}
|
|
>
|
|
{visible.map((notice, i) => (
|
|
<React.Fragment key={notice.id}>
|
|
{i > 0 && <div className="border-t border-black/10 dark:border-white/10" />}
|
|
<AnimatedBannerItem
|
|
notice={notice}
|
|
onDismiss={() => dismiss(notice.id)}
|
|
language={language}
|
|
/>
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ToastRendererProps {
|
|
notices: SystemNoticeDTO[];
|
|
}
|
|
|
|
export function ToastRenderer({ notices }: ToastRendererProps) {
|
|
const { dismiss } = useSystemNoticeStore();
|
|
const { t } = useTranslation();
|
|
const firedRef = useRef(new Set<string>());
|
|
|
|
useEffect(() => {
|
|
for (const notice of notices) {
|
|
if (firedRef.current.has(notice.id)) continue;
|
|
firedRef.current.add(notice.id);
|
|
|
|
// Critical should not be a toast — log and skip
|
|
if (notice.severity === 'critical') {
|
|
console.warn(
|
|
`[systemNotices] notice "${notice.id}" is critical but display=toast. ` +
|
|
'Should be banner or modal.'
|
|
);
|
|
dismiss(notice.id);
|
|
continue;
|
|
}
|
|
|
|
const variantMap: Record<string, string> = { info: 'info', warn: 'warning' };
|
|
const variant = variantMap[notice.severity] ?? 'info';
|
|
const titleStr = t(notice.titleKey);
|
|
const bodyStr = t(notice.bodyKey);
|
|
const message = bodyStr !== titleStr ? `${titleStr}: ${bodyStr}` : titleStr;
|
|
const duration = notice.severity === 'warn' ? 9000 : 6000;
|
|
|
|
// Fire the toast, retrying on the next frame if __addToast isn't registered yet
|
|
// (race between ToastContainer mounting and SystemNoticeHost mounting on cold load).
|
|
const fireToast = (attempt = 0) => {
|
|
if (typeof window.__addToast === 'function') {
|
|
window.__addToast(message, variant as 'info' | 'success' | 'error' | 'warning', duration);
|
|
} else if (attempt < 10) {
|
|
requestAnimationFrame(() => fireToast(attempt + 1));
|
|
return; // don't schedule dismiss until the toast actually fires
|
|
} else {
|
|
console.warn(`[systemNotices] toast "${notice.id}" dropped — __addToast never registered`);
|
|
}
|
|
setTimeout(() => dismiss(notice.id), duration + 500);
|
|
};
|
|
fireToast();
|
|
}
|
|
}, [notices]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
return null;
|
|
}
|