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:
jubnl
2026-04-16 14:36:33 +02:00
parent 3b069bc543
commit 293506217e
46 changed files with 3539 additions and 50 deletions
+8
View File
@@ -6,6 +6,7 @@ import type { User } from '../types'
import { getApiErrorMessage } from '../types'
import { tripSyncManager } from '../sync/tripSyncManager'
import { clearAll } from '../db/offlineDb'
import { useSystemNoticeStore } from './systemNoticeStore.js'
interface AuthResponse {
user: User
@@ -91,6 +92,9 @@ export const useAuthStore = create<AuthState>()(
})
connect()
tripSyncManager.syncAll().catch(console.error)
if (!data.user?.must_change_password) {
useSystemNoticeStore.getState().fetch()
}
return data as AuthResponse
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Login failed')
@@ -112,6 +116,9 @@ export const useAuthStore = create<AuthState>()(
})
connect()
tripSyncManager.syncAll().catch(console.error)
if (!data.user?.must_change_password) {
useSystemNoticeStore.getState().fetch()
}
return data as AuthResponse
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Verification failed')
@@ -133,6 +140,7 @@ export const useAuthStore = create<AuthState>()(
})
connect()
tripSyncManager.syncAll().catch(console.error)
useSystemNoticeStore.getState().fetch()
return data
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Registration failed')
+67
View File
@@ -0,0 +1,67 @@
import { create } from 'zustand';
import axios from '../api/client.js';
// Type mirrors SystemNoticeDTO from the server (copy here to avoid cross-package import)
export interface SystemNoticeDTO {
id: string;
display: 'modal' | 'banner' | 'toast';
severity: 'info' | 'warn' | 'critical';
titleKey: string;
bodyKey: string;
bodyParams?: Record<string, string>;
icon?: string;
media?: {
src: string;
srcDark?: string;
altKey: string;
placement?: 'hero' | 'inline';
aspectRatio?: string;
};
highlights?: Array<{ labelKey: string; iconName?: string }>;
cta?: (
| { kind: 'nav'; labelKey: string; href: string }
| { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean }
);
dismissible: boolean;
}
interface SystemNoticeState {
notices: SystemNoticeDTO[];
loaded: boolean;
fetching: boolean;
fetch: () => Promise<void>;
dismiss: (id: string) => void;
}
export const useSystemNoticeStore = create<SystemNoticeState>()((set, get) => ({
notices: [],
loaded: false,
fetching: false,
async fetch() {
if (get().fetching || get().loaded) return;
set({ fetching: true });
try {
const res = await axios.get<SystemNoticeDTO[]>('/system-notices/active');
set({ notices: res.data, loaded: true, fetching: false });
} catch (err) {
// Notices are non-critical. Fail silently; set loaded so UI doesn't hang.
console.warn('[systemNotices] failed to fetch:', err);
set({ loaded: true, fetching: false });
}
},
dismiss(id: string) {
// Optimistic: remove immediately
const prev = get().notices;
set({ notices: prev.filter(n => n.id !== id) });
// POST in background; retry once on error
const post = () => axios.post(`/system-notices/${id}/dismiss`);
post().catch(() => {
setTimeout(() => {
post().catch(e => console.warn('[systemNotices] dismiss failed:', e));
}, 2000);
});
},
}));