diff --git a/README.md b/README.md index 3ee3189..d725ece 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,11 @@ A self-hosted, real-time collaborative travel planner for organizing trips with - **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file) - **PDF Export** — Export complete trip plans as PDF with images and notes - **Multi-User** — Invite members to collaborate on shared trips with role-based access -- **Admin Panel** — User management, create users, global categories, API key configuration, and backups +- **Addon System** — Modular features that admins can enable/disable: Packing Lists, Budget, Documents, and global addons +- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with WebSocket live sync, and carry-over tracking +- **Atlas** — Interactive world map showing visited countries with travel stats, continent breakdown, streak tracking, and country details on click +- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user +- **Admin Panel** — User management with online status, global categories, addon management, API key configuration, and backups - **Auto-Backups** — Scheduled backups with configurable interval and retention - **Route Optimization** — Auto-optimize place order and export to Google Maps - **Day Notes** — Add timestamped notes to individual days diff --git a/client/package.json b/client/package.json index 727484e..aaea969 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "nomad-client", - "version": "2.0.0", + "version": "2.5.0", "private": true, "type": "module", "scripts": { diff --git a/client/src/App.jsx b/client/src/App.jsx index 1e5beea..619e76a 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -10,6 +10,8 @@ import TripPlannerPage from './pages/TripPlannerPage' import FilesPage from './pages/FilesPage' import AdminPage from './pages/AdminPage' import SettingsPage from './pages/SettingsPage' +import VacayPage from './pages/VacayPage' +import AtlasPage from './pages/AtlasPage' import { ToastContainer } from './components/shared/Toast' import { TranslationProvider } from './i18n' import DemoBanner from './components/Layout/DemoBanner' @@ -33,7 +35,7 @@ function ProtectedRoute({ children, adminRequired = false }) { return } - if (adminRequired && user?.role !== 'admin') { + if (adminRequired && user && user.role !== 'admin') { return } @@ -132,6 +134,22 @@ export default function App() { } /> + + + + } + /> + + + + } + /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 179c5d8..dffb805 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -127,6 +127,12 @@ export const adminApi = { saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data), getOidc: () => apiClient.get('/admin/oidc').then(r => r.data), updateOidc: (data) => apiClient.put('/admin/oidc', data).then(r => r.data), + addons: () => apiClient.get('/admin/addons').then(r => r.data), + updateAddon: (id, data) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), +} + +export const addonsApi = { + enabled: () => apiClient.get('/addons').then(r => r.data), } export const mapsApi = { diff --git a/client/src/components/Admin/AddonManager.jsx b/client/src/components/Admin/AddonManager.jsx new file mode 100644 index 0000000..4751193 --- /dev/null +++ b/client/src/components/Admin/AddonManager.jsx @@ -0,0 +1,158 @@ +import React, { useEffect, useState } from 'react' +import { adminApi } from '../../api/client' +import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase } from 'lucide-react' + +const ICON_MAP = { + ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, +} + +function AddonIcon({ name, size = 20 }) { + const Icon = ICON_MAP[name] || Puzzle + return +} + +export default function AddonManager() { + const { t } = useTranslation() + const toast = useToast() + const [addons, setAddons] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + loadAddons() + }, []) + + const loadAddons = async () => { + setLoading(true) + try { + const data = await adminApi.addons() + setAddons(data.addons) + } catch (err) { + toast.error(t('admin.addons.toast.error')) + } finally { + setLoading(false) + } + } + + const handleToggle = async (addon) => { + const newEnabled = !addon.enabled + // Optimistic update + setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a)) + try { + await adminApi.updateAddon(addon.id, { enabled: newEnabled }) + window.dispatchEvent(new Event('addons-changed')) + toast.success(t('admin.addons.toast.updated')) + } catch (err) { + // Rollback + setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a)) + toast.error(t('admin.addons.toast.error')) + } + } + + const tripAddons = addons.filter(a => a.type === 'trip') + const globalAddons = addons.filter(a => a.type === 'global') + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

{t('admin.addons.title')}

+

{t('admin.addons.subtitle')}

+
+ + {addons.length === 0 ? ( +
+ {t('admin.addons.noAddons')} +
+ ) : ( +
+ {/* Trip Addons */} + {tripAddons.length > 0 && ( +
+
+ + + {t('admin.addons.type.trip')} — {t('admin.addons.tripHint')} + +
+ {tripAddons.map(addon => ( + + ))} +
+ )} + + {/* Global Addons */} + {globalAddons.length > 0 && ( +
+
+ + + {t('admin.addons.type.global')} — {t('admin.addons.globalHint')} + +
+ {globalAddons.map(addon => ( + + ))} +
+ )} +
+ )} +
+
+ ) +} + +function AddonRow({ addon, onToggle, t }) { + return ( +
+ {/* Icon */} +
+ +
+ + {/* Info */} +
+
+ {addon.name} + + {addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')} + +
+

{addon.description}

+
+ + {/* Toggle */} +
+ + {addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} + + +
+
+ ) +} diff --git a/client/src/components/Admin/BackupPanel.jsx b/client/src/components/Admin/BackupPanel.jsx index ac08ec4..47c3f8b 100644 --- a/client/src/components/Admin/BackupPanel.jsx +++ b/client/src/components/Admin/BackupPanel.jsx @@ -153,8 +153,8 @@ export default function BackupPanel() {
-

{t('backup.title')}

-

{t('backup.subtitle')}

+

{t('backup.title')}

+

{t('backup.subtitle')}

@@ -179,26 +179,28 @@ export default function BackupPanel() { onClick={() => fileInputRef.current?.click()} disabled={isUploading} className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60" + title={isUploading ? t('backup.uploading') : t('backup.upload')} > {isUploading ? (
) : ( )} - {isUploading ? t('backup.uploading') : t('backup.upload')} + {isUploading ? t('backup.uploading') : t('backup.upload')}
@@ -275,23 +277,23 @@ export default function BackupPanel() {
-

{t('backup.auto.title')}

-

{t('backup.auto.subtitle')}

+

{t('backup.auto.title')}

+

{t('backup.auto.subtitle')}

{/* Enable toggle */} -