mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
feat(notices): add system notice infrastructure
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.
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user