diff --git a/client/package-lock.json b/client/package-lock.json
index 75302296..ddd545a3 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -22,6 +22,7 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
+ "rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
@@ -171,7 +172,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1807,7 +1807,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -1856,7 +1855,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -3825,7 +3823,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -3966,7 +3965,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -3978,7 +3976,6 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -4221,7 +4218,6 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -4249,6 +4245,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -4649,7 +4646,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -5397,7 +5393,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -6337,6 +6334,21 @@
"node": ">= 0.4"
}
},
+ "node_modules/hast-util-sanitize": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz",
+ "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "unist-util-position": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -7133,7 +7145,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -7261,8 +7272,7 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
- "license": "BSD-2-Clause",
- "peer": true
+ "license": "BSD-2-Clause"
},
"node_modules/leaflet.markercluster": {
"version": "1.5.3",
@@ -7397,6 +7407,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -8437,7 +8448,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@inquirer/confirm": "^5.0.0",
"@mswjs/interceptors": "^0.41.2",
@@ -8823,7 +8833,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8985,6 +8994,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -9085,7 +9095,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -9098,7 +9107,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -9138,14 +9146,14 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/react-leaflet": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
"license": "Hippocratic-2.1",
- "peer": true,
"dependencies": {
"@react-leaflet/core": "^2.1.0"
},
@@ -9388,6 +9396,20 @@
"regjsparser": "bin/parser"
}
},
+ "node_modules/rehype-sanitize": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz",
+ "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hast-util-sanitize": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark-breaks": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz",
@@ -9540,7 +9562,6 @@
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -10658,7 +10679,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -10908,7 +10928,6 @@
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11227,7 +11246,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -11356,7 +11374,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -11854,7 +11871,6 @@
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
diff --git a/client/package.json b/client/package.json
index bc800289..195bc235 100644
--- a/client/package.json
+++ b/client/package.json
@@ -29,6 +29,7 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
+ "rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 90e82cdf..0a53fa46 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -2,6 +2,7 @@ import React, { useEffect, ReactNode } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
+import { useAddonStore } from './store/addonStore'
import LoginPage from './pages/LoginPage'
import DashboardPage from './pages/DashboardPage'
import TripPlannerPage from './pages/TripPlannerPage'
@@ -24,17 +25,22 @@ import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
import OfflineBanner from './components/Layout/OfflineBanner'
+import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
+// Notice action registrations (side-effect imports):
+import './pages/Trips/noticeActions.js'
interface ProtectedRouteProps {
children: ReactNode
adminRequired?: boolean
+ addonId?: string
}
-function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
+function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedRouteProps) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const user = useAuthStore((s) => s.user)
const isLoading = useAuthStore((s) => s.isLoading)
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
+ const addonStore = useAddonStore()
const { t } = useTranslation()
const location = useLocation()
@@ -67,6 +73,10 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
return
}
+ if (addonId && addonStore.loaded && !addonStore.isEnabled(addonId)) {
+ return
+ }
+
return (
{children}
@@ -92,6 +102,7 @@ function RootRedirect() {
export default function App() {
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
+ const { loadAddons } = useAddonStore()
useEffect(() => {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
@@ -145,6 +156,7 @@ export default function App() {
useEffect(() => {
if (isAuthenticated) {
loadSettings()
+ loadAddons()
}
}, [isAuthenticated])
@@ -182,8 +194,11 @@ export default function App() {
applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode, isSharedPage])
+ const isAuthPage = location.pathname.startsWith('/login') || location.pathname.startsWith('/register')
+
return (
+ {!isAuthPage && }
@@ -253,7 +268,7 @@ export default function App() {
+
}
@@ -261,7 +276,7 @@ export default function App() {
+
}
diff --git a/client/src/components/SystemNotices/SystemNoticeBanner.test.tsx b/client/src/components/SystemNotices/SystemNoticeBanner.test.tsx
new file mode 100644
index 00000000..c893a698
--- /dev/null
+++ b/client/src/components/SystemNotices/SystemNoticeBanner.test.tsx
@@ -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 {
+ 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();
+ });
+
+ 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();
+ });
+
+ 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();
+ });
+
+ 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();
+ });
+
+ 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();
+ });
+
+ 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();
+ });
+
+ 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();
+ });
+
+ 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();
+ 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();
+ });
+
+ expect(screen.getByText('Maintenance notice')).toBeTruthy();
+ expect(screen.queryByLabelText(/Dismiss/)).toBeNull();
+ });
+});
diff --git a/client/src/components/SystemNotices/SystemNoticeBanner.tsx b/client/src/components/SystemNotices/SystemNoticeBanner.tsx
new file mode 100644
index 00000000..dd7e8631
--- /dev/null
+++ b/client/src/components/SystemNotices/SystemNoticeBanner.tsx
@@ -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 = {
+ 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 (
+ { e.preventDefault(); handleClick(); }}
+ className="underline hover:no-underline font-medium ml-3 shrink-0"
+ >
+ {label}
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+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 (
+
+ {React.createElement(
+ (SEVERITY_ICONS[notice.severity] ?? Info) as React.ElementType,
+ { size: 20, className: `shrink-0 mt-0.5 ${s.icon}` },
+ )}
+
+ {title}
+ {body !== title && (
+ {body}
+ )}
+ {ctaLabel && notice.cta && (
+
+ )}
+
+ {notice.dismissible && (
+
+ )}
+
+ );
+}
+
+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 (
+
+
+
+ );
+}
+
+interface BannerRendererProps {
+ notices: SystemNoticeDTO[];
+}
+
+export function BannerRenderer({ notices }: BannerRendererProps) {
+ const { dismiss } = useSystemNoticeStore();
+ const { language } = useTranslation();
+ const containerRef = useRef(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 (
+
+ {visible.map((notice, i) => (
+
+ {i > 0 && }
+ dismiss(notice.id)}
+ language={language}
+ />
+
+ ))}
+
+ );
+}
+
+interface ToastRendererProps {
+ notices: SystemNoticeDTO[];
+}
+
+export function ToastRenderer({ notices }: ToastRendererProps) {
+ const { dismiss } = useSystemNoticeStore();
+ const { t } = useTranslation();
+ const firedRef = useRef(new Set());
+
+ 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 = { 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;
+}
diff --git a/client/src/components/SystemNotices/SystemNoticeHost.tsx b/client/src/components/SystemNotices/SystemNoticeHost.tsx
new file mode 100644
index 00000000..f18a55cb
--- /dev/null
+++ b/client/src/components/SystemNotices/SystemNoticeHost.tsx
@@ -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 (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/client/src/components/SystemNotices/SystemNoticeModal.test.tsx b/client/src/components/SystemNotices/SystemNoticeModal.test.tsx
new file mode 100644
index 00000000..9607f48e
--- /dev/null
+++ b/client/src/components/SystemNotices/SystemNoticeModal.test.tsx
@@ -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 }) => {children},
+}));
+vi.mock('remark-gfm', () => ({ default: () => ({}) }));
+vi.mock('rehype-sanitize', () => ({ default: () => ({}) }));
+
+function makeNotice(overrides: Partial = {}): 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();
+
+ // 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ // 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();
+
+ await flushGraceDelay();
+
+ expect(screen.getByText('Hello Alice, welcome to TREK')).toBeTruthy();
+ });
+
+ it('FE-SN-MODAL-008: empty notices renders nothing', () => {
+ const { container } = render();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ });
+ 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();
+ 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();
+ 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();
+ await flushGraceDelay();
+
+ expect(screen.getByText('Solo Notice')).toBeTruthy();
+
+ await act(async () => {
+ fireEvent.click(screen.getByLabelText('Dismiss'));
+ useSystemNoticeStore.setState({ notices: [], loaded: true });
+ rerender();
+ });
+
+ expect(container.firstChild).toBeNull();
+ });
+});
diff --git a/client/src/components/SystemNotices/SystemNoticeModal.tsx b/client/src/components/SystemNotices/SystemNoticeModal.tsx
new file mode 100644
index 00000000..10d0a13f
--- /dev/null
+++ b/client/src/components/SystemNotices/SystemNoticeModal.tsx
@@ -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 = {
+ info: Info,
+ warn: AlertTriangle,
+ critical: AlertOctagon,
+};
+
+const SEVERITY_ACCENT: Record = {
+ 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)[notice.icon] as React.ElementType) ?? DefaultIcon
+ : DefaultIcon;
+
+ return (
+
+ {/* Dismiss X button */}
+ {notice.dismissible && (
+
+ )}
+
+ {/* Hero image (not inline) */}
+ {notice.media && notice.media.placement !== 'inline' && (
+
+

{ (e.target as HTMLImageElement).style.display = 'none'; }}
+ />
+
+ )}
+
+
+ {/* Severity icon (when no hero) */}
+ {!notice.media && (
+
+
+
+ )}
+
+ {/* Title */}
+
+ {title}
+
+
+ {/* Body — markdown */}
+
+
{body}}>
+ (
+
+ {children}
+
+ ),
+ ul: ({ children }) => ,
+ ol: ({ children }) => {children}
,
+ }}
+ >
+ {body}
+
+
+
+
+ {/* Inline image */}
+ {notice.media?.placement === 'inline' && (
+
+

{ (e.target as HTMLImageElement).style.display = 'none'; }}
+ />
+
+ )}
+
+ {/* Highlights */}
+ {notice.highlights && notice.highlights.length > 0 && (
+
+ {notice.highlights.map((h, i) => {
+ const HIcon: React.ElementType | null = h.iconName
+ ? ((LucideIcons as Record)[h.iconName] as React.ElementType) ?? null
+ : null;
+ return (
+ -
+ {HIcon
+ ?
+ : ✓
+ }
+ {t(h.labelKey)}
+
+ );
+ })}
+
+ )}
+
+ {/* Pager — dots, arrows, counter (only when multiple notices) */}
+ {total > 1 && (
+
+
+
+
+ {Array.from({ length: total }, (_, i) => (
+
+
+
+ {t('system_notice.pager.counter')
+ .replace('{current}', String(currentPage + 1))
+ .replace('{total}', String(total))}
+
+
+ )}
+
+ {/* CTA + dismiss link */}
+
+ {ctaLabel ? (
+
+ {ctaLabel}
+
+ ) : (
+
+ {t('common.ok')}
+
+ )}
+ {notice.dismissible && ctaLabel && (
+
+ Not now
+
+ )}
+
+
+
+ );
+}
+
+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(null);
+ // Keep a ref to the current notice id so dismiss/CTA handlers see the latest value
+ const noticeIdRef = useRef(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(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 | 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 (
+
+ {/* Screen-reader page announcements */}
+
{pageAnnouncement}
+ {/* Backdrop */}
+
+ {/* Bottom sheet */}
+
{ 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 */}
+
+
+
+
+
+
+ );
+ }
+
+ // 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 (
+
+ {/* Screen-reader page announcements */}
+
{pageAnnouncement}
+
+
e.stopPropagation()}
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/SystemNotices/noticeActions.ts b/client/src/components/SystemNotices/noticeActions.ts
new file mode 100644
index 00000000..1369bdb9
--- /dev/null
+++ b/client/src/components/SystemNotices/noticeActions.ts
@@ -0,0 +1,21 @@
+import type { NavigateFunction } from 'react-router-dom';
+
+export interface NoticeActionContext {
+ navigate: NavigateFunction;
+}
+type NoticeActionHandler = (ctx: NoticeActionContext) => void | Promise;
+
+const actions = new Map();
+
+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);
+}
diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts
index 43853a42..4b838319 100644
--- a/client/src/i18n/translations/ar.ts
+++ b/client/src/i18n/translations/ar.ts
@@ -1969,6 +1969,38 @@ const ar: Record = {
'oauth.scope.geo:read.description': 'البحث عن مواقع وحل عناوين الخرائط والترميز الجغرافي العكسي للإحداثيات',
'oauth.scope.weather:read.label': 'توقعات الطقس',
'oauth.scope.weather:read.description': 'جلب توقعات الطقس لمواقع الرحلة وتواريخها',
+
+ // System notices
+ 'system_notice.welcome_v1.title': 'مرحبًا بك في TREK',
+ 'system_notice.welcome_v1.body': 'مخطط رحلاتك الشامل. أنشئ جداول السفر، وشارك رحلاتك مع الأصدقاء، وابقَ منظمًا — سواء كنت متصلاً بالإنترنت أم لا.',
+ 'system_notice.welcome_v1.cta_label': 'خطط لرحلة',
+ 'system_notice.welcome_v1.hero_alt': 'وجهة سفر خلابة مع واجهة تطبيق TREK',
+ 'system_notice.welcome_v1.highlight_plan': 'جداول رحلات يومية لكل سفرة',
+ 'system_notice.welcome_v1.highlight_share': 'تعاون مع شركاء السفر',
+ 'system_notice.welcome_v1.highlight_offline': 'يعمل بلا إنترنت على الهاتف',
+ 'system_notice.dev_test_modal.title': '[Dev] Test notice',
+ 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
+ 'system_notice.pager.prev': 'الإشعار السابق',
+ 'system_notice.pager.next': 'الإشعار التالي',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': 'الانتقال إلى الإشعار {n}',
+ 'system_notice.pager.position': 'الإشعار {current} من {total}',
+ // System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': 'تم نقل الصور في الإصدار 3.0',
+ 'system_notice.v3_photos.body': 'تمت إزالة تبويب **الصور** من مخطط الرحلة. صورك آمنة — لم يعدّل TREK مكتبتك على Immich أو Synology قطّ.\n\nتعيش الصور الآن في إضافة **Journey**. Journey اختيارية — إن لم تكن متاحة بعد، اطلب من المسؤول تفعيلها عبر Admin ← الإضافات.',
+ 'system_notice.v3_journey.title': 'تعرّف على Journey — مذكرة سفر',
+ 'system_notice.v3_journey.body': 'وثّق رحلاتك كقصص غنية بخطوط زمنية ومعارض صور وخرائط تفاعلية.',
+ 'system_notice.v3_journey.cta_label': 'فتح Journey',
+ 'system_notice.v3_journey.highlight_timeline': 'جدول زمني يومي ومعرض',
+ 'system_notice.v3_journey.highlight_photos': 'استيراد من Immich أو Synology',
+ 'system_notice.v3_journey.highlight_share': 'مشاركة علنية — دون تسجيل دخول',
+ 'system_notice.v3_journey.highlight_export': 'تصدير كألبوم صور PDF',
+ 'system_notice.v3_features.title': 'مزيد من مميزات 3.0',
+ 'system_notice.v3_features.body': 'بعض الجديد الآخر الجدير بالمعرفة في هذا الإصدار.',
+ 'system_notice.v3_features.highlight_dashboard': 'إعادة تصميم لوحة التحكم mobile-first',
+ 'system_notice.v3_features.highlight_offline': 'وضع لا اتصال كامل كتطبيق PWA',
+ 'system_notice.v3_features.highlight_search': 'إكمال تلقائي في الوقت الفعلي',
+ 'system_notice.v3_features.highlight_import': 'استيراد أماكن من ملفات KMZ/KML',
}
export default ar
diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts
index 45bc26dd..546dbfab 100644
--- a/client/src/i18n/translations/br.ts
+++ b/client/src/i18n/translations/br.ts
@@ -2172,6 +2172,38 @@ const br: Record = {
'oauth.scope.geo:read.description': 'Pesquisar locais, resolver URLs de mapa e geocodificar coordenadas',
'oauth.scope.weather:read.label': 'Previsão do tempo',
'oauth.scope.weather:read.description': 'Obter previsão do tempo para locais e datas da viagem',
+
+ // System notices
+ 'system_notice.welcome_v1.title': 'Bem-vindo ao TREK',
+ 'system_notice.welcome_v1.body': 'Seu planejador de viagens tudo-em-um. Crie roteiros, compartilhe viagens com amigos e fique organizado — online ou offline.',
+ 'system_notice.welcome_v1.cta_label': 'Planejar uma viagem',
+ 'system_notice.welcome_v1.hero_alt': 'Destino de viagem pitoresco com a interface do TREK',
+ 'system_notice.welcome_v1.highlight_plan': 'Roteiros dia a dia para qualquer viagem',
+ 'system_notice.welcome_v1.highlight_share': 'Colabore com seus companheiros de viagem',
+ 'system_notice.welcome_v1.highlight_offline': 'Funciona offline no celular',
+ 'system_notice.dev_test_modal.title': '[Dev] Test notice',
+ 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
+ 'system_notice.pager.prev': 'Aviso anterior',
+ 'system_notice.pager.next': 'Próximo aviso',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': 'Ir para o aviso {n}',
+ 'system_notice.pager.position': 'Aviso {current} de {total}',
+ // System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': 'Fotos foram movidas na versão 3.0',
+ 'system_notice.v3_photos.body': '**Fotos** no Planejador de Viagens foram removidas. Suas fotos estão seguras — o TREK nunca modificou sua biblioteca Immich ou Synology.\n\nAs fotos agora vivem no addon **Journey**. Journey é opcional — se ainda não estiver disponível, peça ao seu admin para ativá-lo em Admin → Addons.',
+ 'system_notice.v3_journey.title': 'Conheça o Journey — diário de viagem',
+ 'system_notice.v3_journey.body': 'Documente suas viagens como histórias ricas com cronologias, galerias de fotos e mapas interativos.',
+ 'system_notice.v3_journey.cta_label': 'Abrir Journey',
+ 'system_notice.v3_journey.highlight_timeline': 'Linha do tempo e galeria diária',
+ 'system_notice.v3_journey.highlight_photos': 'Importar do Immich ou Synology',
+ 'system_notice.v3_journey.highlight_share': 'Compartilhar publicamente — sem login',
+ 'system_notice.v3_journey.highlight_export': 'Exportar como álbum de fotos PDF',
+ 'system_notice.v3_features.title': 'Mais destaques na versão 3.0',
+ 'system_notice.v3_features.body': 'Algumas outras novidades que vale a pena conhecer nesta versão.',
+ 'system_notice.v3_features.highlight_dashboard': 'Redesign do painel mobile-first',
+ 'system_notice.v3_features.highlight_offline': 'Modo offline completo como PWA',
+ 'system_notice.v3_features.highlight_search': 'Autocompleção de lugares em tempo real',
+ 'system_notice.v3_features.highlight_import': 'Importar lugares de arquivos KMZ/KML',
}
export default br
diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts
index 5bd513ad..3e5ee16f 100644
--- a/client/src/i18n/translations/cs.ts
+++ b/client/src/i18n/translations/cs.ts
@@ -2176,6 +2176,38 @@ const cs: Record = {
'oauth.scope.geo:read.description': 'Vyhledávat místa, řešit URL map a zpětně geokódovat souřadnice',
'oauth.scope.weather:read.label': 'Předpovědi počasí',
'oauth.scope.weather:read.description': 'Získávat předpovědi počasí pro místa a data výletu',
+
+ // System notices
+ 'system_notice.welcome_v1.title': 'Vítejte v TREK',
+ 'system_notice.welcome_v1.body': 'Váš kompletní plánovač cest. Vytvářejte itineráře, sdílejte výlety s přáteli a zůstaňte organizovaní — online i offline.',
+ 'system_notice.welcome_v1.cta_label': 'Naplánovat cestu',
+ 'system_notice.welcome_v1.hero_alt': 'Malebné cestovní místo s rozhraním TREK',
+ 'system_notice.welcome_v1.highlight_plan': 'Denní itineráře pro každou cestu',
+ 'system_notice.welcome_v1.highlight_share': 'Spolupráce s cestovními partnery',
+ 'system_notice.welcome_v1.highlight_offline': 'Funguje offline na mobilu',
+ 'system_notice.dev_test_modal.title': '[Dev] Test notice',
+ 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
+ 'system_notice.pager.prev': 'Předchozí oznámení',
+ 'system_notice.pager.next': 'Další oznámení',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': 'Přejít na oznámení {n}',
+ 'system_notice.pager.position': 'Oznámení {current} z {total}',
+ // System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': 'Fotografie přesunuty ve verzi 3.0',
+ 'system_notice.v3_photos.body': '**Fotografie** v Plánovacím nástroji byly odebrány. Vaše fotografie jsou v bezpečí — TREK nikdy neupravoval vaši knihovnu Immich nebo Synology.\n\nFotografie jsou nyní dostupné v doplňku **Journey**. Journey je volitelný — pokud ještě není k dispozici, požádejte svého správce, aby ho aktivoval v Admin → Doplňky.',
+ 'system_notice.v3_journey.title': 'Poznejte Journey — cest. denník',
+ 'system_notice.v3_journey.body': 'Dokumentujte své cesty jako bohaté příběhy s časovnicemi, galeriemi fotek a interaktivními mapami.',
+ 'system_notice.v3_journey.cta_label': 'Otevřít Journey',
+ 'system_notice.v3_journey.highlight_timeline': 'Denní časovnice a galerie',
+ 'system_notice.v3_journey.highlight_photos': 'Import z Immich nebo Synology',
+ 'system_notice.v3_journey.highlight_share': 'Sdílet veřejně — bez přihlašování',
+ 'system_notice.v3_journey.highlight_export': 'Export jako PDF fotokniha',
+ 'system_notice.v3_features.title': 'Další novinky ve verzi 3.0',
+ 'system_notice.v3_features.body': 'Několik dalších změn, které stojí za pozornost.',
+ 'system_notice.v3_features.highlight_dashboard': 'Předesign dashboardu mobile-first',
+ 'system_notice.v3_features.highlight_offline': 'Plný offline režim jako PWA',
+ 'system_notice.v3_features.highlight_search': 'Autodoplňování vyhledávání míst',
+ 'system_notice.v3_features.highlight_import': 'Import míst ze souborů KMZ/KML',
}
export default cs
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index f3901bdc..079a2b93 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -2176,6 +2176,38 @@ const de: Record = {
'oauth.scope.geo:read.description': 'Orte suchen, Karten-URLs auflösen und Koordinaten rückwärts geokodieren',
'oauth.scope.weather:read.label': 'Wettervorhersagen',
'oauth.scope.weather:read.description': 'Wettervorhersagen für Reiseorte und -daten abrufen',
+
+ // System notices
+ 'system_notice.welcome_v1.title': 'Willkommen bei TREK',
+ 'system_notice.welcome_v1.body': 'Dein All-in-one-Reiseplaner. Erstelle Reisepläne, teile sie mit Freunden und bleib organisiert – online und offline.',
+ 'system_notice.welcome_v1.cta_label': 'Reise planen',
+ 'system_notice.welcome_v1.hero_alt': 'Malerisches Reiseziel mit TREK-Planungs-UI',
+ 'system_notice.welcome_v1.highlight_plan': 'Tagesweise Reisepläne für jede Reise',
+ 'system_notice.welcome_v1.highlight_share': 'Gemeinsam mit Reisepartnern planen',
+ 'system_notice.welcome_v1.highlight_offline': 'Funktioniert offline auf dem Handy',
+ 'system_notice.dev_test_modal.title': '[Dev] Test notice',
+ 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
+ 'system_notice.pager.prev': 'Vorherige Meldung',
+ 'system_notice.pager.next': 'Nächste Meldung',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': 'Zu Meldung {n}',
+ 'system_notice.pager.position': 'Meldung {current} von {total}',
+ // System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': 'Fotos wurden in 3.0 verschoben',
+ 'system_notice.v3_photos.body': '**Fotos** im Trip-Planer wurden entfernt. Deine Fotos sind sicher — TREK hat deine Immich- oder Synology-Bibliothek nie verändert.\n\nFotos befinden sich jetzt im **Journey**-Addon. Journey ist optional — falls es noch nicht verfügbar ist, bitte deinen Admin, es unter Admin → Addons zu aktivieren.',
+ 'system_notice.v3_journey.title': 'Neu: Journey — dein Reisetagebuch',
+ 'system_notice.v3_journey.body': 'Dokumentiere deine Reisen als lebendige Geschichten mit Zeitachsen, Fotogalerien und interaktiven Karten.',
+ 'system_notice.v3_journey.cta_label': 'Journey öffnen',
+ 'system_notice.v3_journey.highlight_timeline': 'Zeitleiste und Galerie',
+ 'system_notice.v3_journey.highlight_photos': 'Import von Immich oder Synology',
+ 'system_notice.v3_journey.highlight_share': 'Öffentlich teilen — kein Login nötig',
+ 'system_notice.v3_journey.highlight_export': 'Als PDF-Fotobuch exportieren',
+ 'system_notice.v3_features.title': 'Weitere Highlights in 3.0',
+ 'system_notice.v3_features.body': 'Ein paar weitere Neuerungen in diesem Release.',
+ 'system_notice.v3_features.highlight_dashboard': 'Mobile-first Dashboard-Redesign',
+ 'system_notice.v3_features.highlight_offline': 'Vollständiger Offline-Modus als PWA',
+ 'system_notice.v3_features.highlight_search': 'Echtzeit-Autovervollständigung für Orte',
+ 'system_notice.v3_features.highlight_import': 'Orte aus KMZ/KML-Dateien importieren',
}
export default de
diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts
index 521c85c8..11c0171f 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -2212,6 +2212,39 @@ const en: Record = {
'oauth.scope.geo:read.description': 'Search locations, resolve map URLs, and reverse geocode coordinates',
'oauth.scope.weather:read.label': 'Weather forecasts',
'oauth.scope.weather:read.description': 'Fetch weather forecasts for trip locations and dates',
+
+ // System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': 'Photos have moved in 3.0',
+ 'system_notice.v3_photos.body': '**Photos** in the Trip Planner have been removed. Your photos are safe — TREK never modified your Immich or Synology library.\n\nPhotos now live in the **Journey** addon. Journey is optional — if it is not yet available, ask your admin to enable it under Admin → Addons.',
+ 'system_notice.v3_journey.title': 'Meet Journey — travel journal',
+ 'system_notice.v3_journey.body': 'Document your trips as rich travel stories with timelines, photo galleries, and interactive maps.',
+ 'system_notice.v3_journey.cta_label': 'Open Journey',
+ 'system_notice.v3_journey.highlight_timeline': 'Day-by-day timeline & gallery',
+ 'system_notice.v3_journey.highlight_photos': 'Import from Immich or Synology',
+ 'system_notice.v3_journey.highlight_share': 'Share publicly — no login needed',
+ 'system_notice.v3_journey.highlight_export': 'Export as a PDF photo book',
+ 'system_notice.v3_features.title': 'More highlights in 3.0',
+ 'system_notice.v3_features.body': 'A few more things worth knowing about this release.',
+ 'system_notice.v3_features.highlight_dashboard': 'Mobile-first dashboard redesign',
+ 'system_notice.v3_features.highlight_offline': 'Full offline mode as a PWA',
+ 'system_notice.v3_features.highlight_search': 'Real-time place search autocomplete',
+ 'system_notice.v3_features.highlight_import': 'Import places from KMZ/KML files',
+
+ // System notices — onboarding
+ 'system_notice.welcome_v1.title': 'Welcome to TREK',
+ 'system_notice.welcome_v1.body': 'Your all-in-one travel planner. Build itineraries, share trips with friends, and stay organized — online or offline.',
+ 'system_notice.welcome_v1.cta_label': 'Plan a trip',
+ 'system_notice.welcome_v1.hero_alt': 'A scenic travel destination with TREK planning UI overlay',
+ 'system_notice.welcome_v1.highlight_plan': 'Day-by-day itineraries for any trip',
+ 'system_notice.welcome_v1.highlight_share': 'Collaborate with travel partners',
+ 'system_notice.welcome_v1.highlight_offline': 'Works offline on mobile',
+ 'system_notice.dev_test_modal.title': '[Dev] Test notice',
+ 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
+ 'system_notice.pager.prev': 'Previous notice',
+ 'system_notice.pager.next': 'Next notice',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': 'Go to notice {n}',
+ 'system_notice.pager.position': 'Notice {current} of {total}',
}
export default en
diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts
index 799c2b56..8e02694b 100644
--- a/client/src/i18n/translations/es.ts
+++ b/client/src/i18n/translations/es.ts
@@ -2178,6 +2178,38 @@ const es: Record = {
'oauth.scope.geo:read.description': 'Buscar lugares, resolver URLs de mapa y geocodificar coordenadas',
'oauth.scope.weather:read.label': 'Previsiones meteorológicas',
'oauth.scope.weather:read.description': 'Obtener previsiones meteorológicas para lugares y fechas del viaje',
+
+ // System notices
+ 'system_notice.welcome_v1.title': 'Bienvenido a TREK',
+ 'system_notice.welcome_v1.body': 'Tu planificador de viajes todo en uno. Crea itinerarios, comparte viajes con amigos y mantente organizado, online o sin conexión.',
+ 'system_notice.welcome_v1.cta_label': 'Planificar un viaje',
+ 'system_notice.welcome_v1.hero_alt': 'Destino de viaje pintoresco con la interfaz de TREK',
+ 'system_notice.welcome_v1.highlight_plan': 'Itinerarios día a día para cualquier viaje',
+ 'system_notice.welcome_v1.highlight_share': 'Colabora con tus compañeros de viaje',
+ 'system_notice.welcome_v1.highlight_offline': 'Funciona sin conexión en móvil',
+ 'system_notice.dev_test_modal.title': '[Dev] Test notice',
+ 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
+ 'system_notice.pager.prev': 'Aviso anterior',
+ 'system_notice.pager.next': 'Siguiente aviso',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': 'Ir al aviso {n}',
+ 'system_notice.pager.position': 'Aviso {current} de {total}',
+ // System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': 'Las fotos se han movido en 3.0',
+ 'system_notice.v3_photos.body': '**Fotos** en el Planificador de Viajes han sido eliminadas. Tus fotos están a salvo — TREK nunca modificó tu biblioteca de Immich o Synology.\n\nLas fotos ahora viven en el addon **Journey**. Journey es opcional — si aún no está disponible, pide a tu admin que lo active en Admin → Complementos.',
+ 'system_notice.v3_journey.title': 'Conoce Journey — diario de viaje',
+ 'system_notice.v3_journey.body': 'Documenta tus viajes como historias enriquecidas con cronologías, galerías de fotos y mapas interactivos.',
+ 'system_notice.v3_journey.cta_label': 'Abrir Journey',
+ 'system_notice.v3_journey.highlight_timeline': 'Cronología y galería por día',
+ 'system_notice.v3_journey.highlight_photos': 'Importar desde Immich o Synology',
+ 'system_notice.v3_journey.highlight_share': 'Compartir públicamente — sin inicio de sesión',
+ 'system_notice.v3_journey.highlight_export': 'Exportar como libro de fotos PDF',
+ 'system_notice.v3_features.title': 'Más novedades en 3.0',
+ 'system_notice.v3_features.body': 'Otras cosas que vale la pena conocer de esta versión.',
+ 'system_notice.v3_features.highlight_dashboard': 'Rediseño del panel mobile-first',
+ 'system_notice.v3_features.highlight_offline': 'Modo sin conexión completo como PWA',
+ 'system_notice.v3_features.highlight_search': 'Autocompletado de lugares en tiempo real',
+ 'system_notice.v3_features.highlight_import': 'Importar lugares desde archivos KMZ/KML',
}
export default es
diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts
index effdaa61..8c2bd02d 100644
--- a/client/src/i18n/translations/fr.ts
+++ b/client/src/i18n/translations/fr.ts
@@ -2172,6 +2172,38 @@ const fr: Record = {
'oauth.scope.geo:read.description': 'Chercher des lieux, résoudre des URL cartographiques et géocoder des coordonnées',
'oauth.scope.weather:read.label': 'Prévisions météo',
'oauth.scope.weather:read.description': 'Obtenir les prévisions météo pour les lieux et dates de voyage',
+
+ // System notices
+ 'system_notice.welcome_v1.title': 'Bienvenue sur TREK',
+ 'system_notice.welcome_v1.body': 'Votre planificateur de voyage tout-en-un. Créez des itinéraires, partagez vos voyages et restez organisé — en ligne ou hors ligne.',
+ 'system_notice.welcome_v1.cta_label': 'Planifier un voyage',
+ 'system_notice.welcome_v1.hero_alt': 'Destination de voyage pittoresque avec l\'interface TREK',
+ 'system_notice.welcome_v1.highlight_plan': 'Itinéraires jour par jour',
+ 'system_notice.welcome_v1.highlight_share': 'Collaborez avec vos partenaires',
+ 'system_notice.welcome_v1.highlight_offline': 'Fonctionne hors ligne sur mobile',
+ 'system_notice.dev_test_modal.title': '[Dev] Test notice',
+ 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
+ 'system_notice.pager.prev': 'Avis précédent',
+ 'system_notice.pager.next': 'Avis suivant',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': "Aller à l'avis {n}",
+ 'system_notice.pager.position': 'Avis {current} sur {total}',
+ // System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': 'Les photos ont bougé dans 3.0',
+ 'system_notice.v3_photos.body': "**Photos** dans le planificateur ont été supprimées. Tes photos sont en sécurité — TREK n'a jamais modifié ta bibliothèque Immich ou Synology.\n\nLes photos vivent désormais dans l'addon **Journey**. Journey est optionnel — s'il n'est pas encore disponible, demande à ton admin de l'activer dans Admin → Modules.",
+ 'system_notice.v3_journey.title': 'Découvrez Journey — journal de voyage',
+ 'system_notice.v3_journey.body': 'Documente tes voyages sous forme de récits enrichis avec chronologies, galeries photos et cartes interactives.',
+ 'system_notice.v3_journey.cta_label': 'Ouvrir Journey',
+ 'system_notice.v3_journey.highlight_timeline': 'Chronologie et galerie par jour',
+ 'system_notice.v3_journey.highlight_photos': 'Import depuis Immich ou Synology',
+ 'system_notice.v3_journey.highlight_share': 'Partage public — sans connexion requise',
+ 'system_notice.v3_journey.highlight_export': 'Export en livre photo PDF',
+ 'system_notice.v3_features.title': 'Plus de nouveautés en 3.0',
+ 'system_notice.v3_features.body': 'Quelques autres choses à savoir sur cette version.',
+ 'system_notice.v3_features.highlight_dashboard': 'Tableau de bord repensé mobile-first',
+ 'system_notice.v3_features.highlight_offline': 'Mode hors ligne complet en PWA',
+ 'system_notice.v3_features.highlight_search': 'Autocomplétion des lieux en temps réel',
+ 'system_notice.v3_features.highlight_import': 'Importer des lieux depuis KMZ/KML',
}
export default fr
diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts
index 7fab6db9..b92d15d4 100644
--- a/client/src/i18n/translations/hu.ts
+++ b/client/src/i18n/translations/hu.ts
@@ -2173,6 +2173,38 @@ const hu: Record = {
'oauth.scope.geo:read.description': 'Helyek keresése, térkép URL-ek feloldása és koordináták fordított geokódolása',
'oauth.scope.weather:read.label': 'Időjárás-előrejelzések',
'oauth.scope.weather:read.description': 'Időjárás-előrejelzések lekérése az utazási helyszínekre és dátumokra',
+
+ // System notices
+ 'system_notice.welcome_v1.title': 'Üdvözöl a TREK',
+ 'system_notice.welcome_v1.body': 'Az összes az egyben utazástervező. Készítsen útvonalakat, ossza meg az utakat barátaival, és maradjon szervezett — online és offline.',
+ 'system_notice.welcome_v1.cta_label': 'Utazás tervezése',
+ 'system_notice.welcome_v1.hero_alt': 'Festői úticél TREK tervező felülettel',
+ 'system_notice.welcome_v1.highlight_plan': 'Napi útvonalak minden utazáshoz',
+ 'system_notice.welcome_v1.highlight_share': 'Együttműködés utazótársakkal',
+ 'system_notice.welcome_v1.highlight_offline': 'Mobilon offline is működik',
+ 'system_notice.dev_test_modal.title': '[Dev] Test notice',
+ 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
+ 'system_notice.pager.prev': 'Előző értesítés',
+ 'system_notice.pager.next': 'Következő értesítés',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': '{n}. értesítésre ugrás',
+ 'system_notice.pager.position': '{current}/{total}. értesítés',
+ // System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': 'A fotók helye megváltozott 3.0-ban',
+ 'system_notice.v3_photos.body': 'Az útiterv-tervező **Fényképek** lapja eltávolításra került. Fényképeid biztonságban vannak — TREK soha nem módosította Immich vagy Synology könyvtáradat.\n\nA fényképek mostantól a **Journey** bővítményben élnek. A Journey opcionális — ha még nem elérhető, kérd meg a rendszergazdát, hogy engedélyezze Admin → Bővítmények alatt.',
+ 'system_notice.v3_journey.title': 'Ismerje meg a Journey-t — útinnapló',
+ 'system_notice.v3_journey.body': 'Dokumentáld utazazsaid gazdag történetekként idővonalakkal, fotgáriákkal és interaktív térképekkel.',
+ 'system_notice.v3_journey.cta_label': 'Journey megnyitása',
+ 'system_notice.v3_journey.highlight_timeline': 'Napi idővonal és galéria',
+ 'system_notice.v3_journey.highlight_photos': 'Import Immich-ből vagy Synology-ból',
+ 'system_notice.v3_journey.highlight_share': 'Nyilvános megosztás — bejelentkezés nélkül',
+ 'system_notice.v3_journey.highlight_export': 'Exportálás PDF fotkönyvként',
+ 'system_notice.v3_features.title': 'További újdonságok a 3.0-ban',
+ 'system_notice.v3_features.body': 'Néhány további dolog, amit érdemes tudni erről a kiadásról.',
+ 'system_notice.v3_features.highlight_dashboard': 'Mobile-first irmütébla újratervezve',
+ 'system_notice.v3_features.highlight_offline': 'Teljes offline mód PWA-ként',
+ 'system_notice.v3_features.highlight_search': 'Valós idejű helykeresés-kiegészítés',
+ 'system_notice.v3_features.highlight_import': 'Helyek importálása KMZ/KML fájlokból',
}
export default hu
diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts
index 0c602de9..65573dc1 100644
--- a/client/src/i18n/translations/id.ts
+++ b/client/src/i18n/translations/id.ts
@@ -2214,6 +2214,38 @@ const id: Record = {
'oauth.scope.weather:read.description': 'Ambil prakiraan cuaca untuk lokasi dan tanggal perjalanan',
+
+ // System notices
+ 'system_notice.welcome_v1.title': 'Selamat datang di TREK',
+ 'system_notice.welcome_v1.body': 'Perencana perjalanan lengkap Anda. Buat itinerari, bagikan perjalanan dengan teman, dan tetap terorganisir — online maupun offline.',
+ 'system_notice.welcome_v1.cta_label': 'Rencanakan perjalanan',
+ 'system_notice.welcome_v1.hero_alt': 'Destinasi wisata indah dengan antarmuka TREK',
+ 'system_notice.welcome_v1.highlight_plan': 'Itinerari harian untuk setiap perjalanan',
+ 'system_notice.welcome_v1.highlight_share': 'Berkolaborasi dengan teman perjalanan',
+ 'system_notice.welcome_v1.highlight_offline': 'Bekerja offline di ponsel',
+ 'system_notice.dev_test_modal.title': '[Dev] Test notice',
+ 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
+ 'system_notice.pager.prev': 'Pemberitahuan sebelumnya',
+ 'system_notice.pager.next': 'Pemberitahuan berikutnya',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': 'Pergi ke pemberitahuan {n}',
+ 'system_notice.pager.position': 'Pemberitahuan {current} dari {total}',
+ // System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': 'Foto dipindahkan di 3.0',
+ 'system_notice.v3_photos.body': '**Foto** di Perencana Perjalanan telah dihapus. Foto Anda aman — TREK tidak pernah mengubah perpustakaan Immich atau Synology Anda.\n\nFoto kini ada di addon **Journey**. Journey bersifat opsional — jika belum tersedia, minta admin untuk mengaktifkannya di Admin → Addon.',
+ 'system_notice.v3_journey.title': 'Kenali Journey — jurnal perjalanan',
+ 'system_notice.v3_journey.body': 'Dokumentasikan perjalanan Anda sebagai cerita hidup dengan linimasa, galeri foto, dan peta interaktif.',
+ 'system_notice.v3_journey.cta_label': 'Buka Journey',
+ 'system_notice.v3_journey.highlight_timeline': 'Linimasa & galeri',
+ 'system_notice.v3_journey.highlight_photos': 'Impor dari Immich atau Synology',
+ 'system_notice.v3_journey.highlight_share': 'Bagikan secara publik — tanpa login',
+ 'system_notice.v3_journey.highlight_export': 'Ekspor sebagai buku foto PDF',
+ 'system_notice.v3_features.title': 'Sorotan lain di 3.0',
+ 'system_notice.v3_features.body': 'Beberapa pembaruan lain dalam rilis ini.',
+ 'system_notice.v3_features.highlight_dashboard': 'Desain ulang dashboard mobile-first',
+ 'system_notice.v3_features.highlight_offline': 'Mode offline penuh sebagai PWA',
+ 'system_notice.v3_features.highlight_search': 'Pelengkapan otomatis tempat secara real-time',
+ 'system_notice.v3_features.highlight_import': 'Impor tempat dari file KMZ/KML',
};
export default id;
diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts
index 299a2fc7..b5e62023 100644
--- a/client/src/i18n/translations/it.ts
+++ b/client/src/i18n/translations/it.ts
@@ -2173,6 +2173,38 @@ const it: Record = {
'oauth.scope.geo:read.description': 'Cerca luoghi, risolvi URL mappa e geocodifica inversa coordinate',
'oauth.scope.weather:read.label': 'Previsioni meteo',
'oauth.scope.weather:read.description': 'Ottieni previsioni meteo per luoghi e date del viaggio',
+
+ // System notices
+ 'system_notice.welcome_v1.title': 'Benvenuto su TREK',
+ 'system_notice.welcome_v1.body': 'Il tuo pianificatore di viaggi tutto in uno. Crea itinerari, condividi viaggi con gli amici e rimani organizzato — online e offline.',
+ 'system_notice.welcome_v1.cta_label': 'Pianifica un viaggio',
+ 'system_notice.welcome_v1.hero_alt': 'Destinazione di viaggio panoramica con l\'interfaccia TREK',
+ 'system_notice.welcome_v1.highlight_plan': 'Itinerari giorno per giorno',
+ 'system_notice.welcome_v1.highlight_share': 'Collabora con i tuoi compagni di viaggio',
+ 'system_notice.welcome_v1.highlight_offline': 'Funziona offline su mobile',
+ 'system_notice.dev_test_modal.title': '[Dev] Test notice',
+ 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
+ 'system_notice.pager.prev': 'Avviso precedente',
+ 'system_notice.pager.next': 'Avviso successivo',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': "Vai all'avviso {n}",
+ 'system_notice.pager.position': 'Avviso {current} di {total}',
+ // System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': 'Le foto sono spostate nella 3.0',
+ 'system_notice.v3_photos.body': '**Foto** nel Pianificatore di Viaggio sono state rimosse. Le tue foto sono al sicuro — TREK non ha mai modificato la tua libreria Immich o Synology.\n\nLe foto ora si trovano nel componente aggiuntivo **Journey**. Journey è opzionale — se non è ancora disponibile, chiedi al tuo admin di abilitarlo in Admin → Addon.',
+ 'system_notice.v3_journey.title': 'Scopri Journey — diario di viaggio',
+ 'system_notice.v3_journey.body': 'Documenta i tuoi viaggi come storie ricche con cronologie, gallerie fotografiche e mappe interattive.',
+ 'system_notice.v3_journey.cta_label': 'Apri Journey',
+ 'system_notice.v3_journey.highlight_timeline': 'Cronologia e galleria giornaliera',
+ 'system_notice.v3_journey.highlight_photos': 'Importa da Immich o Synology',
+ 'system_notice.v3_journey.highlight_share': 'Condividi pubblicamente — senza accesso',
+ 'system_notice.v3_journey.highlight_export': 'Esporta come libro fotografico PDF',
+ 'system_notice.v3_features.title': 'Altri punti salienti nel 3.0',
+ 'system_notice.v3_features.body': 'Altre novità da conoscere in questa versione.',
+ 'system_notice.v3_features.highlight_dashboard': 'Dashboard ridisegnata mobile-first',
+ 'system_notice.v3_features.highlight_offline': 'Modalità offline completa come PWA',
+ 'system_notice.v3_features.highlight_search': 'Completamento automatico luoghi in tempo reale',
+ 'system_notice.v3_features.highlight_import': 'Importa luoghi da file KMZ/KML',
}
export default it
diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts
index 3cb2c582..a3c1f766 100644
--- a/client/src/i18n/translations/nl.ts
+++ b/client/src/i18n/translations/nl.ts
@@ -2172,6 +2172,38 @@ const nl: Record = {
'oauth.scope.geo:read.description': 'Locaties zoeken, kaart-URL\'s oplossen en coördinaten omgekeerd geocoderen',
'oauth.scope.weather:read.label': 'Weersverwachtingen',
'oauth.scope.weather:read.description': 'Weersverwachtingen ophalen voor reislocaties en -datums',
+
+ // System notices
+ 'system_notice.welcome_v1.title': 'Welkom bij TREK',
+ 'system_notice.welcome_v1.body': 'Jouw alles-in-één reisplanner. Maak reisschema\'s, deel trips met vrienden en blijf georganiseerd — online en offline.',
+ 'system_notice.welcome_v1.cta_label': 'Reis plannen',
+ 'system_notice.welcome_v1.hero_alt': 'Schilderachtige reisbestemming met TREK interface',
+ 'system_notice.welcome_v1.highlight_plan': 'Dag-voor-dag reisschema\'s',
+ 'system_notice.welcome_v1.highlight_share': 'Samenwerken met reisgezelschap',
+ 'system_notice.welcome_v1.highlight_offline': 'Werkt offline op mobiel',
+ 'system_notice.dev_test_modal.title': '[Dev] Test notice',
+ 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
+ 'system_notice.pager.prev': 'Vorige melding',
+ 'system_notice.pager.next': 'Volgende melding',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': 'Ga naar melding {n}',
+ 'system_notice.pager.position': 'Melding {current} van {total}',
+ // System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': "Foto's zijn verplaatst in 3.0",
+ 'system_notice.v3_photos.body': "**Foto's** in de Reisplanner zijn verwijderd. Je foto's zijn veilig — TREK heeft je Immich- of Synology-bibliotheek nooit gewijzigd.\n\nFoto's leven nu in de **Journey**-addon. Journey is optioneel — als het nog niet beschikbaar is, vraag je admin het te activeren via Admin → Addons.",
+ 'system_notice.v3_journey.title': 'Maak kennis met Journey — reisdagboek',
+ 'system_notice.v3_journey.body': 'Documenteer je reizen als rijke verhalen met tijdlijnen, fotogalerijen en interactieve kaarten.',
+ 'system_notice.v3_journey.cta_label': 'Journey openen',
+ 'system_notice.v3_journey.highlight_timeline': 'Dag-voor-dag tijdlijn & galerij',
+ 'system_notice.v3_journey.highlight_photos': 'Importeer van Immich of Synology',
+ 'system_notice.v3_journey.highlight_share': 'Openbaar delen — geen login vereist',
+ 'system_notice.v3_journey.highlight_export': 'Exporteer als PDF-fotoboek',
+ 'system_notice.v3_features.title': 'Meer hoogtepunten in 3.0',
+ 'system_notice.v3_features.body': 'Nog een paar dingen die het weten waard zijn in deze release.',
+ 'system_notice.v3_features.highlight_dashboard': 'Mobile-first dashboard herontwerp',
+ 'system_notice.v3_features.highlight_offline': 'Volledige offline modus als PWA',
+ 'system_notice.v3_features.highlight_search': 'Realtime plaatsautocomplete',
+ 'system_notice.v3_features.highlight_import': 'Importeer plaatsen uit KMZ/KML-bestanden',
}
export default nl
diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts
index 8befc14e..3b4d0c46 100644
--- a/client/src/i18n/translations/pl.ts
+++ b/client/src/i18n/translations/pl.ts
@@ -2165,6 +2165,38 @@ const pl: Record = {
'oauth.scope.geo:read.description': 'Wyszukuj miejsca, rozwiązuj adresy URL map i odwrotnie geokoduj współrzędne',
'oauth.scope.weather:read.label': 'Prognozy pogody',
'oauth.scope.weather:read.description': 'Pobieraj prognozy pogody dla miejsc i dat podróży',
+
+ // System notices
+ 'system_notice.welcome_v1.title': 'Witaj w TREK',
+ 'system_notice.welcome_v1.body': 'Twój kompleksowy planer podróży. Twórz trasy, dziel się wycieczkami ze znajomymi i bądź zorganizowany — online i offline.',
+ 'system_notice.welcome_v1.cta_label': 'Zaplanuj podróż',
+ 'system_notice.welcome_v1.hero_alt': 'Malownicze miejsce z interfejsem planowania TREK',
+ 'system_notice.welcome_v1.highlight_plan': 'Trasy dzień po dniu',
+ 'system_notice.welcome_v1.highlight_share': 'Współpraca z partnerami podróży',
+ 'system_notice.welcome_v1.highlight_offline': 'Działa offline na telefonie',
+ 'system_notice.dev_test_modal.title': '[Dev] Test notice',
+ 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
+ 'system_notice.pager.prev': 'Poprzednie powiadomienie',
+ 'system_notice.pager.next': 'Następne powiadomienie',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': 'Przejdź do powiadomienia {n}',
+ 'system_notice.pager.position': 'Powiadomienie {current} z {total}',
+ // System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': 'Zdjęcia zostały przeniesione w 3.0',
+ 'system_notice.v3_photos.body': '**Zdjęcia** w Planerze Podróży zostały usunięte. Twoje zdjęcia są bezpieczne — TREK nigdy nie modyfikował Twojej biblioteki Immich lub Synology.\n\nZdjęcia są teraz dostępne w dodatku **Journey**. Journey jest opcjonalny — jeśli jeszcze nie jest dostępny, poproś administratora o jego włączenie w Admin → Dodatki.',
+ 'system_notice.v3_journey.title': 'Poznaj Journey — dziennik podróży',
+ 'system_notice.v3_journey.body': 'Dokumentuj swoje podróże jako bogatrze opowieści z osami czasu, galeriami i mapami interaktywnymi.',
+ 'system_notice.v3_journey.cta_label': 'Otwórz Journey',
+ 'system_notice.v3_journey.highlight_timeline': 'Dzienna oś czasu i galeria',
+ 'system_notice.v3_journey.highlight_photos': 'Import z Immich lub Synology',
+ 'system_notice.v3_journey.highlight_share': 'Udostępnij publicznie — bez logowania',
+ 'system_notice.v3_journey.highlight_export': 'Eksportuj jako książkę fotograficzną PDF',
+ 'system_notice.v3_features.title': 'Więcej nowości w 3.0',
+ 'system_notice.v3_features.body': 'Kilka innych rzeczy wartych uwagi w tym wydaniu.',
+ 'system_notice.v3_features.highlight_dashboard': 'Przeprojektowany pulpit mobile-first',
+ 'system_notice.v3_features.highlight_offline': 'Pełny tryb offline jako PWA',
+ 'system_notice.v3_features.highlight_search': 'Autouzupełnianie wyszukiwania miejsc',
+ 'system_notice.v3_features.highlight_import': 'Import miejsc z plików KMZ/KML',
}
export default pl
diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts
index 61add90e..08d3b72d 100644
--- a/client/src/i18n/translations/ru.ts
+++ b/client/src/i18n/translations/ru.ts
@@ -2172,6 +2172,38 @@ const ru: Record = {
'oauth.scope.geo:read.description': 'Поиск мест, разрешение URL карт и обратное геокодирование координат',
'oauth.scope.weather:read.label': 'Прогнозы погоды',
'oauth.scope.weather:read.description': 'Получение прогнозов погоды для мест и дат поездки',
+
+ // System notices
+ 'system_notice.welcome_v1.title': 'Добро пожаловать в TREK',
+ 'system_notice.welcome_v1.body': 'Ваш универсальный планировщик путешествий. Создавайте маршруты, делитесь поездками с друзьями и оставайтесь организованными — онлайн и офлайн.',
+ 'system_notice.welcome_v1.cta_label': 'Спланировать поездку',
+ 'system_notice.welcome_v1.hero_alt': 'Живописное место назначения с интерфейсом TREK',
+ 'system_notice.welcome_v1.highlight_plan': 'Маршруты по дням',
+ 'system_notice.welcome_v1.highlight_share': 'Совместное планирование с партнёрами',
+ 'system_notice.welcome_v1.highlight_offline': 'Работает офлайн на мобильном',
+ 'system_notice.dev_test_modal.title': '[Dev] Test notice',
+ 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
+ 'system_notice.pager.prev': 'Предыдущее уведомление',
+ 'system_notice.pager.next': 'Следующее уведомление',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': 'Перейти к уведомлению {n}',
+ 'system_notice.pager.position': 'Уведомление {current} из {total}',
+ // System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': 'Фото перемещены в версии 3.0',
+ 'system_notice.v3_photos.body': 'Вкладка **Фото** в Планировщике путешествий удалена. Ваши фото в безопасности — TREK никогда не изменял вашу библиотеку Immich или Synology.\n\nФото теперь доступны в дополнении **Journey**. Journey необязателен — если он ещё недоступен, попросите администратора включить его в разделе Admin → Дополнения.',
+ 'system_notice.v3_journey.title': 'Знакомьтесь с Journey',
+ 'system_notice.v3_journey.body': 'Документируйте путешествия в виде рассказов с хронологиями, фотогалереями и интерактивными картами.',
+ 'system_notice.v3_journey.cta_label': 'Открыть Journey',
+ 'system_notice.v3_journey.highlight_timeline': 'Ежедневная хронология и галерея',
+ 'system_notice.v3_journey.highlight_photos': 'Импорт из Immich или Synology',
+ 'system_notice.v3_journey.highlight_share': 'Общий доступ — без входа',
+ 'system_notice.v3_journey.highlight_export': 'Экспорт в PDF-фотокнигу',
+ 'system_notice.v3_features.title': 'Ещё нового в версии 3.0',
+ 'system_notice.v3_features.body': 'Несколько других важных новшеств в этом релизе.',
+ 'system_notice.v3_features.highlight_dashboard': 'Переработанная панель в mobile-first стиле',
+ 'system_notice.v3_features.highlight_offline': 'Полный офлайн-режим как PWA',
+ 'system_notice.v3_features.highlight_search': 'Автодополнение поиска мест в реальном времени',
+ 'system_notice.v3_features.highlight_import': 'Импорт мест из KMZ/KML-файлов',
}
export default ru
diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts
index bf47a65e..36d4a2e8 100644
--- a/client/src/i18n/translations/zh.ts
+++ b/client/src/i18n/translations/zh.ts
@@ -2172,6 +2172,38 @@ const zh: Record = {
'oauth.scope.geo:read.description': '搜索位置、解析地图 URL 和反向地理编码坐标',
'oauth.scope.weather:read.label': '天气预报',
'oauth.scope.weather:read.description': '获取行程地点和日期的天气预报',
+
+ // System notices
+ 'system_notice.welcome_v1.title': '欢迎使用 TREK',
+ 'system_notice.welcome_v1.body': '您的全能旅行规划器。制定行程、与朋友分享旅行,随时保持井然有序——在线或离线均可。',
+ 'system_notice.welcome_v1.cta_label': '规划行程',
+ 'system_notice.welcome_v1.hero_alt': '风景优美的旅游目的地与 TREK 界面',
+ 'system_notice.welcome_v1.highlight_plan': '逐日行程规划',
+ 'system_notice.welcome_v1.highlight_share': '与旅行伙伴协作',
+ 'system_notice.welcome_v1.highlight_offline': '移动端支持离线使用',
+ 'system_notice.dev_test_modal.title': '[Dev] Test notice',
+ 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
+ 'system_notice.pager.prev': '上一条通知',
+ 'system_notice.pager.next': '下一条通知',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': '转到通知 {n}',
+ 'system_notice.pager.position': '通知 {current}/{total}',
+ // System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': '3.0 版照片已迁移',
+ 'system_notice.v3_photos.body': '行程规划器中的**照片**标签已被移除。您的照片安全无虑 — TREK 从未修改您的 Immich 或 Synology 相册。\n\n照片现在位于 **Journey** 插件中。Journey 是可选的 — 如果尚未启用,请联系管理员在 Admin → 插件 中开启。',
+ 'system_notice.v3_journey.title': '认识 Journey — 旅行日记',
+ 'system_notice.v3_journey.body': '将您的旅程记录为展示时间线、照片画廊和互动地图的丰富旅行故事。',
+ 'system_notice.v3_journey.cta_label': '打开 Journey',
+ 'system_notice.v3_journey.highlight_timeline': '每日时间线与画廊',
+ 'system_notice.v3_journey.highlight_photos': '从 Immich 或 Synology 导入',
+ 'system_notice.v3_journey.highlight_share': '公开分享 — 无需登录',
+ 'system_notice.v3_journey.highlight_export': '导出为 PDF 相册书',
+ 'system_notice.v3_features.title': '3.0 版更多亮点',
+ 'system_notice.v3_features.body': '此版本还有一些其他值得了解的新功能。',
+ 'system_notice.v3_features.highlight_dashboard': '移动优先仪表板重设计',
+ 'system_notice.v3_features.highlight_offline': '作为 PWA 的完整离线模式',
+ 'system_notice.v3_features.highlight_search': '地点搜索实时自动补全',
+ 'system_notice.v3_features.highlight_import': '从 KMZ/KML 文件导入地点',
}
export default zh
diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts
index 71dc5cde..d7fa4a02 100644
--- a/client/src/i18n/translations/zhTw.ts
+++ b/client/src/i18n/translations/zhTw.ts
@@ -2173,6 +2173,38 @@ const zhTw: Record = {
'oauth.scope.geo:read.description': '搜尋地點、解析地圖 URL 及反向地理編碼坐標',
'oauth.scope.weather:read.label': '天氣預報',
'oauth.scope.weather:read.description': '取得行程地點及日期的天氣預報',
+
+ // System notices
+ 'system_notice.welcome_v1.title': '歡迎使用 TREK',
+ 'system_notice.welcome_v1.body': '您的全方位旅遊規劃器。建立行程、與朋友分享旅遊,隨時保持條理分明——無論線上或離線皆可。',
+ 'system_notice.welcome_v1.cta_label': '規劃行程',
+ 'system_notice.welcome_v1.hero_alt': '風景優美的旅遊目的地與 TREK 介面',
+ 'system_notice.welcome_v1.highlight_plan': '逐日行程規劃',
+ 'system_notice.welcome_v1.highlight_share': '與旅伴協作規劃',
+ 'system_notice.welcome_v1.highlight_offline': '行動裝置支援離線使用',
+ 'system_notice.dev_test_modal.title': '[Dev] Test notice',
+ 'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
+ 'system_notice.pager.prev': '上一則通知',
+ 'system_notice.pager.next': '下一則通知',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': '前往通知 {n}',
+ 'system_notice.pager.position': '通知 {current}/{total}',
+ // System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': '3.0 版相片已移至',
+ 'system_notice.v3_photos.body': '行程規劃器中的**相片**標籤已被移除。您的相片安全— TREK 從未修改您的 Immich 或 Synology 相簿。\n\n相片現在位於 **Journey** 附加元件中。Journey 為選用 — 若尚未啟用,請聯絡管理員於 Admin → 附加元件 中開啟。',
+ 'system_notice.v3_journey.title': '認識 Journey — 旅行日記',
+ 'system_notice.v3_journey.body': '將您的旅程記錄為具有時間軸、相片畫庫與互動地圖的豐富旅行故事。',
+ 'system_notice.v3_journey.cta_label': '開啟 Journey',
+ 'system_notice.v3_journey.highlight_timeline': '每日時間軸與畫庫',
+ 'system_notice.v3_journey.highlight_photos': '從 Immich 或 Synology 匯入',
+ 'system_notice.v3_journey.highlight_share': '公開分享 — 無需登入',
+ 'system_notice.v3_journey.highlight_export': '匯出為 PDF 相簿书',
+ 'system_notice.v3_features.title': '3.0 版更多亮點',
+ 'system_notice.v3_features.body': '這個版本還有一些其他專項值得了解。',
+ 'system_notice.v3_features.highlight_dashboard': '行動先行儀表板重設計',
+ 'system_notice.v3_features.highlight_offline': '作為 PWA 的完整離線模式',
+ 'system_notice.v3_features.highlight_search': '地點搜尋即時自動補全',
+ 'system_notice.v3_features.highlight_import': '從 KMZ/KML 檔案匯入地點',
}
export default zhTw
\ No newline at end of file
diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx
index fcc253a2..96c8375f 100644
--- a/client/src/pages/DashboardPage.tsx
+++ b/client/src/pages/DashboardPage.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef } from 'react'
-import { useNavigate } from 'react-router-dom'
+import { useNavigate, useSearchParams } from 'react-router-dom'
import { tripsApi } from '../api/client'
import { tripRepo } from '../repo/tripRepo'
import { useAuthStore } from '../store/authStore'
@@ -689,6 +689,7 @@ export default function DashboardPage(): React.ReactElement {
}
const navigate = useNavigate()
+ const [searchParams, setSearchParams] = useSearchParams()
const toast = useToast()
const { t, locale } = useTranslation()
const { demoMode, user } = useAuthStore()
@@ -709,6 +710,13 @@ export default function DashboardPage(): React.ReactElement {
return () => { document.body.style.overflow = '' }
}, [showWidgetSettings])
+ useEffect(() => {
+ if (searchParams.get('create') === '1') {
+ setShowForm(true)
+ setSearchParams({}, { replace: true })
+ }
+ }, [searchParams])
+
useEffect(() => { loadTrips() }, [])
const loadTrips = async () => {
diff --git a/client/src/pages/Trips/noticeActions.ts b/client/src/pages/Trips/noticeActions.ts
new file mode 100644
index 00000000..b7cf5328
--- /dev/null
+++ b/client/src/pages/Trips/noticeActions.ts
@@ -0,0 +1,7 @@
+import { registerNoticeAction } from '../../components/SystemNotices/noticeActions.js';
+
+// Opens the new-trip creation modal on DashboardPage via URL param.
+// DashboardPage reads ?create=1 on mount and calls setShowForm(true).
+registerNoticeAction('open:trip-create', ({ navigate }) => {
+ navigate('/dashboard?create=1');
+});
diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts
index c88654e3..4a3f8a84 100644
--- a/client/src/store/authStore.ts
+++ b/client/src/store/authStore.ts
@@ -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()(
})
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()(
})
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()(
})
connect()
tripSyncManager.syncAll().catch(console.error)
+ useSystemNoticeStore.getState().fetch()
return data
} catch (err: unknown) {
const error = getApiErrorMessage(err, 'Registration failed')
diff --git a/client/src/store/systemNoticeStore.ts b/client/src/store/systemNoticeStore.ts
new file mode 100644
index 00000000..249b0b6b
--- /dev/null
+++ b/client/src/store/systemNoticeStore.ts
@@ -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;
+ 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;
+ dismiss: (id: string) => void;
+}
+
+export const useSystemNoticeStore = create()((set, get) => ({
+ notices: [],
+ loaded: false,
+ fetching: false,
+
+ async fetch() {
+ if (get().fetching || get().loaded) return;
+ set({ fetching: true });
+ try {
+ const res = await axios.get('/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);
+ });
+ },
+}));
diff --git a/docs/system-notices.md b/docs/system-notices.md
new file mode 100644
index 00000000..6c1961fe
--- /dev/null
+++ b/docs/system-notices.md
@@ -0,0 +1,754 @@
+# System Notices — Technical Documentation & Dev Guide
+
+System notices are server-evaluated, user-targeted messages shown in the TREK UI as modals, banners, or toasts. They are used for onboarding, upgrade announcements, breaking change warnings, and time-boxed campaigns. Every aspect — targeting, display, copy, and dismissal — is controlled from one place: the server-side registry.
+
+---
+
+## Table of Contents
+
+1. [Architecture overview](#1-architecture-overview)
+2. [Data flow](#2-data-flow)
+3. [Database schema](#3-database-schema)
+4. [The notice registry](#4-the-notice-registry)
+5. [Notice fields reference](#5-notice-fields-reference)
+6. [Condition system](#6-condition-system)
+7. [Display types](#7-display-types)
+8. [CTAs (call to action)](#8-ctas-call-to-action)
+9. [i18n — translation keys](#9-i18n--translation-keys)
+10. [Client store & dismissal](#10-client-store--dismissal)
+11. [Sorting & priority](#11-sorting--priority)
+12. [How-to recipes](#12-how-to-recipes)
+13. [Testing](#13-testing)
+14. [Rules & constraints](#14-rules--constraints)
+
+---
+
+## 1. Architecture overview
+
+```
+server/src/systemNotices/
+├── types.ts — TypeScript types (SystemNotice, NoticeCondition, …)
+├── registry.ts — Authoritative list of all notices (edit here to add/change/remove)
+├── conditions.ts — Condition evaluators + custom predicate registry
+└── service.ts — Queries DB, evaluates conditions, sorts, strips server-only fields
+
+server/src/routes/systemNotices.ts — REST endpoints
+
+client/src/store/systemNoticeStore.ts — Zustand store (fetch + optimistic dismiss)
+client/src/components/SystemNotices/
+├── SystemNoticeHost.tsx — Renders all three channels (modal / banner / toast)
+├── SystemNoticeModal.tsx — Modal renderer (pager, animations, keyboard nav)
+├── SystemNoticeBanner.tsx — Banner + toast renderers
+└── noticeActions.ts — Client-side action registry for action-kind CTAs
+
+client/src/pages/Trips/noticeActions.ts — Example domain action registration
+```
+
+There are **no database rows for notice definitions**. The registry is code-only. The database only stores which notices a user has dismissed.
+
+---
+
+## 2. Data flow
+
+```
+1. User authenticates
+ │
+ ▼
+2. authStore.loadUser() completes
+ │
+ ▼
+3. SystemNoticeHost mounts → calls useSystemNoticeStore.fetch()
+ │ (also triggered on cold page reload if store not yet loaded)
+ ▼
+4. GET /api/system-notices/active
+ │
+ ▼
+5. service.getActiveNoticesFor(userId)
+ ├── reads user row (login_count, first_seen_version, role)
+ ├── counts user trips
+ ├── reads user_notice_dismissals
+ ├── filters SYSTEM_NOTICES:
+ │ – not dismissed
+ │ – not expired (expiresAt)
+ │ – all conditions pass (AND logic)
+ ├── sorts by priority → severity → publishedAt (desc)
+ └── strips server-only fields (conditions, publishedAt, expiresAt, priority)
+ │
+ ▼
+6. Client receives SystemNoticeDTO[]
+ │
+ ▼
+7. SystemNoticeHost partitions by display type
+ ├── modal → ModalRenderer (multi-page pager, slide transitions)
+ ├── banner → BannerRenderer (sticky top bar, max 2)
+ └── toast → ToastRenderer (fires window.__addToast, auto-dismisses)
+ │
+ ▼
+8. User dismisses → POST /api/system-notices/:id/dismiss
+ ├── Server: INSERT OR IGNORE into user_notice_dismissals
+ └── Client: optimistic remove from store (retry once on failure)
+```
+
+---
+
+## 3. Database schema
+
+Added in **migration 101** (`server/src/db/migrations.ts`).
+
+### `users` columns (added by migration 101)
+
+| Column | Type | Default | Purpose |
+|---|---|---|---|
+| `first_seen_version` | `TEXT` | `'0.0.0'` | App version at account creation. Used by `existingUserBeforeVersion` condition. Backfilled users get `'0.0.0'`. |
+| `login_count` | `INTEGER` | `0` | Incremented on each successful login. Used by `firstLogin` condition. |
+
+### `user_notice_dismissals`
+
+| Column | Type | Notes |
+|---|---|---|
+| `user_id` | `INTEGER` | FK → `users.id` CASCADE DELETE |
+| `notice_id` | `TEXT` | Matches `SystemNotice.id` from registry |
+| `dismissed_at` | `INTEGER` | Unix ms timestamp |
+
+Primary key: `(user_id, notice_id)` — dismissals are idempotent.
+
+---
+
+## 4. The notice registry
+
+**`server/src/systemNotices/registry.ts`** is the single source of truth. Add, change, or retire notices here.
+
+```typescript
+export const SYSTEM_NOTICES: SystemNotice[] = [
+ {
+ id: 'my-notice', // ← globally unique, never reuse
+ display: 'modal',
+ severity: 'info',
+ titleKey: 'system_notice.my_notice.title',
+ bodyKey: 'system_notice.my_notice.body',
+ dismissible: true,
+ conditions: [{ kind: 'firstLogin' }],
+ publishedAt: '2026-05-01T00:00:00Z',
+ priority: 50,
+ },
+];
+```
+
+### The golden rule for IDs
+
+**Never remove or renumber an entry. Never reuse an ID.**
+
+Dismissals are stored in the database keyed by `id`. Removing an entry means dismissed users would see it again if you ever add a notice with the same ID. If a notice is no longer needed, add `expiresAt` to stop it from being shown — do not delete the entry.
+
+---
+
+## 5. Notice fields reference
+
+### Required fields
+
+| Field | Type | Description |
+|---|---|---|
+| `id` | `string` | Globally unique, stable identifier. Use kebab-case, descriptive, version-scoped when appropriate (`v3-photos`, `welcome-v1`). Max recommended length: 40 chars. |
+| `display` | `'modal' \| 'banner' \| 'toast'` | How the notice is rendered. See [§7 Display types](#7-display-types). |
+| `severity` | `'info' \| 'warn' \| 'critical'` | Affects colour scheme and accessibility role. `critical` notices cannot be toasts. |
+| `titleKey` | `string` | i18n key for the title. |
+| `bodyKey` | `string` | i18n key for the body. Markdown supported in modals; plain text only in banners/toasts. |
+| `dismissible` | `boolean` | If `false`, the X button and ESC key are hidden/blocked. Use only for `critical` notices that require action before proceeding. |
+| `conditions` | `NoticeCondition[]` | Empty array (`[]`) means always shown (same as `[{ kind: 'always' }]`). All conditions must pass (AND logic). |
+| `publishedAt` | `string` | ISO 8601 date. Used as a tiebreaker in sorting. Set to the deployment date. |
+
+### Optional fields
+
+| Field | Type | Description |
+|---|---|---|
+| `priority` | `number` | Higher number = shown first. Primary sort key. Default: `0`. |
+| `expiresAt` | `string` | ISO 8601 date. Notice is automatically hidden after this date. Preferred over deleting entries. |
+| `icon` | `string` | Lucide icon name (e.g. `'Sparkles'`, `'ImageOff'`). Shown in the modal's severity icon circle. Falls back to the severity default icon if absent or unrecognised. |
+| `bodyParams` | `Record` | Interpolation parameters for `bodyKey`. Values replace `{key}` placeholders in the translated string. **Never hardcode version numbers or dates directly in translation strings — use this instead.** |
+| `media` | `NoticeMedia` | Image to display in the modal. See below. |
+| `highlights` | `Array<{ labelKey: string; iconName?: string }>` | Bullet-point feature list rendered below the body in modals. Each entry is a translation key + optional Lucide icon name. |
+| `cta` | `NoticeCta` | Primary action button. See [§8 CTAs](#8-ctas-call-to-action). |
+
+### `NoticeMedia`
+
+```typescript
+interface NoticeMedia {
+ src: string; // URL or path
+ srcDark?: string; // Optional dark-mode variant
+ altKey: string; // i18n key for alt text
+ placement?: 'hero' | 'inline'; // default: 'hero' (full-width above body)
+ aspectRatio?: string; // CSS aspect-ratio value, default '16/9'
+}
+```
+
+### Character limits
+
+| Field | Modal | Banner | Toast |
+|---|---|---|---|
+| Title | ≤ 40 chars | ≤ 40 chars | ≤ 40 chars |
+| Body | ≤ 400 chars (markdown) | ≤ 140 chars (plain) | ≤ 80 chars (plain) |
+| CTA label | ≤ 20 chars, a verb | ≤ 20 chars | ≤ 20 chars |
+
+---
+
+## 6. Condition system
+
+Conditions are evaluated **server-side** on every `GET /api/system-notices/active` call. The client never sees conditions — only the filtered result.
+
+All conditions in `conditions[]` must pass (AND logic). To implement OR logic, create multiple notices with overlapping IDs is not possible — instead use a `custom` predicate with internal OR logic.
+
+### Built-in conditions
+
+#### `always`
+```typescript
+{ kind: 'always' }
+```
+Always passes. Equivalent to an empty `conditions` array.
+
+---
+
+#### `firstLogin`
+```typescript
+{ kind: 'firstLogin' }
+```
+Passes when `users.login_count <= 1`. The counter is incremented during login, so this fires on the first fetch after the very first login. Useful for onboarding notices.
+
+---
+
+#### `noTrips`
+```typescript
+{ kind: 'noTrips' }
+```
+Passes when the user has zero trips. Often combined with `firstLogin`.
+
+---
+
+#### `existingUserBeforeVersion`
+```typescript
+{ kind: 'existingUserBeforeVersion', version: '3.0.0' }
+```
+Passes when:
+- `users.first_seen_version < version` (user existed before this version)
+- AND the running app version `>= version` (the version has been deployed)
+
+Backfilled/legacy users have `first_seen_version = '0.0.0'` and always pass the first condition. Use this for upgrade announcements targeting users who were around before a breaking change.
+
+---
+
+#### `dateWindow`
+```typescript
+{ kind: 'dateWindow', startsAt: '2026-06-01T00:00:00Z', endsAt: '2026-07-01T00:00:00Z' }
+```
+Passes when the current server time is inside `[startsAt, endsAt]`. `endsAt` is optional (open-ended). Use for campaigns, maintenance banners, and time-limited promotions.
+
+---
+
+#### `role`
+```typescript
+{ kind: 'role', roles: ['admin'] }
+// or both roles:
+{ kind: 'role', roles: ['admin', 'user'] }
+```
+Passes when the user's role is in the given list.
+
+---
+
+#### `addonEnabled`
+```typescript
+{ kind: 'addonEnabled', addonId: 'journey' }
+```
+Passes when the named addon is enabled in admin settings. Addon IDs are the string values in `server/src/addons.ts` (`ADDON_IDS`). Use this to gate notices that promote features behind an addon.
+
+---
+
+#### `custom`
+```typescript
+{ kind: 'custom', id: 'my-predicate-id' }
+```
+Delegates evaluation to a predicate registered server-side with `registerPredicate`. This is the escape hatch for logic not covered by the built-in conditions.
+
+**Registering a custom predicate:**
+
+```typescript
+// server/src/systemNotices/conditions.ts exports registerPredicate
+import { registerPredicate } from '../systemNotices/conditions.js';
+
+registerPredicate('has-immich-configured', (ctx) => {
+ // ctx.user = { login_count, first_seen_version, role, noTrips }
+ // ctx.currentAppVersion = string
+ // ctx.now = Date
+ return someDbCheck(ctx.user);
+});
+```
+
+Register predicates at application startup before the first `getActiveNoticesFor` call.
+
+---
+
+### Combining conditions (AND)
+
+```typescript
+conditions: [
+ { kind: 'existingUserBeforeVersion', version: '3.0.0' },
+ { kind: 'addonEnabled', addonId: 'journey' },
+]
+// Only shows to pre-3.0 users AND only if the journey addon is enabled.
+```
+
+---
+
+## 7. Display types
+
+### `modal`
+
+Full-screen overlay with backdrop. On mobile: bottom sheet with drag-to-dismiss. On desktop: centered card.
+
+**Features:**
+- Markdown body (via `react-markdown` + `remark-gfm` + `rehype-sanitize`)
+- Optional hero or inline image
+- Optional highlights list (icon + label bullets)
+- Optional CTA button + "Not now" link
+- OK button when no CTA is defined
+- **Multi-page pager**: when multiple modal notices are active simultaneously, they are rendered as a paginated single modal with prev/next arrows, dot indicators, `N / M` counter, and keyboard arrow navigation
+- Slide transition between pages
+- ESC to dismiss all (if current notice is dismissible)
+- CTA and OK dismiss **all** active modal notices, not just the current page
+- "Not now" dismisses only the current page
+
+**Non-dismissible modals** (`dismissible: false`): X button, ESC key, and pager navigation are all disabled until the user acts on the CTA. Use only for `critical` severity.
+
+---
+
+### `banner`
+
+Sticky top bar below the navigation. Slides in with a translate-Y animation.
+
+**Constraints:**
+- Maximum 2 banners shown simultaneously (the 2 highest-priority active banners)
+- Plain text only (no markdown)
+- RTL-aware left-border accent
+- Reports its height via a CSS variable `--banner-stack-h` for layout reflow
+
+---
+
+### `toast`
+
+Fires the global `window.__addToast` toast system. Auto-dismisses after 6 s (`info`) or 9 s (`warn`). The notice is dismissed from the store after the toast expires.
+
+**Constraints:**
+- `critical` severity is not allowed as a toast — the renderer logs a warning and auto-dismisses it instead
+- Plain text only
+- No interaction (no CTA rendered via toast)
+
+---
+
+## 8. CTAs (call to action)
+
+A CTA renders as the primary blue button in modals and as an underline link in banners. There are two kinds.
+
+### `nav` — navigate to a route
+
+```typescript
+cta: {
+ kind: 'nav',
+ labelKey: 'system_notice.my_notice.cta_label',
+ href: '/journey',
+}
+```
+
+On click: navigates to `href` using React Router, then **dismisses all active modal notices** (or the current banner notice). The label is resolved through the i18n system.
+
+---
+
+### `action` — run a registered client-side handler
+
+```typescript
+cta: {
+ kind: 'action',
+ labelKey: 'system_notice.my_notice.cta_label',
+ actionId: 'open:trip-create',
+ dismissOnAction: true, // default true — set false to keep notice open after action
+}
+```
+
+On click: looks up `actionId` in the client-side action registry and calls the handler, then **dismisses all active modal notices**.
+
+**To add a new action:**
+
+1. Create (or extend) a `noticeActions.ts` file in the relevant feature directory:
+
+```typescript
+// client/src/pages/MyFeature/noticeActions.ts
+import { registerNoticeAction } from '../../components/SystemNotices/noticeActions.js';
+
+registerNoticeAction('open:my-feature', ({ navigate }) => {
+ navigate('/my-feature?from=notice');
+});
+```
+
+2. Import it as a side-effect in `client/src/App.tsx`:
+
+```typescript
+import './pages/MyFeature/noticeActions.js'
+```
+
+3. The registry integrity test (`server/tests/unit/systemNotices/registry.test.ts`) automatically scans all `noticeActions.ts` files and verifies that every `actionId` in the registry is registered. The test will fail if you add an `actionId` to the registry without registering it on the client.
+
+**Action handler signature:**
+
+```typescript
+(ctx: NoticeActionContext) => void | Promise
+
+interface NoticeActionContext {
+ navigate: NavigateFunction; // React Router navigate function
+}
+```
+
+### Dismiss behaviour summary
+
+| Trigger | What is dismissed |
+|---|---|
+| X button (modal) | All active modal notices |
+| ESC key | All active modal notices (if current is dismissible) |
+| CTA button | All active modal notices |
+| OK button (no CTA) | All active modal notices |
+| "Not now" link | Current page only |
+| Banner dismiss (X) | That banner only |
+| Backdrop click (modal) | Current page only |
+| Swipe down (mobile) | Current page only |
+| Toast expires | That toast only |
+
+---
+
+## 9. i18n — translation keys
+
+Every notice field that is user-visible (`titleKey`, `bodyKey`, CTA `labelKey`, highlight `labelKey`, media `altKey`) is an i18n key resolved through `useTranslation().t()`. The key string is what gets stored in the registry; the display value lives in the translation files.
+
+**Translation files location:** `client/src/i18n/translations/` (15 files: `en`, `de`, `fr`, `es`, `it`, `nl`, `pl`, `cs`, `hu`, `ru`, `zh`, `zhTw`, `ar`, `br`, `id`)
+
+### Key naming convention
+
+```
+system_notice..
+```
+
+Examples:
+```
+system_notice.welcome_v1.title
+system_notice.welcome_v1.body
+system_notice.welcome_v1.cta_label
+system_notice.welcome_v1.highlight_plan
+system_notice.welcome_v1.hero_alt
+```
+
+### Adding keys
+
+Add the English key to `client/src/i18n/translations/en.ts` first, then replicate to the other 14 files. Group related notice keys together with a comment:
+
+```typescript
+// System notices — my feature
+'system_notice.my_notice.title': 'My feature is here',
+'system_notice.my_notice.body': 'Here is what changed.',
+'system_notice.my_notice.cta_label': 'Explore',
+```
+
+### `bodyParams` interpolation
+
+For values that vary at runtime (version numbers, dates, counts), use `{placeholder}` syntax in the translation string and pass `bodyParams` in the registry entry:
+
+```typescript
+// In registry:
+bodyKey: 'system_notice.my_notice.body',
+bodyParams: { version: '3.1.0', date: '1 May 2026' },
+
+// In en.ts:
+'system_notice.my_notice.body': 'TREK {version} was released on {date}.',
+```
+
+**Never hardcode dynamic values directly in translation strings.** The interpolation runs client-side in `ModalRenderer` before rendering.
+
+### Multiline bodies (modals only)
+
+Use `\n\n` (escaped, not literal newlines) for paragraph breaks in modal body strings:
+
+```typescript
+'system_notice.my_notice.body': 'First paragraph.\n\nSecond paragraph.',
+```
+
+Literal newlines in single-quoted TypeScript strings cause a parse error.
+
+### Pager i18n keys
+
+The pager UI uses its own keys (already present in all 15 files):
+
+```
+system_notice.pager.prev → "Previous notice"
+system_notice.pager.next → "Next notice"
+system_notice.pager.counter → "{current} / {total}"
+system_notice.pager.goto → "Go to notice {n}"
+system_notice.pager.position → "Notice {current} of {total}" (aria-live)
+```
+
+---
+
+## 10. Client store & dismissal
+
+`client/src/store/systemNoticeStore.ts` (Zustand, no persistence).
+
+| Action | Behaviour |
+|---|---|
+| `fetch()` | `GET /api/system-notices/active`. Fails silently (non-critical). Sets `loaded = true` regardless. |
+| `dismiss(id)` | Optimistic: removes notice from store immediately. POSTs to `/api/system-notices/{id}/dismiss` in background with one retry on failure. |
+
+`SystemNoticeHost` triggers `fetch()` on mount if `loaded === false`. Auth store also triggers it after login, so on a fresh login the fetch happens exactly once.
+
+---
+
+## 11. Sorting & priority
+
+Notices are sorted before being sent to the client. The sort order is:
+
+1. **`priority`** (descending) — primary key. Higher number appears first.
+2. **`severity`** (descending) — tiebreaker: `critical` (2) > `warn` (1) > `info` (0).
+3. **`publishedAt`** (descending) — final tiebreaker: more recent notices first.
+
+This means `priority` always wins over severity. Assign priorities deliberately so the intended reading order is preserved when multiple notices are active simultaneously.
+
+Current priority allocations in the registry:
+
+| Range | Use |
+|---|---|
+| 100 | Onboarding / first-login |
+| 80–90 | Major version upgrade notices |
+| 50–70 | Feature announcements |
+| 10–40 | Campaigns, banners |
+| 0 (default) | Miscellaneous |
+
+---
+
+## 12. How-to recipes
+
+### Add a new modal notice
+
+1. **Registry** — add an entry to `SYSTEM_NOTICES` in `server/src/systemNotices/registry.ts`:
+
+```typescript
+{
+ id: 'my-feature-v2',
+ display: 'modal',
+ severity: 'info',
+ icon: 'Zap',
+ titleKey: 'system_notice.my_feature_v2.title',
+ bodyKey: 'system_notice.my_feature_v2.body',
+ highlights: [
+ { labelKey: 'system_notice.my_feature_v2.highlight_one', iconName: 'Check' },
+ ],
+ cta: {
+ kind: 'nav',
+ labelKey: 'system_notice.my_feature_v2.cta_label',
+ href: '/my-feature',
+ },
+ dismissible: true,
+ conditions: [{ kind: 'existingUserBeforeVersion', version: '2.0.0' }],
+ publishedAt: '2026-06-01T00:00:00Z',
+ priority: 60,
+},
+```
+
+2. **i18n** — add keys to `client/src/i18n/translations/en.ts` and the 14 other language files.
+
+3. **Test** — run `cd server && npx vitest run tests/unit/systemNotices/` to verify registry integrity.
+
+---
+
+### Add a notice with an action CTA
+
+1. Create the action handler in the relevant feature directory:
+
+```typescript
+// client/src/pages/MyFeature/noticeActions.ts
+import { registerNoticeAction } from '../../components/SystemNotices/noticeActions.js';
+
+registerNoticeAction('open:my-feature-dialog', ({ navigate }) => {
+ navigate('/my-feature?dialog=welcome');
+});
+```
+
+2. Import it in `client/src/App.tsx`:
+
+```typescript
+import './pages/MyFeature/noticeActions.js'
+```
+
+3. Reference the `actionId` in the registry:
+
+```typescript
+cta: {
+ kind: 'action',
+ labelKey: 'system_notice.my_notice.cta_label',
+ actionId: 'open:my-feature-dialog',
+},
+```
+
+The registry integrity test will catch any `actionId` that appears in the registry but lacks a `registerNoticeAction` call.
+
+---
+
+### Retire a notice (stop showing it)
+
+**Do not delete the entry.** Set `expiresAt`:
+
+```typescript
+{
+ id: 'old-campaign',
+ // ... all existing fields unchanged ...
+ expiresAt: '2026-07-01T00:00:00Z',
+}
+```
+
+After the expiry date the service filters it out automatically. The database row for dismissed users remains harmless.
+
+---
+
+### Show a notice only during a campaign window
+
+Combine `dateWindow` with any other targeting conditions:
+
+```typescript
+conditions: [
+ { kind: 'dateWindow', startsAt: '2026-06-15T00:00:00Z', endsAt: '2026-06-30T23:59:59Z' },
+ { kind: 'role', roles: ['admin'] },
+],
+```
+
+---
+
+### Show a notice only if an addon is enabled
+
+```typescript
+conditions: [
+ { kind: 'addonEnabled', addonId: 'journey' },
+],
+```
+
+Addon IDs are the string values in `server/src/addons.ts` → `ADDON_IDS`.
+
+---
+
+### Add a custom condition
+
+```typescript
+// server/src/startup.ts (or wherever your bootstrap code runs)
+import { registerPredicate } from './systemNotices/conditions.js';
+
+registerPredicate('has-no-profile-photo', (ctx) => {
+ const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(ctx.user.id);
+ return !row?.avatar;
+});
+```
+
+Then reference it in the registry:
+
+```typescript
+conditions: [{ kind: 'custom', id: 'has-no-profile-photo' }],
+```
+
+---
+
+### Create a multipage upgrade announcement
+
+Give multiple notices the same `conditions` and adjacent `priority` values. The pager groups all active modal notices together automatically — no extra wiring required.
+
+```typescript
+// Page 1 — breaking change (higher priority, warn severity)
+{ id: 'v4-breaking', priority: 90, severity: 'warn', conditions: [{ kind: 'existingUserBeforeVersion', version: '4.0.0' }], ... },
+
+// Page 2 — new feature (lower priority, info severity)
+{ id: 'v4-feature', priority: 80, severity: 'info', conditions: [{ kind: 'existingUserBeforeVersion', version: '4.0.0' }], ... },
+```
+
+Users who have already dismissed page 1 will only see page 2 on their next session.
+
+---
+
+## 13. Testing
+
+### Server unit tests
+
+**`server/tests/unit/systemNotices/conditions.test.ts`**
+
+Tests each condition kind in isolation using `evaluate()` directly. No DB required.
+
+**`server/tests/unit/systemNotices/registry.test.ts`**
+
+Validates registry integrity:
+- No duplicate `id` values
+- All `action` CTA `actionId`s have a corresponding `registerNoticeAction()` call in the client source (scanned via regex — no JSON file needed)
+- All `publishedAt` values parse as valid ISO dates
+
+Run: `cd server && npx vitest run tests/unit/systemNotices/`
+
+**`server/tests/integration/systemNotices.test.ts`**
+
+Integration tests against a real in-memory SQLite database:
+- `GET /api/system-notices/active` returns 401 without auth, returns correct notices per user state
+- `POST /api/system-notices/:id/dismiss` stores the dismissal and filters on subsequent requests
+- Dismissing an unknown ID returns 404
+
+Run: `cd server && npx vitest run tests/integration/systemNotices.test.ts`
+
+---
+
+### Client unit tests
+
+**`client/src/components/SystemNotices/SystemNoticeModal.test.tsx`**
+
+Tests `ModalRenderer` with fake timers (`vi.useFakeTimers()`) and MSW for the dismiss endpoint. Key helpers:
+
+```typescript
+// Flush the 500 ms grace delay that gates the modal's visible state
+async function flushGraceDelay() {
+ await act(async () => { vi.runAllTimers(); });
+}
+
+// Minimal notice factory
+function makeNotice(overrides?: Partial): SystemNoticeDTO
+```
+
+Covered cases (FE-SN-MODAL-001 to 018):
+- Grace delay before visibility
+- Dismiss button, X button, ESC key
+- Non-dismissible notices (all affordances blocked)
+- CTA nav button — dismisses all notices
+- Body param interpolation
+- Pager: counter, dots, prev/next buttons, keyboard arrows, dot click, non-dismissible lock
+- Dismiss-does-not-skip regression
+- X and ESC dismiss all in multipage scenario
+- Last notice close
+
+Run: `cd client && npm run test -- SystemNoticeModal`
+
+---
+
+### Running all notice tests
+
+```bash
+cd server && npx vitest run tests/unit/systemNotices/ tests/integration/systemNotices.test.ts
+cd client && npm run test -- SystemNoticeModal
+```
+
+---
+
+## 14. Rules & constraints
+
+| Rule | Reason |
+|---|---|
+| Never delete or reuse a notice `id` | Dismissal records are keyed by `id`. Deletion causes dismissed users to see the notice again. |
+| Never use literal newlines in translation strings | Single-quoted TS strings with literal newlines cause esbuild parse errors. Use `\n\n` (escaped). |
+| Never hardcode version numbers or dates in translation strings | Use `bodyParams` so strings stay translatable without retranslation per release. |
+| `critical` severity must have `dismissible: false` | `critical` toasts are auto-dismissed with a warning; a dismissible critical modal is inconsistent UX. |
+| `critical` must not use `display: 'toast'` | The toast renderer logs a warning and auto-dismisses critical toasts rather than showing them. |
+| CTA labels ≤ 20 chars, sentence case, a verb | Consistent button copy across the app. |
+| Priorities must be set explicitly for upgrade notices | Adjacent notices form a multipage group; ordering matters for the reading flow. |
+| `action` CTA `actionId` must be registered client-side | The registry integrity test enforces this. Add both the registry entry and the `registerNoticeAction` call in the same PR. |
+| `expiresAt` over deletion for retiring notices | See above. |
diff --git a/server/package-lock.json b/server/package-lock.json
index a75ba28f..10a291fc 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -24,6 +24,7 @@
"nodemailer": "^8.0.5",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
+ "semver": "^7.7.4",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"undici": "^7.0.0",
@@ -45,6 +46,7 @@
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.5",
+ "@types/semver": "^7.7.1",
"@types/supertest": "^6.0.3",
"@types/unzipper": "^0.10.11",
"@types/uuid": "^10.0.0",
@@ -1590,7 +1592,6 @@
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
@@ -1721,6 +1722,13 @@
"@types/node": "*"
}
},
+ "node_modules/@types/semver": {
+ "version": "7.7.1",
+ "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
+ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@@ -2152,7 +2160,6 @@
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
- "peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -3164,7 +3171,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -3668,11 +3674,10 @@
}
},
"node_modules/hono": {
- "version": "4.12.12",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
- "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
+ "version": "4.12.14",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
+ "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -5730,7 +5735,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -5805,7 +5809,6 @@
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -5961,7 +5964,6 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -6078,7 +6080,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -6092,7 +6093,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -6331,7 +6331,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/server/package.json b/server/package.json
index 1ffb8f24..1e873c8f 100644
--- a/server/package.json
+++ b/server/package.json
@@ -26,12 +26,13 @@
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"node-cron": "^4.2.1",
- "undici": "^7.0.0",
"nodemailer": "^8.0.5",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
+ "semver": "^7.7.4",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
+ "undici": "^7.0.0",
"unzipper": "^0.12.3",
"uuid": "^9.0.0",
"ws": "^8.19.0",
@@ -54,6 +55,7 @@
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.5",
+ "@types/semver": "^7.7.1",
"@types/supertest": "^6.0.3",
"@types/unzipper": "^0.10.11",
"@types/uuid": "^10.0.0",
diff --git a/server/src/addons.ts b/server/src/addons.ts
index 7dc96cca..a845f8c5 100644
--- a/server/src/addons.ts
+++ b/server/src/addons.ts
@@ -6,6 +6,7 @@ export const ADDON_IDS = {
VACAY: 'vacay',
ATLAS: 'atlas',
COLLAB: 'collab',
+ JOURNEY: 'journey',
} as const;
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
diff --git a/server/src/app.ts b/server/src/app.ts
index f4c91791..34266f3d 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -42,9 +42,12 @@ import shareRoutes from './routes/share';
import journeyRoutes from './routes/journey';
import journeyPublicRoutes from './routes/journeyPublic';
import publicConfigRoutes from './routes/publicConfig';
+import systemNoticesRoutes from './routes/systemNotices';
import { mcpHandler } from './mcp';
import { Addon } from './types';
import { getPhotoProviderConfig } from './services/memories/helpersService';
+import { isAddonEnabled } from './services/adminService';
+import { ADDON_IDS } from './addons';
export function createApp(): express.Application {
const app = express();
@@ -265,13 +268,17 @@ export function createApp(): express.Application {
// Addon routes
app.use('/api/addons/vacay', vacayRoutes);
app.use('/api/addons/atlas', atlasRoutes);
- app.use('/api/journeys', journeyRoutes);
+ app.use('/api/journeys', (req, res, next) => {
+ if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return res.status(404).json({ error: 'Journey addon is not enabled' });
+ next();
+ }, journeyRoutes);
app.use('/api/public/journey', journeyPublicRoutes);
app.use('/api/integrations/memories', memoriesRoutes);
app.use('/api/photos', photoRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
+ app.use('/api/system-notices', systemNoticesRoutes);
app.use('/api/backup', backupRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api', shareRoutes);
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index d7202469..4eddc78d 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -1605,6 +1605,20 @@ function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at);
`);
},
+ // Migration 101: System notices — user tracking columns + dismissals table
+ () => {
+ db.exec(`ALTER TABLE users ADD COLUMN first_seen_version TEXT NOT NULL DEFAULT '0.0.0'`);
+ db.exec(`ALTER TABLE users ADD COLUMN login_count INTEGER NOT NULL DEFAULT 0`);
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS user_notice_dismissals (
+ user_id INTEGER NOT NULL,
+ notice_id TEXT NOT NULL,
+ dismissed_at INTEGER NOT NULL,
+ PRIMARY KEY (user_id, notice_id),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ )
+ `);
+ },
];
if (currentVersion < migrations.length) {
diff --git a/server/src/routes/systemNotices.ts b/server/src/routes/systemNotices.ts
new file mode 100644
index 00000000..2190963b
--- /dev/null
+++ b/server/src/routes/systemNotices.ts
@@ -0,0 +1,29 @@
+import { Router } from 'express';
+import { authenticate } from '../middleware/auth.js';
+import { getActiveNoticesFor, dismissNotice } from '../systemNotices/service.js';
+import type { AuthRequest } from '../types.js';
+
+const router = Router();
+
+// GET /api/system-notices/active
+// Returns notices active for the authenticated user.
+router.get('/active', authenticate, (req, res) => {
+ const userId = (req as AuthRequest).user!.id;
+ const notices = getActiveNoticesFor(userId);
+ res.json(notices);
+});
+
+// POST /api/system-notices/:id/dismiss
+// Marks a notice as dismissed for the authenticated user. Idempotent.
+router.post('/:id/dismiss', authenticate, (req, res) => {
+ const userId = (req as AuthRequest).user!.id;
+ const noticeId = req.params.id;
+ const ok = dismissNotice(userId, noticeId);
+ if (!ok) {
+ res.status(404).json({ error: 'NOTICE_NOT_FOUND' });
+ return;
+ }
+ res.status(204).end();
+});
+
+export default router;
diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts
index e15395d3..e1463514 100644
--- a/server/src/services/authService.ts
+++ b/server/src/services/authService.ts
@@ -334,8 +334,8 @@ export function registerUser(body: {
try {
const result = db.prepare(
- 'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
- ).run(username, email, password_hash, role);
+ 'INSERT INTO users (username, email, password_hash, role, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, 0)'
+ ).run(username, email, password_hash, role, process.env.APP_VERSION || '0.0.0');
const user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false };
const token = generateToken(user);
@@ -408,7 +408,7 @@ export function loginUser(body: {
return { mfa_required: true, mfa_token };
}
- db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
+ db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
const token = generateToken(user);
const userSafe = stripUserForClient(user) as Record;
@@ -972,7 +972,7 @@ export function verifyMfaLogin(body: {
user.id
);
}
- db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
+ db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
const sessionToken = generateToken(user);
const userSafe = stripUserForClient(user) as Record;
return {
diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts
index e5c8c683..3874de37 100644
--- a/server/src/services/oidcService.ts
+++ b/server/src/services/oidcService.ts
@@ -287,8 +287,8 @@ export function findOrCreateUser(
if (existing) username = `${username}_${Date.now() % 10000}`;
const result = db.prepare(
- 'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)',
- ).run(username, email, hash, role, sub, config.issuer);
+ 'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, ?, ?, 0)',
+ ).run(username, email, hash, role, sub, config.issuer, process.env.APP_VERSION || '0.0.0');
if (validInvite) {
const updated = db.prepare(
@@ -308,5 +308,5 @@ export function findOrCreateUser(
// ---------------------------------------------------------------------------
export function touchLastLogin(userId: number): void {
- db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
+ db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(userId);
}
diff --git a/server/src/systemNotices/conditions.ts b/server/src/systemNotices/conditions.ts
new file mode 100644
index 00000000..616301a3
--- /dev/null
+++ b/server/src/systemNotices/conditions.ts
@@ -0,0 +1,70 @@
+import semver from 'semver';
+import { isAddonEnabled } from '../services/adminService.js';
+import type { NoticeCondition, SystemNotice } from './types.js';
+
+interface ConditionContext {
+ user: { login_count: number; first_seen_version: string; role: string; noTrips: number };
+ currentAppVersion: string;
+ now: Date;
+}
+
+// Custom predicate registry — extensible without modifying this file
+const customPredicates = new Map boolean>();
+export function registerPredicate(id: string, fn: (ctx: ConditionContext) => boolean): void {
+ customPredicates.set(id, fn);
+}
+
+function evaluateOne(condition: NoticeCondition, ctx: ConditionContext): boolean {
+ switch (condition.kind) {
+ case 'always':
+ return true;
+ case 'firstLogin':
+ // login_count is incremented during login, so on the FIRST post-login fetch it's 1.
+ return ctx.user.login_count <= 1;
+ case 'noTrips':
+ return ctx.user.noTrips === 0;
+
+ case 'existingUserBeforeVersion': {
+ // Show to users who existed BEFORE this version was released.
+ // Backfilled users have first_seen_version='0.0.0', so all pass semver.lt.
+ const userVersion = semver.valid(ctx.user.first_seen_version) ?? '0.0.0';
+ const noticeVersion = semver.valid(condition.version);
+ if (!noticeVersion) return false;
+ return (
+ semver.lt(userVersion, noticeVersion) &&
+ semver.gte(semver.valid(ctx.currentAppVersion) ?? '0.0.0', noticeVersion)
+ );
+ }
+
+ case 'dateWindow': {
+ const start = new Date(condition.startsAt);
+ const end = condition.endsAt ? new Date(condition.endsAt) : null;
+ return ctx.now >= start && (end === null || ctx.now <= end);
+ }
+
+ case 'role':
+ return condition.roles.includes(ctx.user.role as 'admin' | 'user');
+
+ case 'addonEnabled':
+ return isAddonEnabled(condition.addonId);
+
+ case 'custom': {
+ const fn = customPredicates.get(condition.id);
+ if (!fn) {
+ console.warn(`[systemNotices] unknown custom predicate: "${condition.id}"`);
+ return false;
+ }
+ return fn(ctx);
+ }
+
+ default:
+ return false;
+ }
+}
+
+/** Returns true only if ALL conditions pass (AND logic). */
+export function evaluate(notice: SystemNotice, ctx: ConditionContext): boolean {
+ return notice.conditions.every(c => evaluateOne(c, ctx));
+}
+
+export type { ConditionContext };
diff --git a/server/src/systemNotices/registry.ts b/server/src/systemNotices/registry.ts
new file mode 100644
index 00000000..83331a4e
--- /dev/null
+++ b/server/src/systemNotices/registry.ts
@@ -0,0 +1,104 @@
+import type { SystemNotice } from './types.js';
+
+/**
+ * SYSTEM NOTICE REGISTRY
+ *
+ * Rules for authoring:
+ * - NEVER remove or renumber entries — dismissal tracking is keyed by `id`.
+ * - `id` must be globally unique and stable across deployments.
+ * - Title: ≤40 chars, sentence case, no trailing punctuation.
+ * - Body: markdown (modal) or plain text (banner/toast). ≤400/140/80 chars.
+ * - CTA label: ≤20 chars, a verb.
+ * - Never hardcode version numbers/dates in translated strings — use bodyParams.
+ * - See plans/system-notices/00-overview.md for full authoring guidelines.
+ */
+export const SYSTEM_NOTICES: SystemNotice[] = [
+ // ── 3.0.0 upgrade notices (shown as a multipage modal to pre-3.0 users) ─────
+
+ {
+ // Page 1 — breaking change first (warn → sorts before the two info notices)
+ id: 'v3-photos',
+ display: 'modal',
+ severity: 'warn',
+ icon: 'ImageOff',
+ titleKey: 'system_notice.v3_photos.title',
+ bodyKey: 'system_notice.v3_photos.body',
+ dismissible: true,
+ conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
+ publishedAt: '2026-04-16T00:00:00Z',
+ priority: 90,
+ },
+
+ {
+ // Page 2 — flagship feature (only when Journey addon is enabled)
+ id: 'v3-journey',
+ display: 'modal',
+ severity: 'info',
+ icon: 'BookOpen',
+ titleKey: 'system_notice.v3_journey.title',
+ bodyKey: 'system_notice.v3_journey.body',
+ highlights: [
+ { labelKey: 'system_notice.v3_journey.highlight_timeline', iconName: 'CalendarDays' },
+ { labelKey: 'system_notice.v3_journey.highlight_photos', iconName: 'Images' },
+ { labelKey: 'system_notice.v3_journey.highlight_share', iconName: 'Globe' },
+ { labelKey: 'system_notice.v3_journey.highlight_export', iconName: 'FileText' },
+ ],
+ cta: {
+ kind: 'nav',
+ labelKey: 'system_notice.v3_journey.cta_label',
+ href: '/journey',
+ },
+ dismissible: true,
+ conditions: [
+ { kind: 'existingUserBeforeVersion', version: '3.0.0' },
+ { kind: 'addonEnabled', addonId: 'journey' },
+ ],
+ publishedAt: '2026-04-16T00:00:00Z',
+ priority: 80,
+ },
+
+ {
+ // Page 3 — other highlights
+ id: 'v3-features',
+ display: 'modal',
+ severity: 'info',
+ icon: 'Sparkles',
+ titleKey: 'system_notice.v3_features.title',
+ bodyKey: 'system_notice.v3_features.body',
+ highlights: [
+ { labelKey: 'system_notice.v3_features.highlight_dashboard', iconName: 'LayoutDashboard' },
+ { labelKey: 'system_notice.v3_features.highlight_offline', iconName: 'WifiOff' },
+ { labelKey: 'system_notice.v3_features.highlight_search', iconName: 'Search' },
+ { labelKey: 'system_notice.v3_features.highlight_import', iconName: 'FileInput' },
+ ],
+ dismissible: true,
+ conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
+ publishedAt: '2026-04-16T00:00:00Z',
+ priority: 70,
+ },
+
+ // ── Onboarding ─────────────────────────────────────────────────────────────
+
+ {
+ id: 'welcome-v1',
+ display: 'modal',
+ severity: 'info',
+ icon: 'Sparkles',
+ titleKey: 'system_notice.welcome_v1.title',
+ bodyKey: 'system_notice.welcome_v1.body',
+ highlights: [
+ { labelKey: 'system_notice.welcome_v1.highlight_plan', iconName: 'Map' },
+ { labelKey: 'system_notice.welcome_v1.highlight_share', iconName: 'Users' },
+ { labelKey: 'system_notice.welcome_v1.highlight_offline', iconName: 'WifiOff' },
+ ],
+ cta: {
+ kind: 'action',
+ labelKey: 'system_notice.welcome_v1.cta_label',
+ actionId: 'open:trip-create',
+ },
+ dismissible: true,
+ conditions: [{ kind: 'firstLogin' }],
+ publishedAt: '2026-04-16T00:00:00Z',
+ priority: 100,
+ },
+];
diff --git a/server/src/systemNotices/service.ts b/server/src/systemNotices/service.ts
new file mode 100644
index 00000000..d962ed90
--- /dev/null
+++ b/server/src/systemNotices/service.ts
@@ -0,0 +1,59 @@
+import { db } from '../db/database.js';
+import { SYSTEM_NOTICES } from './registry.js';
+import { evaluate } from './conditions.js';
+import type { SystemNoticeDTO } from './types.js';
+
+function getCurrentAppVersion(): string {
+ return process.env.APP_VERSION || '0.0.0';
+}
+
+function severityWeight(s: string): number {
+ return s === 'critical' ? 2 : s === 'warn' ? 1 : 0;
+}
+
+export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
+ const user = db.prepare(
+ 'SELECT login_count, first_seen_version, role FROM users WHERE id = ?'
+ ).get(userId) as { login_count: number; first_seen_version: string; role: string } | undefined;
+
+ if (!user) return [];
+
+ const { count: tripCount } = db.prepare(
+ 'SELECT COUNT(*) AS count FROM trips WHERE user_id = ?'
+ ).get(userId) as { count: number };
+
+ const dismissedIds = new Set(
+ (db.prepare('SELECT notice_id FROM user_notice_dismissals WHERE user_id = ?')
+ .all(userId) as Array<{ notice_id: string }>)
+ .map(r => r.notice_id)
+ );
+
+ const now = new Date();
+ const currentAppVersion = getCurrentAppVersion();
+ const ctx = { user: { ...user, noTrips: tripCount }, currentAppVersion, now };
+
+ return SYSTEM_NOTICES
+ .filter(n => {
+ if (dismissedIds.has(n.id)) return false;
+ if (n.expiresAt && now > new Date(n.expiresAt)) return false;
+ return evaluate(n, ctx);
+ })
+ .sort((a, b) => {
+ const pw = (b.priority ?? 0) - (a.priority ?? 0);
+ if (pw !== 0) return pw;
+ const sw = severityWeight(b.severity) - severityWeight(a.severity);
+ if (sw !== 0) return sw;
+ return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
+ })
+ .map(({ conditions: _c, publishedAt: _p, expiresAt: _e, priority: _pr, ...dto }) => dto);
+}
+
+export function dismissNotice(userId: number, noticeId: string): boolean {
+ const exists = SYSTEM_NOTICES.some(n => n.id === noticeId);
+ if (!exists) return false;
+ db.prepare(`
+ INSERT OR IGNORE INTO user_notice_dismissals (user_id, notice_id, dismissed_at)
+ VALUES (?, ?, ?)
+ `).run(userId, noticeId, Date.now());
+ return true;
+}
diff --git a/server/src/systemNotices/types.ts b/server/src/systemNotices/types.ts
new file mode 100644
index 00000000..bfd4812a
--- /dev/null
+++ b/server/src/systemNotices/types.ts
@@ -0,0 +1,45 @@
+export type Display = 'modal' | 'banner' | 'toast';
+export type Severity = 'info' | 'warn' | 'critical';
+
+export type NoticeCondition =
+ | { kind: 'firstLogin' }
+ | { kind: 'always' }
+ | { kind: 'noTrips' }
+ | { kind: 'existingUserBeforeVersion'; version: string }
+ | { kind: 'dateWindow'; startsAt: string; endsAt?: string }
+ | { kind: 'role'; roles: Array<'admin' | 'user'> }
+ | { kind: 'addonEnabled'; addonId: string }
+ | { kind: 'custom'; id: string };
+
+export interface NoticeMedia {
+ src: string;
+ srcDark?: string;
+ altKey: string;
+ placement?: 'hero' | 'inline';
+ aspectRatio?: string;
+}
+
+export type NoticeCta =
+ | { kind: 'nav'; labelKey: string; href: string }
+ | { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
+
+export interface SystemNotice {
+ id: string;
+ display: Display;
+ severity: Severity;
+ titleKey: string;
+ bodyKey: string;
+ bodyParams?: Record;
+ icon?: string;
+ media?: NoticeMedia;
+ highlights?: Array<{ labelKey: string; iconName?: string }>;
+ cta?: NoticeCta;
+ dismissible: boolean;
+ conditions: NoticeCondition[];
+ publishedAt: string;
+ expiresAt?: string;
+ priority?: number;
+}
+
+// DTO sent to client (same shape minus the conditions — server evaluates those)
+export type SystemNoticeDTO = Omit;
diff --git a/server/src/types.ts b/server/src/types.ts
index 83e9d51d..fcc0d114 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -17,6 +17,8 @@ export interface User {
mfa_secret?: string | null;
mfa_backup_codes?: string | null;
must_change_password?: number | boolean;
+ first_seen_version?: string;
+ login_count?: number;
created_at?: string;
updated_at?: string;
}
diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts
index 067ebb76..d09497b0 100644
--- a/server/tests/helpers/test-db.ts
+++ b/server/tests/helpers/test-db.ts
@@ -91,6 +91,8 @@ const RESET_TABLES = [
'notification_channel_preferences',
'notifications',
'audit_log',
+ // System notices
+ 'user_notice_dismissals',
// User data
'settings',
'mcp_tokens',
diff --git a/server/tests/integration/systemNotices.test.ts b/server/tests/integration/systemNotices.test.ts
new file mode 100644
index 00000000..3e540067
--- /dev/null
+++ b/server/tests/integration/systemNotices.test.ts
@@ -0,0 +1,241 @@
+/**
+ * System Notices API integration tests.
+ * Covers GET /api/system-notices/active and POST /api/system-notices/:id/dismiss.
+ */
+import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
+import request from 'supertest';
+import type { Application } from 'express';
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Bare in-memory DB — schema applied in beforeAll after mocks register
+// ─────────────────────────────────────────────────────────────────────────────
+const { testDb, dbMock } = vi.hoisted(() => {
+ const Database = require('better-sqlite3');
+ const db = new Database(':memory:');
+ db.exec('PRAGMA journal_mode = WAL');
+ db.exec('PRAGMA foreign_keys = ON');
+ db.exec('PRAGMA busy_timeout = 5000');
+
+ const mock = {
+ db,
+ closeDb: () => {},
+ reinitialize: () => {},
+ getPlaceWithTags: () => null,
+ canAccessTrip: () => null,
+ isOwner: () => false,
+ };
+
+ return { testDb: db, dbMock: mock };
+});
+
+vi.mock('../../src/db/database', () => dbMock);
+vi.mock('../../src/config', () => ({
+ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
+ ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
+ updateJwtSecret: () => {},
+}));
+
+import { createApp } from '../../src/app';
+import { createTables } from '../../src/db/schema';
+import { runMigrations } from '../../src/db/migrations';
+import { resetTestDb } from '../helpers/test-db';
+import { createUser } from '../helpers/factories';
+import { authCookie } from '../helpers/auth';
+import { SYSTEM_NOTICES } from '../../src/systemNotices/registry';
+import type { SystemNotice } from '../../src/systemNotices/types';
+
+const app: Application = createApp();
+
+// Test notice injected into the registry for notice-specific tests
+const TEST_NOTICE: SystemNotice = {
+ id: 'test-first-login-notice',
+ display: 'modal',
+ severity: 'info',
+ titleKey: 'system_notice.test_first_login_notice.title',
+ bodyKey: 'system_notice.test_first_login_notice.body',
+ dismissible: true,
+ conditions: [{ kind: 'firstLogin' }],
+ publishedAt: '2026-01-01T00:00:00Z',
+ priority: 0,
+};
+
+beforeAll(() => {
+ createTables(testDb);
+ runMigrations(testDb);
+});
+
+beforeEach(() => {
+ resetTestDb(testDb);
+});
+
+afterAll(() => {
+ testDb.close();
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// GET /api/system-notices/active
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('GET /api/system-notices/active', () => {
+ it('returns 401 without auth', async () => {
+ const res = await request(app).get('/api/system-notices/active');
+ expect(res.status).toBe(401);
+ });
+
+ it('returns empty array for non-first-login user with no applicable notices', async () => {
+ const { user } = createUser(testDb);
+ // login_count > 1 means firstLogin condition does not match for any notice
+ testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
+ const res = await request(app)
+ .get('/api/system-notices/active')
+ .set('Cookie', authCookie(user.id));
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual([]);
+ });
+
+ it('returns firstLogin notice for user with login_count <= 1', async () => {
+ SYSTEM_NOTICES.push(TEST_NOTICE);
+ try {
+ const { user } = createUser(testDb);
+ // Set login_count to 1 (first login)
+ testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id);
+
+ const res = await request(app)
+ .get('/api/system-notices/active')
+ .set('Cookie', authCookie(user.id));
+ expect(res.status).toBe(200);
+ // welcome-v1 is also in the registry and matches firstLogin, so at least TEST_NOTICE is present
+ const testNotice = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id);
+ expect(testNotice).toBeDefined();
+ // DTO should not expose conditions, publishedAt, expiresAt, priority
+ expect(testNotice.conditions).toBeUndefined();
+ expect(testNotice.publishedAt).toBeUndefined();
+ } finally {
+ const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
+ if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
+ }
+ });
+
+ it('does not return firstLogin notice for user with login_count > 1', async () => {
+ SYSTEM_NOTICES.push(TEST_NOTICE);
+ try {
+ const { user } = createUser(testDb);
+ testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
+
+ const res = await request(app)
+ .get('/api/system-notices/active')
+ .set('Cookie', authCookie(user.id));
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual([]);
+ } finally {
+ const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
+ if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
+ }
+ });
+
+ it('filters out dismissed notices', async () => {
+ SYSTEM_NOTICES.push(TEST_NOTICE);
+ try {
+ const { user } = createUser(testDb);
+ testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id);
+
+ // Dismiss the notice directly in DB
+ testDb.prepare(
+ 'INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, ?, ?)'
+ ).run(user.id, TEST_NOTICE.id, Date.now());
+
+ const res = await request(app)
+ .get('/api/system-notices/active')
+ .set('Cookie', authCookie(user.id));
+ expect(res.status).toBe(200);
+ // TEST_NOTICE should be filtered out; welcome-v1 may still appear
+ const found = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id);
+ expect(found).toBeUndefined();
+ } finally {
+ const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
+ if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
+ }
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// POST /api/system-notices/:id/dismiss
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('POST /api/system-notices/:id/dismiss', () => {
+ it('returns 401 without auth', async () => {
+ const res = await request(app).post('/api/system-notices/test-id/dismiss');
+ expect(res.status).toBe(401);
+ });
+
+ it('returns 404 for unknown notice id', async () => {
+ const { user } = createUser(testDb);
+ const res = await request(app)
+ .post('/api/system-notices/nonexistent-id/dismiss')
+ .set('Cookie', authCookie(user.id));
+ expect(res.status).toBe(404);
+ expect(res.body.error).toBe('NOTICE_NOT_FOUND');
+ });
+
+ it('returns 204 for valid notice id', async () => {
+ SYSTEM_NOTICES.push(TEST_NOTICE);
+ try {
+ const { user } = createUser(testDb);
+ const res = await request(app)
+ .post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`)
+ .set('Cookie', authCookie(user.id));
+ expect(res.status).toBe(204);
+ } finally {
+ const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
+ if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
+ }
+ });
+
+ it('is idempotent — second dismiss also returns 204', async () => {
+ SYSTEM_NOTICES.push(TEST_NOTICE);
+ try {
+ const { user } = createUser(testDb);
+ const first = await request(app)
+ .post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`)
+ .set('Cookie', authCookie(user.id));
+ expect(first.status).toBe(204);
+
+ const second = await request(app)
+ .post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`)
+ .set('Cookie', authCookie(user.id));
+ expect(second.status).toBe(204);
+ } finally {
+ const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
+ if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
+ }
+ });
+
+ it('dismiss appears in GET /active as filtered out', async () => {
+ SYSTEM_NOTICES.push(TEST_NOTICE);
+ try {
+ const { user } = createUser(testDb);
+ testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id);
+
+ // Confirm TEST_NOTICE is visible before dismiss
+ const before = await request(app)
+ .get('/api/system-notices/active')
+ .set('Cookie', authCookie(user.id));
+ expect(before.body.find((n: { id: string }) => n.id === TEST_NOTICE.id)).toBeDefined();
+
+ // Dismiss it
+ await request(app)
+ .post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`)
+ .set('Cookie', authCookie(user.id));
+
+ // Confirm TEST_NOTICE is gone; other notices (e.g. welcome-v1) may still appear
+ const after = await request(app)
+ .get('/api/system-notices/active')
+ .set('Cookie', authCookie(user.id));
+ expect(after.status).toBe(200);
+ expect(after.body.find((n: { id: string }) => n.id === TEST_NOTICE.id)).toBeUndefined();
+ } finally {
+ const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
+ if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
+ }
+ });
+});
diff --git a/server/tests/unit/systemNotices/conditions.test.ts b/server/tests/unit/systemNotices/conditions.test.ts
new file mode 100644
index 00000000..60f99fa2
--- /dev/null
+++ b/server/tests/unit/systemNotices/conditions.test.ts
@@ -0,0 +1,88 @@
+import { describe, it, expect } from 'vitest';
+import { evaluate } from '../../../src/systemNotices/conditions.js';
+import type { SystemNotice } from '../../../src/systemNotices/types.js';
+
+const baseNotice: SystemNotice = {
+ id: 'test',
+ display: 'modal',
+ severity: 'info',
+ titleKey: 'k.title',
+ bodyKey: 'k.body',
+ dismissible: true,
+ conditions: [],
+ publishedAt: '2026-01-01T00:00:00Z',
+};
+
+const baseCtx = {
+ user: { login_count: 5, first_seen_version: '1.0.0', role: 'user' },
+ currentAppVersion: '2.0.0',
+ now: new Date('2026-06-01T00:00:00Z'),
+};
+
+describe('firstLogin', () => {
+ const notice = { ...baseNotice, conditions: [{ kind: 'firstLogin' as const }] };
+ it('passes when login_count <= 1', () => {
+ expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, login_count: 1 } })).toBe(true);
+ });
+ it('fails when login_count > 1', () => {
+ expect(evaluate(notice, baseCtx)).toBe(false);
+ });
+});
+
+describe('existingUserBeforeVersion', () => {
+ const notice = { ...baseNotice, conditions: [{ kind: 'existingUserBeforeVersion' as const, version: '2.0.0' }] };
+ it('passes for user with first_seen_version < notice version when current >= notice version', () => {
+ expect(evaluate(notice, baseCtx)).toBe(true);
+ });
+ it('fails for new user (first_seen_version >= notice version)', () => {
+ expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, first_seen_version: '2.0.0' } })).toBe(false);
+ });
+ it('fails when current app version < notice version', () => {
+ expect(evaluate(notice, { ...baseCtx, currentAppVersion: '1.5.0' })).toBe(false);
+ });
+});
+
+describe('dateWindow', () => {
+ it('passes when now is inside window', () => {
+ const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-05-01T00:00:00Z', endsAt: '2026-07-01T00:00:00Z' }] };
+ expect(evaluate(notice, baseCtx)).toBe(true);
+ });
+ it('fails when now is before start', () => {
+ const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-07-01T00:00:00Z' }] };
+ expect(evaluate(notice, baseCtx)).toBe(false);
+ });
+ it('passes when no endsAt', () => {
+ const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-01-01T00:00:00Z' }] };
+ expect(evaluate(notice, baseCtx)).toBe(true);
+ });
+});
+
+describe('role', () => {
+ it('passes for matching role', () => {
+ const notice = { ...baseNotice, conditions: [{ kind: 'role' as const, roles: ['user'] }] };
+ expect(evaluate(notice, baseCtx)).toBe(true);
+ });
+ it('fails for non-matching role', () => {
+ const notice = { ...baseNotice, conditions: [{ kind: 'role' as const, roles: ['admin'] }] };
+ expect(evaluate(notice, baseCtx)).toBe(false);
+ });
+});
+
+describe('AND logic', () => {
+ it('requires all conditions to pass', () => {
+ const notice = { ...baseNotice, conditions: [
+ { kind: 'firstLogin' as const },
+ { kind: 'role' as const, roles: ['user'] },
+ ]};
+ // login_count=1 passes firstLogin, role=user passes role → true
+ expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, login_count: 1 } })).toBe(true);
+ // login_count=2 fails firstLogin → false
+ expect(evaluate(notice, baseCtx)).toBe(false);
+ });
+});
+
+describe('empty conditions', () => {
+ it('always passes when conditions array is empty', () => {
+ expect(evaluate(baseNotice, baseCtx)).toBe(true);
+ });
+});
diff --git a/server/tests/unit/systemNotices/registry.test.ts b/server/tests/unit/systemNotices/registry.test.ts
new file mode 100644
index 00000000..bb9cc035
--- /dev/null
+++ b/server/tests/unit/systemNotices/registry.test.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+import { SYSTEM_NOTICES } from '../../../src/systemNotices/registry.js';
+
+/** Collect all actionIds registered via registerNoticeAction() in client source files. */
+function collectRegisteredActionIds(): Set {
+ const clientSrc = path.resolve(__dirname, '../../../../client/src');
+ const ids = new Set();
+ const queue = [clientSrc];
+ while (queue.length) {
+ const dir = queue.pop()!;
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) { queue.push(full); continue; }
+ if (!entry.name.endsWith('noticeActions.ts') && !entry.name.endsWith('noticeActions.js')) continue;
+ const src = fs.readFileSync(full, 'utf8');
+ for (const m of src.matchAll(/registerNoticeAction\(\s*['"]([^'"]+)['"]/g)) {
+ ids.add(m[1]);
+ }
+ }
+ }
+ return ids;
+}
+
+describe('registry integrity', () => {
+ it('has no duplicate ids', () => {
+ const ids = SYSTEM_NOTICES.map(n => n.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it('all action CTAs reference a registered actionId', () => {
+ const registeredActionIds = collectRegisteredActionIds();
+ const actionCtaIds = SYSTEM_NOTICES
+ .filter(n => n.cta?.kind === 'action')
+ .map(n => (n.cta as { actionId: string }).actionId);
+
+ for (const id of actionCtaIds) {
+ expect(registeredActionIds, `actionId "${id}" not found in any client noticeActions.ts`).toContain(id);
+ }
+ });
+
+ it('all publishedAt are valid ISO dates', () => {
+ for (const n of SYSTEM_NOTICES) {
+ expect(() => new Date(n.publishedAt).toISOString()).not.toThrow();
+ }
+ });
+});