mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
Merge branch 'feat/system-notices' into dev
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { act } from '@testing-library/react';
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore';
|
||||
import { BannerRenderer } from './SystemNoticeBanner';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore';
|
||||
|
||||
function makeBanner(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDTO {
|
||||
return {
|
||||
id: 'banner-1',
|
||||
display: 'banner',
|
||||
severity: 'info',
|
||||
titleKey: 'Maintenance notice',
|
||||
bodyKey: 'System will be down briefly.',
|
||||
dismissible: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BannerRenderer', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.post('/api/system-notices/:id/dismiss', () => {
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
useSystemNoticeStore.setState({ notices: [], loaded: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.documentElement.style.removeProperty('--banner-stack-h');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-001: renders banner with correct title and body', async () => {
|
||||
const notice = makeBanner();
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Maintenance notice')).toBeTruthy();
|
||||
expect(screen.getByText('System will be down briefly.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-002: dismiss button calls store.dismiss(id)', async () => {
|
||||
const notice = makeBanner();
|
||||
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
const dismissBtn = screen.getByLabelText(/Dismiss/);
|
||||
await act(async () => {
|
||||
fireEvent.click(dismissBtn);
|
||||
});
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith('banner-1');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-003: two banners stack correctly', async () => {
|
||||
const n1 = makeBanner({ id: 'banner-1', titleKey: 'First notice' });
|
||||
const n2 = makeBanner({ id: 'banner-2', titleKey: 'Second notice' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[n1, n2]} />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('First notice')).toBeTruthy();
|
||||
expect(screen.getByText('Second notice')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-004: third banner is not rendered (only last 2 shown)', async () => {
|
||||
const n1 = makeBanner({ id: 'banner-1', titleKey: 'Oldest notice' });
|
||||
const n2 = makeBanner({ id: 'banner-2', titleKey: 'Second notice' });
|
||||
const n3 = makeBanner({ id: 'banner-3', titleKey: 'Newest notice' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[n1, n2, n3]} />);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Oldest notice')).toBeNull();
|
||||
expect(screen.getByText('Second notice')).toBeTruthy();
|
||||
expect(screen.getByText('Newest notice')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-005: critical banner has aria-live="assertive"', async () => {
|
||||
const notice = makeBanner({ severity: 'critical', id: 'crit-1' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
const alertEl = screen.getByRole('alert');
|
||||
expect(alertEl.getAttribute('aria-live')).toBe('assertive');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-006: info banner has aria-live="polite"', async () => {
|
||||
const notice = makeBanner({ severity: 'info' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
const statusEl = screen.getByRole('status');
|
||||
expect(statusEl.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-007: warn banner has aria-live="polite"', async () => {
|
||||
const notice = makeBanner({ severity: 'warn', id: 'warn-1' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
const statusEl = screen.getByRole('status');
|
||||
expect(statusEl.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-008: renders nothing when notices array is empty', () => {
|
||||
const { container } = render(<BannerRenderer notices={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-009: non-dismissible banner hides dismiss button', async () => {
|
||||
const notice = makeBanner({ dismissible: false });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[notice]} />);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Maintenance notice')).toBeTruthy();
|
||||
expect(screen.queryByLabelText(/Dismiss/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
||||
import { ModalRenderer } from './SystemNoticeModal.js';
|
||||
import { BannerRenderer, ToastRenderer } from './SystemNoticeBanner.js';
|
||||
|
||||
export function SystemNoticeHost() {
|
||||
const { notices, loaded } = useSystemNoticeStore();
|
||||
|
||||
// Notices are fetched by authStore after login (see App.tsx / authStore modification).
|
||||
// Cold-session fetch (page reload with valid session) is triggered here:
|
||||
useEffect(() => {
|
||||
// Only fetch if not already loaded (authStore may have already triggered)
|
||||
if (!loaded) {
|
||||
useSystemNoticeStore.getState().fetch();
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!loaded) return null;
|
||||
|
||||
const modals = notices.filter(n => n.display === 'modal');
|
||||
const banners = notices.filter(n => n.display === 'banner');
|
||||
const toasts = notices.filter(n => n.display === 'toast');
|
||||
|
||||
return (
|
||||
<>
|
||||
<BannerRenderer notices={banners} />
|
||||
<ModalRenderer notices={modals} />
|
||||
<ToastRenderer notices={toasts} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { act } from '@testing-library/react';
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore';
|
||||
import { ModalRenderer } from './SystemNoticeModal';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore';
|
||||
|
||||
// Stub react-markdown to avoid async chunk issues in tests
|
||||
vi.mock('react-markdown', () => ({
|
||||
default: ({ children }: { children: string }) => <span data-testid="md">{children}</span>,
|
||||
}));
|
||||
vi.mock('remark-gfm', () => ({ default: () => ({}) }));
|
||||
vi.mock('rehype-sanitize', () => ({ default: () => ({}) }));
|
||||
|
||||
function makeNotice(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDTO {
|
||||
return {
|
||||
id: 'test-notice-1',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
titleKey: 'Test Title',
|
||||
bodyKey: 'Test body text',
|
||||
dismissible: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance fake timers past the grace delay (2× rAF fallback → each is a
|
||||
* setTimeout(0), then 500ms). All three timers fire in sequence with
|
||||
* runAllTimers() — no need to advance exact milliseconds.
|
||||
*/
|
||||
async function flushGraceDelay() {
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
}
|
||||
|
||||
describe('ModalRenderer', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.post('/api/system-notices/:id/dismiss', () => {
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
useSystemNoticeStore.setState({ notices: [], loaded: true });
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-001: renders title and body after grace delay', async () => {
|
||||
const notice = makeNotice();
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
// Before delay fires: dialog present but body not yet visible (class-based)
|
||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Test Title')).toBeTruthy();
|
||||
expect(screen.getByText('Test body text')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-002: dismiss button calls store.dismiss(id)', async () => {
|
||||
const notice = makeNotice();
|
||||
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
const dismissBtn = screen.getByLabelText('Dismiss');
|
||||
await act(async () => {
|
||||
fireEvent.click(dismissBtn);
|
||||
});
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith('test-notice-1');
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-003: non-dismissible critical notice hides dismiss affordance', async () => {
|
||||
const notice = makeNotice({ severity: 'critical', dismissible: false });
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull();
|
||||
expect(screen.queryByText('Not now')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-004: ESC key does not close non-dismissible notice', async () => {
|
||||
const notice = makeNotice({ severity: 'critical', dismissible: false });
|
||||
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
});
|
||||
|
||||
expect(dismissSpy).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-005: CTA nav button dismisses all notices (not just current)', async () => {
|
||||
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } });
|
||||
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
|
||||
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
render(<ModalRenderer notices={[noticeA, noticeB]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
const ctaBtn = screen.getByRole('button', { name: 'Go to trips' });
|
||||
await act(async () => {
|
||||
fireEvent.click(ctaBtn);
|
||||
});
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-a');
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-b');
|
||||
expect(dismissSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-006: modal backdrop has opacity-0 class before grace delay fires', () => {
|
||||
const notice = makeNotice();
|
||||
const { container } = render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
// Dialog is in DOM, backdrop has opacity-0 before timers fire
|
||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||
const backdrop = container.querySelector('[role="presentation"]');
|
||||
expect(backdrop?.className).toContain('opacity-0');
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-007: body params are interpolated before rendering', async () => {
|
||||
const notice = makeNotice({
|
||||
bodyKey: 'Hello {name}, welcome to {app}',
|
||||
bodyParams: { name: 'Alice', app: 'TREK' },
|
||||
});
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Hello Alice, welcome to TREK')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-008: empty notices renders nothing', () => {
|
||||
const { container } = render(<ModalRenderer notices={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
// ── Multipage (pager) ──────────────────────────────────────────────────────
|
||||
|
||||
it('FE-SN-MODAL-009: pager is hidden when only one notice is present', async () => {
|
||||
const notice = makeNotice();
|
||||
render(<ModalRenderer notices={[notice]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.queryByLabelText('Previous notice')).toBeNull();
|
||||
expect(screen.queryByLabelText('Next notice')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-010: pager shows counter and dots for multiple notices', async () => {
|
||||
const notices = [
|
||||
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
|
||||
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
|
||||
makeNotice({ id: 'n3', titleKey: 'Notice C' }),
|
||||
];
|
||||
render(<ModalRenderer notices={notices} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Go to notice 1')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Go to notice 2')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Go to notice 3')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Previous notice')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Next notice')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-011: next button advances to the next notice; prev returns', async () => {
|
||||
const notices = [
|
||||
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
|
||||
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
|
||||
makeNotice({ id: 'n3', titleKey: 'Notice C' }),
|
||||
];
|
||||
render(<ModalRenderer notices={notices} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy();
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
|
||||
// Navigate to page 2
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Next notice'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('2 / 3')).toBeTruthy();
|
||||
expect(screen.getByText('Notice B')).toBeTruthy();
|
||||
|
||||
// Navigate back to page 1
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Previous notice'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy();
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-012: ArrowRight / ArrowLeft keys navigate between pages', async () => {
|
||||
const notices = [
|
||||
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
|
||||
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
|
||||
];
|
||||
render(<ModalRenderer notices={notices} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'ArrowRight' });
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Notice B')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'ArrowLeft' });
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-013: clicking a dot navigates directly to that page', async () => {
|
||||
const notices = [
|
||||
makeNotice({ id: 'n1', titleKey: 'Notice A' }),
|
||||
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
|
||||
makeNotice({ id: 'n3', titleKey: 'Notice C' }),
|
||||
];
|
||||
render(<ModalRenderer notices={notices} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
|
||||
// Click third dot
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Go to notice 3'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('3 / 3')).toBeTruthy();
|
||||
expect(screen.getByText('Notice C')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-014: non-dismissible notice locks the pager (prev/next/dots disabled)', async () => {
|
||||
const notices = [
|
||||
makeNotice({ id: 'n1', titleKey: 'Notice A', dismissible: false }),
|
||||
makeNotice({ id: 'n2', titleKey: 'Notice B' }),
|
||||
];
|
||||
render(<ModalRenderer notices={notices} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
const prevBtn = screen.getByLabelText('Previous notice') as HTMLButtonElement;
|
||||
const nextBtn = screen.getByLabelText('Next notice') as HTMLButtonElement;
|
||||
const dot2 = screen.getByLabelText('Go to notice 2') as HTMLButtonElement;
|
||||
|
||||
expect(prevBtn.disabled).toBe(true);
|
||||
expect(nextBtn.disabled).toBe(true);
|
||||
expect(dot2.disabled).toBe(true);
|
||||
|
||||
// Arrow keys should also be blocked
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'ArrowRight' });
|
||||
});
|
||||
// Still on page 1 (no grace delay needed because page didn't change)
|
||||
expect(screen.getByText('1 / 2')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-015: dismissing a notice does not skip the next one (regression)', async () => {
|
||||
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
|
||||
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
|
||||
const noticeC = makeNotice({ id: 'n-c', titleKey: 'Notice C' });
|
||||
|
||||
useSystemNoticeStore.setState({ notices: [noticeA, noticeB, noticeC], loaded: true });
|
||||
const { rerender } = render(<ModalRenderer notices={[noticeA, noticeB, noticeC]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy();
|
||||
|
||||
// Dismiss notice A — store shrinks, parent re-renders with [B, C]
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Dismiss'));
|
||||
useSystemNoticeStore.setState({ notices: [noticeB, noticeC], loaded: true });
|
||||
rerender(<ModalRenderer notices={[noticeB, noticeC]} />);
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
// Must show B (idx=0), not C (idx=1 — the old buggy behavior)
|
||||
expect(screen.getByText('Notice B')).toBeTruthy();
|
||||
expect(screen.getByText('1 / 2')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-017: X button dismisses all notices, not just the current one', async () => {
|
||||
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
|
||||
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
|
||||
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
render(<ModalRenderer notices={[noticeA, noticeB]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Dismiss'));
|
||||
});
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-a');
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-b');
|
||||
expect(dismissSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-018: ESC key dismisses all notices when current is dismissible', async () => {
|
||||
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
|
||||
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
|
||||
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
render(<ModalRenderer notices={[noticeA, noticeB]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
});
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-a');
|
||||
expect(dismissSpy).toHaveBeenCalledWith('n-b');
|
||||
expect(dismissSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-016: dismissing the only remaining notice closes the modal', async () => {
|
||||
const notice = makeNotice({ id: 'solo', titleKey: 'Solo Notice' });
|
||||
useSystemNoticeStore.setState({ notices: [notice], loaded: true });
|
||||
|
||||
const { rerender, container } = render(<ModalRenderer notices={[notice]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Solo Notice')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Dismiss'));
|
||||
useSystemNoticeStore.setState({ notices: [], loaded: true });
|
||||
rerender(<ModalRenderer notices={[]} />);
|
||||
});
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,601 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
|
||||
import { useTranslation, isRtlLanguage } from '../../i18n/index.js';
|
||||
import { runNoticeAction } from './noticeActions.js';
|
||||
|
||||
const ReactMarkdown = React.lazy(() =>
|
||||
import('react-markdown').then(m => ({ default: m.default }))
|
||||
);
|
||||
|
||||
/** Safe rAF shim — falls back to setTimeout(0) in environments without rAF (e.g. jsdom). */
|
||||
function scheduleFrame(cb: () => void): () => void {
|
||||
if (typeof requestAnimationFrame !== 'undefined') {
|
||||
const id = requestAnimationFrame(cb);
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
const id = setTimeout(cb, 0);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
|
||||
const SEVERITY_ICONS: Record<string, React.ElementType> = {
|
||||
info: Info,
|
||||
warn: AlertTriangle,
|
||||
critical: AlertOctagon,
|
||||
};
|
||||
|
||||
const SEVERITY_ACCENT: Record<string, string> = {
|
||||
info: 'text-blue-500 dark:text-blue-400 bg-blue-50 dark:bg-blue-950',
|
||||
warn: 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950',
|
||||
critical: 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-950',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
notices: SystemNoticeDTO[];
|
||||
}
|
||||
|
||||
// Inner content shared between desktop and mobile layouts
|
||||
interface ContentProps {
|
||||
notice: SystemNoticeDTO;
|
||||
title: string;
|
||||
body: string;
|
||||
ctaLabel: string | null;
|
||||
titleId: string;
|
||||
bodyId: string;
|
||||
isDark: boolean;
|
||||
onDismiss: () => void;
|
||||
onDismissAll: () => void;
|
||||
onCTA: () => void;
|
||||
// Pager
|
||||
total: number;
|
||||
currentPage: number;
|
||||
canPage: boolean;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onGoto: (i: number) => void;
|
||||
}
|
||||
|
||||
function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const DefaultIcon = SEVERITY_ICONS[notice.severity] ?? Info;
|
||||
const LucideIcon: React.ElementType = notice.icon
|
||||
? ((LucideIcons as Record<string, unknown>)[notice.icon] as React.ElementType) ?? DefaultIcon
|
||||
: DefaultIcon;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col relative">
|
||||
{/* Dismiss X button */}
|
||||
{notice.dismissible && (
|
||||
<button
|
||||
onClick={onDismissAll}
|
||||
className="absolute top-4 right-4 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Hero image (not inline) */}
|
||||
{notice.media && notice.media.placement !== 'inline' && (
|
||||
<div
|
||||
className="w-full overflow-hidden"
|
||||
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
|
||||
>
|
||||
<img
|
||||
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
|
||||
alt={t(notice.media.altKey)}
|
||||
className="w-full h-full object-cover"
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-8">
|
||||
{/* Severity icon (when no hero) */}
|
||||
{!notice.media && (
|
||||
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
|
||||
<LucideIcon size={28} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h2
|
||||
id={titleId}
|
||||
className="text-xl font-semibold text-center text-slate-900 dark:text-slate-100 mb-3"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{/* Body — markdown */}
|
||||
<div
|
||||
id={bodyId}
|
||||
className="text-sm leading-relaxed text-center text-slate-600 dark:text-slate-400 max-w-[340px] mx-auto mb-4"
|
||||
>
|
||||
<React.Suspense fallback={<p className="text-sm text-slate-500">{body}</p>}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-blue-600 dark:text-blue-400 underline hover:no-underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => <ul className="list-disc list-inside text-left">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside text-left">{children}</ol>,
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</ReactMarkdown>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
|
||||
{/* Inline image */}
|
||||
{notice.media?.placement === 'inline' && (
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-lg mb-4 max-w-[340px] mx-auto"
|
||||
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
|
||||
>
|
||||
<img
|
||||
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
|
||||
alt={t(notice.media.altKey)}
|
||||
className="w-full h-full object-cover"
|
||||
decoding="async"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Highlights */}
|
||||
{notice.highlights && notice.highlights.length > 0 && (
|
||||
<ul className="max-w-[340px] mx-auto mb-4 space-y-2">
|
||||
{notice.highlights.map((h, i) => {
|
||||
const HIcon: React.ElementType | null = h.iconName
|
||||
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
|
||||
: null;
|
||||
return (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
{HIcon
|
||||
? <HIcon size={16} className="text-blue-500 shrink-0" />
|
||||
: <span className="text-blue-500 shrink-0">✓</span>
|
||||
}
|
||||
{t(h.labelKey)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Pager — dots, arrows, counter (only when multiple notices) */}
|
||||
{total > 1 && (
|
||||
<div className="flex flex-col items-center gap-1 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={!canPage || currentPage === 0}
|
||||
aria-label={t('system_notice.pager.prev')}
|
||||
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => { if (canPage) onGoto(i); }}
|
||||
aria-label={t('system_notice.pager.goto').replace('{n}', String(i + 1))}
|
||||
aria-current={i === currentPage ? 'true' : undefined}
|
||||
disabled={!canPage && i !== currentPage}
|
||||
className={`w-2 h-2 rounded-full transition-colors ${
|
||||
i === currentPage
|
||||
? 'bg-blue-500 dark:bg-blue-400'
|
||||
: 'bg-slate-300 dark:bg-slate-600 hover:bg-slate-400 dark:hover:bg-slate-500 disabled:cursor-not-allowed'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!canPage || currentPage === total - 1}
|
||||
aria-label={t('system_notice.pager.next')}
|
||||
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-slate-400 tabular-nums">
|
||||
{t('system_notice.pager.counter')
|
||||
.replace('{current}', String(currentPage + 1))
|
||||
.replace('{total}', String(total))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA + dismiss link */}
|
||||
<div className="flex flex-col items-center gap-3 mt-2">
|
||||
{ctaLabel ? (
|
||||
<button
|
||||
id={`notice-cta-${notice.id}`}
|
||||
onClick={onCTA}
|
||||
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
|
||||
>
|
||||
{ctaLabel}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
id={`notice-cta-${notice.id}`}
|
||||
onClick={onDismissAll}
|
||||
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
|
||||
>
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
)}
|
||||
{notice.dismissible && ctaLabel && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalRenderer({ notices }: Props) {
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [pageAnnouncement, setPageAnnouncement] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const { dismiss } = useSystemNoticeStore();
|
||||
const { t, language } = useTranslation();
|
||||
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => typeof window !== 'undefined' && (window.matchMedia?.('(max-width: 639px)')?.matches ?? false)
|
||||
);
|
||||
|
||||
const [isDark, setIsDark] = useState(
|
||||
() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
const prefersReducedMotion =
|
||||
typeof window !== 'undefined' &&
|
||||
(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false);
|
||||
|
||||
const notice = notices[idx] ?? null;
|
||||
|
||||
// Non-dismissible notices lock the pager so users must act before advancing.
|
||||
const canPage = notice?.dismissible !== false;
|
||||
|
||||
const touchStartY = useRef<number | null>(null);
|
||||
// Keep a ref to the current notice id so dismiss/CTA handlers see the latest value
|
||||
const noticeIdRef = useRef<string | null>(null);
|
||||
noticeIdRef.current = notice?.id ?? null;
|
||||
|
||||
// Page-slide animation refs.
|
||||
// isPageNavRef: set to true just before a user-initiated page change so the
|
||||
// grace-delay effect knows to run a slide instead of hide+show.
|
||||
// slideDirRef: 'right' = new content enters from the right (Next), 'left' = from the left (Prev).
|
||||
// contentWrapperRef: the div wrapping NoticeContent — we animate its transform directly.
|
||||
const isPageNavRef = useRef(false);
|
||||
const slideDirRef = useRef<'left' | 'right'>('right');
|
||||
const contentWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Mobile breakpoint
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia?.('(max-width: 639px)');
|
||||
if (!mq) return;
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
// Dark mode observer
|
||||
useEffect(() => {
|
||||
const obs = new MutationObserver(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
});
|
||||
obs.observe(document.documentElement, { attributeFilter: ['class'] });
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
// Clamp idx when notices array shrinks (e.g. after dismiss of the last page)
|
||||
useEffect(() => {
|
||||
if (notices.length > 0 && idx >= notices.length) {
|
||||
setIdx(notices.length - 1);
|
||||
}
|
||||
}, [notices.length, idx]);
|
||||
|
||||
// Fires on every notice-id change. Branches on whether this is a user-initiated
|
||||
// page navigation (slide the content wrapper) or a modal appear/dismiss-advance
|
||||
// (grace-delay the whole modal).
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
|
||||
// ── Page navigation: slide new content in, keep modal visible ────────────
|
||||
if (isPageNavRef.current) {
|
||||
isPageNavRef.current = false;
|
||||
const el = contentWrapperRef.current;
|
||||
if (el && !prefersReducedMotion) {
|
||||
// The handler already set el.style.transform to the start position
|
||||
// synchronously before setIdx was called. Trigger the transition here.
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transition = 'transform 260ms ease-out';
|
||||
el.style.transform = 'translateX(0)';
|
||||
const onEnd = () => {
|
||||
el.style.transition = '';
|
||||
el.style.transform = '';
|
||||
el.removeEventListener('transitionend', onEnd);
|
||||
};
|
||||
el.addEventListener('transitionend', onEnd);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Modal appearing / dismiss-advance: grace delay ────────────────────────
|
||||
setVisible(false);
|
||||
let cancelled = false;
|
||||
let timerId: ReturnType<typeof setTimeout> | undefined;
|
||||
const cancel1 = scheduleFrame(() => {
|
||||
const cancel2 = scheduleFrame(() => {
|
||||
timerId = setTimeout(() => {
|
||||
if (!cancelled) setVisible(true);
|
||||
}, 500);
|
||||
});
|
||||
if (cancelled) cancel2();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancel1();
|
||||
if (timerId !== undefined) clearTimeout(timerId);
|
||||
};
|
||||
}, [notice?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ESC key — closes all modal notices (same as clicking X)
|
||||
useEffect(() => {
|
||||
if (!visible || !notice?.dismissible) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleDismissAll();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [visible, notice?.dismissible]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Arrow-key pager navigation
|
||||
useEffect(() => {
|
||||
if (!visible || notices.length <= 1) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
if (!canPage) return;
|
||||
// In RTL layouts the directional meaning of arrows is flipped
|
||||
const forward = isRtlLanguage(language) ? e.key === 'ArrowLeft' : e.key === 'ArrowRight';
|
||||
if (forward && idx < notices.length - 1) {
|
||||
triggerPageSlide('right');
|
||||
setIdx(idx + 1);
|
||||
announceIndex(idx + 1, notices.length);
|
||||
} else if (!forward && idx > 0) {
|
||||
triggerPageSlide('left');
|
||||
setIdx(idx - 1);
|
||||
announceIndex(idx - 1, notices.length);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [visible, idx, notices.length, canPage, language]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Body scroll lock
|
||||
useEffect(() => {
|
||||
if (visible && notice) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [visible, notice]);
|
||||
|
||||
function announceIndex(newIdx: number, total: number) {
|
||||
setPageAnnouncement(
|
||||
t('system_notice.pager.position')
|
||||
.replace('{current}', String(newIdx + 1))
|
||||
.replace('{total}', String(total)),
|
||||
);
|
||||
}
|
||||
|
||||
// Dismiss current notice. The store removes it from the array, and the next
|
||||
// notice naturally shifts into notices[idx]. The clamp effect handles the
|
||||
// edge case where idx was pointing at the last item.
|
||||
function handleDismissById(id: string) {
|
||||
setVisible(false);
|
||||
dismiss(id);
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
const id = noticeIdRef.current;
|
||||
if (id) handleDismissById(id);
|
||||
}
|
||||
|
||||
// Dismiss every notice in the current modal list — used by the X button and ESC.
|
||||
function handleDismissAll() {
|
||||
setVisible(false);
|
||||
notices.forEach(n => dismiss(n.id));
|
||||
}
|
||||
|
||||
function handleCTA() {
|
||||
if (!notice) return;
|
||||
if (!notice.cta) {
|
||||
handleDismissAll();
|
||||
return;
|
||||
}
|
||||
if (notice.cta.kind === 'nav') {
|
||||
navigate(notice.cta.href);
|
||||
if (notice.dismissible !== false) handleDismissAll();
|
||||
} else {
|
||||
runNoticeAction(notice.cta.actionId, { navigate });
|
||||
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
|
||||
if (actionCta.dismissOnAction !== false) handleDismissAll();
|
||||
}
|
||||
}
|
||||
|
||||
// Sets up the content wrapper's start transform SYNCHRONOUSLY (before React
|
||||
// re-renders with the new notice), then flags the grace-delay effect to slide
|
||||
// rather than hide+show.
|
||||
function triggerPageSlide(dir: 'left' | 'right') {
|
||||
isPageNavRef.current = true;
|
||||
slideDirRef.current = dir;
|
||||
if (!prefersReducedMotion) {
|
||||
const el = contentWrapperRef.current;
|
||||
if (el) {
|
||||
el.style.transition = 'none';
|
||||
el.style.transform = dir === 'right' ? 'translateX(100%)' : 'translateX(-100%)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
if (!canPage || idx <= 0) return;
|
||||
const next = idx - 1;
|
||||
triggerPageSlide('left');
|
||||
setIdx(next);
|
||||
announceIndex(next, notices.length);
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (!canPage || idx >= notices.length - 1) return;
|
||||
const next = idx + 1;
|
||||
triggerPageSlide('right');
|
||||
setIdx(next);
|
||||
announceIndex(next, notices.length);
|
||||
}
|
||||
|
||||
function handleGoto(i: number) {
|
||||
if (!canPage || i === idx) return;
|
||||
triggerPageSlide(i > idx ? 'right' : 'left');
|
||||
setIdx(i);
|
||||
announceIndex(i, notices.length);
|
||||
}
|
||||
|
||||
// No notice to show
|
||||
if (!notice) return null;
|
||||
|
||||
// Pre-compute body with params interpolated
|
||||
const rawBody = t(notice.bodyKey);
|
||||
const body = notice.bodyParams
|
||||
? Object.entries(notice.bodyParams).reduce(
|
||||
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
|
||||
rawBody
|
||||
)
|
||||
: rawBody;
|
||||
|
||||
const title = t(notice.titleKey);
|
||||
const ctaLabel = notice.cta ? t(notice.cta.labelKey) : null;
|
||||
|
||||
const titleId = `notice-title-${notice.id}`;
|
||||
const bodyId = `notice-body-${notice.id}`;
|
||||
|
||||
// Animation classes
|
||||
const dur = prefersReducedMotion ? 'duration-[120ms]' : 'duration-[260ms]';
|
||||
const ease = visible ? 'ease-out' : 'ease-in';
|
||||
|
||||
const contentProps: ContentProps = {
|
||||
notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
||||
onDismiss: handleDismiss,
|
||||
onDismissAll: handleDismissAll,
|
||||
onCTA: handleCTA,
|
||||
total: notices.length,
|
||||
currentPage: idx,
|
||||
canPage,
|
||||
onPrev: handlePrev,
|
||||
onNext: handleNext,
|
||||
onGoto: handleGoto,
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
const mobileMotion = prefersReducedMotion
|
||||
? (visible ? 'opacity-100' : 'opacity-0')
|
||||
: (visible ? 'opacity-100 translate-y-0' : 'opacity-100 translate-y-full');
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="presentation">
|
||||
{/* Screen-reader page announcements */}
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={notice.dismissible ? handleDismiss : undefined}
|
||||
/>
|
||||
{/* Bottom sheet */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={bodyId}
|
||||
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden max-h-[85dvh] overflow-y-auto bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-all ${dur} ${ease} ${mobileMotion}`}
|
||||
onTouchStart={e => { touchStartY.current = e.touches[0].clientY; }}
|
||||
onTouchEnd={e => {
|
||||
if (touchStartY.current !== null && notice.dismissible) {
|
||||
const delta = e.changedTouches[0].clientY - touchStartY.current;
|
||||
if (delta > 80) handleDismiss();
|
||||
}
|
||||
touchStartY.current = null;
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="pt-3 pb-1 flex justify-center">
|
||||
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
|
||||
</div>
|
||||
<div ref={contentWrapperRef}>
|
||||
<NoticeContent {...contentProps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop centered modal
|
||||
const maxWidth = notice.severity === 'critical' ? 'max-w-[560px]' : 'max-w-[480px]';
|
||||
const desktopMotion = prefersReducedMotion
|
||||
? (visible ? 'opacity-100' : 'opacity-0')
|
||||
: (visible ? 'opacity-100 scale-100' : 'opacity-0 scale-[0.97]');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
role="presentation"
|
||||
onClick={notice.dismissible ? handleDismiss : undefined}
|
||||
>
|
||||
{/* Screen-reader page announcements */}
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={bodyId}
|
||||
className={`w-full ${maxWidth} rounded-2xl overflow-hidden shadow-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 transition-all ${dur} ${ease} ${desktopMotion}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div ref={contentWrapperRef}>
|
||||
<NoticeContent {...contentProps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
export interface NoticeActionContext {
|
||||
navigate: NavigateFunction;
|
||||
}
|
||||
type NoticeActionHandler = (ctx: NoticeActionContext) => void | Promise<void>;
|
||||
|
||||
const actions = new Map<string, NoticeActionHandler>();
|
||||
|
||||
export function registerNoticeAction(id: string, handler: NoticeActionHandler): void {
|
||||
actions.set(id, handler);
|
||||
}
|
||||
|
||||
export function runNoticeAction(id: string, ctx: NoticeActionContext): void {
|
||||
const handler = actions.get(id);
|
||||
if (!handler) {
|
||||
console.error(`[systemNotices] unknown action CTA id: "${id}". Register it via registerNoticeAction().`);
|
||||
return;
|
||||
}
|
||||
void handler(ctx);
|
||||
}
|
||||
Reference in New Issue
Block a user