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' && ( +
+ {t(notice.media.altKey)} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+ )} + +
+ {/* Severity icon (when no hero) */} + {!notice.media && ( +
+ +
+ )} + + {/* Title */} +

+ {title} +

+ + {/* Body — markdown */} +
+ {body}

}> + ( + + {children} + + ), + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + }} + > + {body} +
+
+
+ + {/* Inline image */} + {notice.media?.placement === 'inline' && ( +
+ {t(notice.media.altKey)} { (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 ? ( + + ) : ( + + )} + {notice.dismissible && ctaLabel && ( + + )} +
+
+
+ ); +} + +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(); + } + }); +});