From 15e333681fe4ce94afa8e5b477b339b179d0195a Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 10 May 2026 20:36:32 +0200 Subject: [PATCH] feat(settings): Features tab + sidebar reorg with feature-flag gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorganises the admin sidebar around what users actually do, and adds a single Features page that gates which feature surfaces appear in the nav. Shrinks the main sidebar from 11 items to 4-6 (depending on feature flags) and groups configuration screens into a single Settings home with six logical sections. Why --- The current sidebar mixes three concerns: workspaces (Dashboard, Events, Archives), feature surfaces (Analytics, Users), and configuration screens that get touched maybe once a month (Email Settings, Branding, Event Types, Backup, CMS Pages). That's 11 items, half of them config. Backend ------- - New `feature_flags` table (key, value, updated_at, updated_by). Migration 088 detects existing-vs-fresh installs from the events table: * Existing install (events>0) → all 9 flags TRUE so nothing vanishes from an admin's UI on upgrade. * Fresh install (events=0) → spec defaults: galleries, reminderEmails, analytics, userManagement TRUE; calendar, calendarBooking, quotes, bills, messaging FALSE. - New `/api/admin/feature-flags` (GET/PUT) under `settings.view` and `settings.edit`. Server enforces the same dependency rules the frontend does (galleries always TRUE, quotes=false → bills=false, calendar=false → calendarBooking=false). PUT writes one `feature_flags_updated` activity log row with the diff. Frontend -------- - `FeatureFlagsContext` provides `useFeatureFlags()` (with staged/save/ reset/isDirty) and `useFeatureEnabled(key)`. Mounted inside AdminLayout so flag fetches carry the auth cookie. Source of truth is the server response; staged is a local copy that the Features tab edits and the Save button PUTs. - `RequireFeature` route guard for /admin/analytics and /admin/users — redirects to /admin/dashboard when the corresponding flag is OFF. - AdminSidebar dropped from 11 to 6 items. Removed: Email Settings, Branding, Event Types, Backup, CMS Pages (now Settings tabs). Feature-gated: Analytics, Users. - Old top-level routes (/admin/email, /admin/branding, /admin/event- types, /admin/backup, /admin/cms) kept as redirects to /admin/settings?tab= so existing bookmarks don't 404. - SettingsPage rewritten with a 6-group inner-nav (General / Content & Appearance / Communication / Privacy & Security / Integrations / System) and 19 tabs. New Features tab is the default landing tab. URL ?tab= roundtrips with state — deep links and the back button work. - FeaturesTab renders 9 cards across 5 sections. Toggles enabled for Analytics + User Management (the two flags that gate sidebar items in this PR). All other toggles disabled with a "Not yet available" lockedReason — the cards still render so admins see the roadmap, but the flag has no UI effect until the surface ships in its own PR. The galleries card is locked TRUE per spec (foundation, can't be off). - Live SidebarPreview reflects unsaved staged changes — admins see what their sidebar will look like before they save. - New i18n keys across all 5 locales (en, de, nl, pt, ru) for the Features tab copy, the new Settings group labels, and the lifted tab titles. Verified end-to-end ------------------- - Migration on this dev DB (existing install, 977 events): all 9 flags set to TRUE. - Migration on simulated fresh install (events table emptied): spec defaults applied (5 OFF, 4 ON). - Backend round-trip: GET → PUT → audit-log entry written, dependency rule enforced (bills forced false when quotes=false even when bills= true requested). - UI Playwright spec: sidebar dropped 5 items, old top-level routes redirect, Features tab is default, Galleries+Calendar+Quotes+Bills+ Messaging+ReminderEmails toggles disabled, Analytics+Users toggles enabled, toggling Analytics off + saving updates the sidebar + redirects /admin/analytics to /admin/dashboard. - Smoke 13/13 still green; no regressions on existing flows. --- .../migrations/core/088_add_feature_flags.js | 88 ++++++ backend/server.js | 1 + backend/src/routes/adminFeatureFlags.js | 148 +++++++++ frontend/src/App.tsx | 33 +- frontend/src/components/admin/AdminLayout.tsx | 23 ++ .../src/components/admin/AdminSidebar.tsx | 52 +-- .../src/components/admin/RequireFeature.tsx | 25 ++ frontend/src/contexts/FeatureFlagsContext.tsx | 161 ++++++++++ .../settings/components/FeatureCard.tsx | 114 +++++++ .../settings/components/SidebarPreview.tsx | 81 +++++ .../settings/components/StatusBadge.tsx | 28 ++ .../features/settings/components/Switch.tsx | 38 +++ frontend/src/features/settings/index.ts | 1 + .../features/settings/tabs/FeaturesTab.tsx | 261 +++++++++++++++ frontend/src/i18n/locales/de.json | 91 +++++- frontend/src/i18n/locales/en.json | 93 +++++- frontend/src/i18n/locales/nl.json | 91 +++++- frontend/src/i18n/locales/pt.json | 93 +++++- frontend/src/i18n/locales/ru.json | 91 +++++- frontend/src/pages/admin/SettingsPage.tsx | 297 ++++++++++++------ frontend/src/services/featureFlags.service.ts | 26 ++ 21 files changed, 1679 insertions(+), 157 deletions(-) create mode 100644 backend/migrations/core/088_add_feature_flags.js create mode 100644 backend/src/routes/adminFeatureFlags.js create mode 100644 frontend/src/components/admin/RequireFeature.tsx create mode 100644 frontend/src/contexts/FeatureFlagsContext.tsx create mode 100644 frontend/src/features/settings/components/FeatureCard.tsx create mode 100644 frontend/src/features/settings/components/SidebarPreview.tsx create mode 100644 frontend/src/features/settings/components/StatusBadge.tsx create mode 100644 frontend/src/features/settings/components/Switch.tsx create mode 100644 frontend/src/features/settings/tabs/FeaturesTab.tsx create mode 100644 frontend/src/services/featureFlags.service.ts diff --git a/backend/migrations/core/088_add_feature_flags.js b/backend/migrations/core/088_add_feature_flags.js new file mode 100644 index 00000000..9f70c672 --- /dev/null +++ b/backend/migrations/core/088_add_feature_flags.js @@ -0,0 +1,88 @@ +/** + * Migration 088: Feature flags table. + * + * Backs the Features tab on the admin Settings page. Flags gate which + * product surfaces appear in the main sidebar and (in future PRs) which + * background jobs run. + * + * Existing-vs-fresh detection: + * The Features tab introduces a curated set of "default ON" flags + * (galleries, reminderEmails, analytics, userManagement) and "default + * OFF" flags for surfaces that aren't built yet (calendar, quotes, + * bills, messaging). For a brand-new install those defaults are right + * out of the box. For an existing install, we want every flag ON so + * nothing in the admin's UI silently disappears the moment they + * upgrade — they can opt out later via the Features tab. + * + * Detection rule: if the `events` table has any rows at migration + * time, treat this as an existing install. Empty events = fresh. + * This is single-shot (the migration only runs once) and atomic + * (no race window). It picks up the rare edge case where an admin + * upgrades immediately after running setup but before creating an + * event — they'll get fresh-install defaults, which is acceptable + * (they can flip flags on the Features page). + * + * Schema: + * - key (PK): the flag identifier (matches FeatureKey on the frontend) + * - value: the boolean state + * - updated_at: last-changed timestamp + * - updated_by: admin id of the last person who flipped it (nullable + * for the migration-seeded rows) + */ + +exports.up = async function(knex) { + console.log('Running migration: 088_add_feature_flags'); + + const exists = await knex.schema.hasTable('feature_flags'); + if (!exists) { + await knex.schema.createTable('feature_flags', (table) => { + table.string('key', 64).primary(); + table.boolean('value').notNullable(); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.integer('updated_by').references('id').inTable('admin_users').onDelete('SET NULL'); + }); + console.log(' created feature_flags table'); + } else { + console.log(' feature_flags table already exists, skipping create'); + } + + // Detect install age. Use events table — it's user-created content, + // unlike admin_users which is seeded by migration 001. + const eventCountRow = await knex('events').count({ count: '*' }).first(); + const eventCount = parseInt(eventCountRow?.count || 0, 10); + const isExistingInstall = eventCount > 0; + console.log(` detected ${isExistingInstall ? 'EXISTING' : 'FRESH'} install (events count: ${eventCount})`); + + // Spec defaults (frontend/src/contexts/FeatureFlagsContext.tsx). + // For an existing install every flag becomes TRUE so nothing + // disappears from the admin UI on upgrade. + const FLAGS_FRESH = { + galleries: true, // always-on, locked + reminderEmails: true, // existing cron, locked-on for now + calendar: false, // surface not built yet + calendarBooking: false, // ditto + quotes: false, // surface not built yet + bills: false, // surface not built yet (depends on quotes) + messaging: false, // surface not built yet + analytics: true, // existing surface + userManagement: true, // existing surface + }; + const flagsToSeed = isExistingInstall + ? Object.fromEntries(Object.keys(FLAGS_FRESH).map((k) => [k, true])) + : FLAGS_FRESH; + + for (const [key, value] of Object.entries(flagsToSeed)) { + const existingRow = await knex('feature_flags').where({ key }).first(); + if (!existingRow) { + await knex('feature_flags').insert({ key, value }); + } + } + console.log(` seeded ${Object.keys(flagsToSeed).length} flags`); + + console.log('Migration 088_add_feature_flags completed'); +}; + +exports.down = async function(knex) { + console.log('Rollback: 088_add_feature_flags'); + await knex.schema.dropTableIfExists('feature_flags'); +}; diff --git a/backend/server.js b/backend/server.js index 38b32d53..aafa81ae 100644 --- a/backend/server.js +++ b/backend/server.js @@ -555,6 +555,7 @@ app.use('/api/gallery', require('./src/routes/galleryGuests')); app.use('/api/admin', adminRoutes); app.use('/api/admin/auth', adminAuthRoutes); app.use('/api/admin/system', require('./src/routes/adminSystem')); +app.use('/api/admin/feature-flags', require('./src/routes/adminFeatureFlags')); app.use('/api/admin/backup', require('./src/routes/adminBackup')); app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup')); app.use('/api/admin/feedback', require('./src/routes/adminFeedback')); diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js new file mode 100644 index 00000000..d736b27f --- /dev/null +++ b/backend/src/routes/adminFeatureFlags.js @@ -0,0 +1,148 @@ +/** + * Feature flags admin endpoints (#feature-flags-settings-reorg). + * + * GET /api/admin/feature-flags → { [key]: boolean } + * PUT /api/admin/feature-flags → body { [key]: boolean }, replaces in tx + * + * Server-side dependency rules mirror the frontend: + * - quotes=false forces bills=false + * - calendar=false forces calendarBooking=false + * - galleries is hard-coded true regardless of input + * + * Audit log: every successful PUT writes one activity_logs row with the + * before/after diff so changes are traceable. + */ + +const express = require('express'); +const router = express.Router(); +const { db, logActivity } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const logger = require('../utils/logger'); + +// Canonical flag list. Keep in sync with frontend +// `FeatureKey` union in frontend/src/contexts/FeatureFlagsContext.tsx. +const KNOWN_FLAGS = [ + 'galleries', + 'reminderEmails', + 'calendar', + 'calendarBooking', + 'quotes', + 'bills', + 'messaging', + 'analytics', + 'userManagement', +]; + +// Spec defaults for any flag missing from the DB (e.g. a row added by a +// new release that hasn't run its migration yet on this instance). +const DEFAULT_FLAGS = { + galleries: true, + reminderEmails: true, + calendar: false, + calendarBooking: false, + quotes: false, + bills: false, + messaging: false, + analytics: true, + userManagement: true, +}; + +async function readAllFlags() { + const rows = await db('feature_flags').select('key', 'value'); + const result = { ...DEFAULT_FLAGS }; + for (const row of rows) { + if (KNOWN_FLAGS.includes(row.key)) { + result[row.key] = Boolean(row.value); + } + } + return result; +} + +function applyDependencyRules(flags) { + const out = { ...flags }; + // Galleries is the foundation — never off. + out.galleries = true; + // Sub-features can't outlive their parents. + if (out.quotes === false) out.bills = false; + if (out.calendar === false) out.calendarBooking = false; + return out; +} + +router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => { + try { + const flags = await readAllFlags(); + res.json(flags); + } catch (error) { + logger.error('Failed to read feature flags', { error: error.message }); + res.status(500).json({ error: 'Failed to read feature flags' }); + } +}); + +router.put('/', adminAuth, requirePermission('settings.edit'), async (req, res) => { + try { + const body = req.body || {}; + if (typeof body !== 'object' || Array.isArray(body)) { + return res.status(400).json({ error: 'Body must be an object of { key: boolean } pairs' }); + } + + // Validate keys + types up front. + const cleaned = {}; + for (const [key, value] of Object.entries(body)) { + if (!KNOWN_FLAGS.includes(key)) { + return res.status(400).json({ error: `Unknown feature flag: ${key}` }); + } + if (typeof value !== 'boolean') { + return res.status(400).json({ error: `Flag ${key} must be boolean, got ${typeof value}` }); + } + cleaned[key] = value; + } + + const before = await readAllFlags(); + const merged = applyDependencyRules({ ...before, ...cleaned }); + + // Compute diff for audit log. + const changed = {}; + for (const key of KNOWN_FLAGS) { + if (merged[key] !== before[key]) { + changed[key] = { from: before[key], to: merged[key] }; + } + } + + if (Object.keys(changed).length === 0) { + // No-op write — return current state, skip audit log. + return res.json(merged); + } + + const adminId = req.admin?.id || null; + const adminUsername = req.admin?.username || 'unknown'; + + await db.transaction(async (trx) => { + for (const key of KNOWN_FLAGS) { + const value = merged[key]; + const existing = await trx('feature_flags').where({ key }).first(); + if (existing) { + await trx('feature_flags') + .where({ key }) + .update({ value, updated_at: trx.fn.now(), updated_by: adminId }); + } else { + await trx('feature_flags').insert({ key, value, updated_by: adminId }); + } + } + }); + + await logActivity( + 'feature_flags_updated', + { changed, actor: adminUsername }, + null, + { type: 'admin' } + ); + + res.json(merged); + } catch (error) { + logger.error('Failed to update feature flags', { error: error.message, stack: error.stack }); + res.status(500).json({ error: 'Failed to update feature flags' }); + } +}); + +module.exports = router; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 91e8be8a..bda78611 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,19 +18,15 @@ import { CreateEventPage, EventDetailsPage, EventFeedbackPage, - EmailConfigPage, ArchivesPage, AnalyticsPage, - BrandingPage, SettingsPage, - BackupManagement, - CMSPage, UserManagementPage, - EventTypesPage, WebhookDeliveriesPage } from './pages/admin'; import { AcceptInvitePage } from './pages/public/AcceptInvitePage'; import { AdminLayout, AdminAuthWrapper } from './components/admin'; +import { RequireFeature } from './components/admin/RequireFeature'; import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common'; import { MaintenanceWrapper } from './components/MaintenanceWrapper'; import { GlobalThemeProvider } from './components/GlobalThemeProvider'; @@ -128,15 +124,28 @@ function App() { } /> } /> } /> - } /> - } /> - } /> + + {/* Feature-gated surfaces — redirect to /admin/dashboard when flag is off. */} + }> + } /> + + }> + } /> + + } /> - } /> } /> - } /> - } /> - } /> + + {/* Old top-level routes — these surfaces now live as + Settings tabs (#feature-flags-settings-reorg). + Kept indefinitely as redirects so existing bookmarks + and external links don't 404. */} + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx index 23e8e846..25d6850b 100644 --- a/frontend/src/components/admin/AdminLayout.tsx +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { Outlet, Navigate } from 'react-router-dom'; import { useAdminAuth } from '../../contexts'; +import { FeatureFlagsProvider } from '../../contexts/FeatureFlagsContext'; import { useSessionTimeout } from '../../hooks/useSessionTimeout'; import { AdminSidebar } from './AdminSidebar'; import { AdminHeader } from './AdminHeader'; @@ -30,6 +31,28 @@ export const AdminLayout: React.FC = () => { return ; } + // FeatureFlagsProvider wraps the entire admin chrome — sidebar reads + // flags to decide which surfaces to render, the Features tab reads/writes + // the same source. Mounted INSIDE the auth-required tree so the GET to + // /api/admin/feature-flags has a session cookie attached. + return ( + + + + ); +}; + +interface AdminLayoutInnerProps { + sidebarOpen: boolean; + setSidebarOpen: (v: boolean) => void; + mustChangePassword: boolean; +} + +const AdminLayoutInner: React.FC = ({ sidebarOpen, setSidebarOpen, mustChangePassword }) => { return (
{/* Mandatory Password Change Modal */} diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index 441a84d4..187e17f1 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -3,51 +3,61 @@ import { NavLink, useLocation } from 'react-router-dom'; import { LayoutDashboard, Calendar, - Mail, Archive, BarChart3, Settings, X, - Palette, - FileText, - HardDrive, Users, - Tags } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { settingsService } from '../../services/settings.service'; import { VersionInfo } from './VersionInfo'; import { usePermissions } from '../../contexts/PermissionsContext'; +import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; interface AdminSidebarProps { isOpen: boolean; onClose: () => void; } -const navigation = [ +interface NavItem { + nameKey: string; + href: string; + icon: React.ComponentType<{ className?: string }>; + permission?: string | false; + featureFlag?: FeatureKey; +} + +// Sidebar shape after the Settings reorg (#feature-flags-settings-reorg). +// +// Removed (now live as Settings tabs, with redirects from the old +// top-level paths so bookmarks keep working): +// /admin/email, /admin/branding, /admin/event-types, /admin/backup, +// /admin/cms. +// +// Feature-gated (only render when the corresponding feature flag is on): +// Analytics → flags.analytics +// Users → flags.userManagement +const navigation: NavItem[] = [ { nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard, permission: false }, - { nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' }, - { nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' }, - { nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view' }, - { nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail, permission: 'email.view' }, - { nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette, permission: 'branding.view' }, - { nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' }, - { nameKey: 'navigation.eventTypes', href: '/admin/event-types', icon: Tags, permission: 'settings.view' }, - { nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive, permission: 'backup.view' }, - { nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText, permission: 'cms.view' }, - { nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view' }, -] as const; + { nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' }, + { nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' }, + { nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' }, + { nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' }, + { nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view', featureFlag: 'userManagement' }, +]; export const AdminSidebar: React.FC = ({ isOpen, onClose }) => { const location = useLocation(); const { t } = useTranslation(); const { hasPermission } = usePermissions(); + const { flags } = useFeatureFlags(); - // Filter navigation items based on permissions - const filteredNavigation = navigation.filter(item => { - if (!item.permission) return true; - return hasPermission(item.permission); + const filteredNavigation = navigation.filter((item) => { + if (item.permission && !hasPermission(item.permission as string)) return false; + if (item.featureFlag && !flags[item.featureFlag]) return false; + return true; }); return ( diff --git a/frontend/src/components/admin/RequireFeature.tsx b/frontend/src/components/admin/RequireFeature.tsx new file mode 100644 index 00000000..a0a87f7d --- /dev/null +++ b/frontend/src/components/admin/RequireFeature.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; +import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; + +interface RequireFeatureProps { + flag: FeatureKey; + fallback?: string; +} + +/** + * Route guard that redirects to /admin/dashboard when the named feature + * flag is OFF. Used so deep links (or stale bookmarks) to disabled + * surfaces don't render an empty page or a half-loaded view. + * + * Mounted as the `element` of a parent , with the gated routes as + * children — see App.tsx. + */ +export const RequireFeature: React.FC = ({ flag, fallback = '/admin/dashboard' }) => { + const { flags, isLoading } = useFeatureFlags(); + // Wait for the first fetch — otherwise we'd briefly fall back to the + // default-flags object and could redirect on a transient false. + if (isLoading) return null; + if (!flags[flag]) return ; + return ; +}; diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx new file mode 100644 index 00000000..57515f7f --- /dev/null +++ b/frontend/src/contexts/FeatureFlagsContext.tsx @@ -0,0 +1,161 @@ +import React, { createContext, useContext, useMemo, useState, useCallback, useEffect } from 'react'; +import type { ReactNode } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service'; + +export type { FeatureKey, FeatureFlags }; + +// Spec defaults — used as a non-blocking fallback while the network +// request is in flight, and as the source of truth for any flag the +// server doesn't return (e.g. a brand-new flag added in a release that +// hasn't run its migration yet on this instance). +export const DEFAULT_FLAGS: FeatureFlags = { + galleries: true, + reminderEmails: true, + calendar: false, + calendarBooking: false, + quotes: false, + bills: false, + messaging: false, + analytics: true, + userManagement: true, +}; + +export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const; + +interface FeatureFlagsContextValue { + // Live (saved) flags from the server. Never undefined — falls back to + // DEFAULT_FLAGS while loading so consumers don't have to handle the + // loading state for nav rendering. + flags: FeatureFlags; + // Staged (unsaved) flags. Same shape; equals `flags` when nothing is + // pending. + staged: FeatureFlags; + isLoading: boolean; + isSaving: boolean; + isDirty: boolean; + // Stage a single change locally. Enforces dependency rules + // (quotes→bills, calendar→calendarBooking, galleries always true). + setFlag: (key: FeatureKey, value: boolean) => void; + // PUT all staged changes to the server. + save: () => Promise; + // Revert staged → server-current. + reset: () => void; +} + +const FeatureFlagsContext = createContext(undefined); + +function applyDependencyRules(flags: FeatureFlags): FeatureFlags { + const out = { ...flags }; + out.galleries = true; // foundation — always on + if (out.quotes === false) out.bills = false; // bills depend on quotes + if (out.calendar === false) out.calendarBooking = false; // booking depends on calendar + return out; +} + +function flagsEqual(a: FeatureFlags, b: FeatureFlags): boolean { + return (Object.keys(a) as FeatureKey[]).every((k) => a[k] === b[k]); +} + +interface ProviderProps { + children: ReactNode; +} + +export const FeatureFlagsProvider: React.FC = ({ children }) => { + const queryClient = useQueryClient(); + const { data: serverFlags, isLoading } = useQuery({ + queryKey: FEATURE_FLAGS_QUERY_KEY, + queryFn: () => featureFlagsService.get(), + staleTime: 60_000, + }); + + // Source-of-truth = server response; fall back to defaults during load. + const flags = useMemo(() => serverFlags ?? DEFAULT_FLAGS, [serverFlags]); + + // Staged copy — what the Features tab is currently showing pre-save. + const [staged, setStaged] = useState(flags); + + // Re-sync staged whenever the server response arrives or refreshes, + // unless the user has unsaved changes (which we'd silently overwrite + // otherwise). + useEffect(() => { + setStaged((current) => { + if (flagsEqual(current, flags)) return current; + // If staged matches the OLD server state (no pending edits), accept + // the new server state. Otherwise keep user's edits. + // Detection: if every key is either equal to flags or differs only + // because the user edited, we can't distinguish — so be conservative + // and only auto-sync when the user hasn't touched anything. + // Simplification: only replace staged on first load (when staged + // still equals DEFAULT_FLAGS shape from the initial useState call). + return current; + }); + }, [flags]); + + // First-load wiring: when serverFlags arrives, seed `staged` once. + useEffect(() => { + if (serverFlags) { + setStaged((current) => (flagsEqual(current, DEFAULT_FLAGS) ? serverFlags : current)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [Boolean(serverFlags)]); + + const isDirty = useMemo(() => !flagsEqual(staged, flags), [staged, flags]); + + const setFlag = useCallback((key: FeatureKey, value: boolean) => { + if (key === 'galleries') return; // locked + setStaged((prev) => applyDependencyRules({ ...prev, [key]: value })); + }, []); + + const reset = useCallback(() => { + setStaged(flags); + }, [flags]); + + const mutation = useMutation({ + mutationFn: (next: FeatureFlags) => featureFlagsService.update(next), + onSuccess: (saved) => { + queryClient.setQueryData(FEATURE_FLAGS_QUERY_KEY, saved); + setStaged(saved); + // Also invalidate so any other consumer (e.g. AdminSidebar that + // reads via the context) re-renders against fresh data. + queryClient.invalidateQueries({ queryKey: FEATURE_FLAGS_QUERY_KEY }); + }, + }); + + const save = useCallback(async () => { + if (!isDirty) return; + await mutation.mutateAsync(staged); + }, [isDirty, mutation, staged]); + + const value = useMemo( + () => ({ + flags, + staged, + isLoading, + isSaving: mutation.isPending, + isDirty, + setFlag, + save, + reset, + }), + [flags, staged, isLoading, mutation.isPending, isDirty, setFlag, save, reset], + ); + + return {children}; +}; + +export function useFeatureFlags(): FeatureFlagsContextValue { + const ctx = useContext(FeatureFlagsContext); + if (!ctx) { + throw new Error('useFeatureFlags must be used inside a FeatureFlagsProvider'); + } + return ctx; +} + +// Convenience hook for "is this feature enabled right now?" — returns +// the SAVED value, not the staged one (sidebar visibility shouldn't +// flip the moment someone toggles something on the Features page). +export function useFeatureEnabled(key: FeatureKey): boolean { + const { flags } = useFeatureFlags(); + return flags[key]; +} diff --git a/frontend/src/features/settings/components/FeatureCard.tsx b/frontend/src/features/settings/components/FeatureCard.tsx new file mode 100644 index 00000000..196a20ce --- /dev/null +++ b/frontend/src/features/settings/components/FeatureCard.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import clsx from 'clsx'; +import { CornerDownRight, Lock, AlertTriangle } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { Switch } from './Switch'; +import { StatusBadge, type FeatureStatus } from './StatusBadge'; + +interface FeatureCardProps { + icon: LucideIcon; + title: string; + description: string; + status: FeatureStatus; + statusLabel: string; + sidebarLabel?: string; + sidebarHidden?: boolean; + sidebarHiddenLabel?: string; + enabled: boolean; + onToggle: (next: boolean) => void; + disabled?: boolean; + lockedReason?: string; + warning?: string; + children?: React.ReactNode; +} + +export const FeatureCard: React.FC = ({ + icon: Icon, + title, + description, + status, + statusLabel, + sidebarLabel, + sidebarHidden, + sidebarHiddenLabel, + enabled, + onToggle, + disabled = false, + lockedReason, + warning, + children, +}) => ( +
  • +
    + {/* Icon tile */} +
    + +
    + + {/* Body */} +
    +
    +

    {title}

    + +
    +

    {description}

    + + {/* Sidebar callout */} +
    + + {sidebarHidden ? ( + {sidebarHiddenLabel} + ) : sidebarLabel ? ( + <> + Sidebar: + {sidebarLabel} + + ) : null} +
    + + {/* Locked-reason hint */} + {lockedReason && ( +
    + + {lockedReason} +
    + )} + + {/* Warning shown only when the user is about to disable (i.e. enabled=true). + Wording assumes "you're disabling X — here's the consequence". */} + {warning && enabled && ( +
    + + {warning} +
    + )} + + {/* Sub-controls visible when enabled (e.g. Calendar mode radio) */} + {enabled && children && ( +
    {children}
    + )} +
    + + {/* Toggle */} + +
    +
  • +); diff --git a/frontend/src/features/settings/components/SidebarPreview.tsx b/frontend/src/features/settings/components/SidebarPreview.tsx new file mode 100644 index 00000000..b012e724 --- /dev/null +++ b/frontend/src/features/settings/components/SidebarPreview.tsx @@ -0,0 +1,81 @@ +import React, { useMemo } from 'react'; +import clsx from 'clsx'; +import { + LayoutDashboard, + Calendar, + Archive, + BarChart3, + Settings, + Users, +} from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { Card } from '../../../components/common'; +import { useTranslation } from 'react-i18next'; +import type { FeatureFlags } from '../../../contexts/FeatureFlagsContext'; + +interface PreviewItem { + key: string; + label: string; + icon: LucideIcon; + featureDriven: boolean; +} + +interface SidebarPreviewProps { + staged: FeatureFlags; +} + +/** + * Renders the live shape of the main admin sidebar based on the user's + * staged (unsaved) feature flags. Items in green (primary tint) are + * controlled by toggles above; greyscale items are unconditional. + */ +export const SidebarPreview: React.FC = ({ staged }) => { + const { t } = useTranslation(); + + const items = useMemo(() => { + const all: Array = [ + { key: 'dashboard', label: t('navigation.dashboard'), icon: LayoutDashboard, featureDriven: false }, + { key: 'events', label: t('navigation.events'), icon: Calendar, featureDriven: false }, + { key: 'archives', label: t('navigation.archives'), icon: Archive, featureDriven: false }, + { key: 'analytics', label: t('admin.analytics', 'Analytics'), icon: BarChart3, featureDriven: true, gate: 'analytics' }, + { key: 'settings', label: t('navigation.settings'), icon: Settings, featureDriven: false }, + { key: 'users', label: t('navigation.users'), icon: Users, featureDriven: true, gate: 'userManagement' }, + ]; + return all.filter((it) => !it.gate || staged[it.gate]); + }, [staged, t]); + + return ( + +
    +

    + {t('settings.features.preview.title', 'Sidebar preview')} +

    + + {t('settings.features.preview.note', 'Reflects unsaved changes')} + +
    +
      + {items.map((item) => ( +
    • + + {item.label} +
    • + ))} +
    +

    + {t( + 'settings.features.preview.legend', + 'Green tinted items are controlled by toggles above.', + )} +

    +
    + ); +}; diff --git a/frontend/src/features/settings/components/StatusBadge.tsx b/frontend/src/features/settings/components/StatusBadge.tsx new file mode 100644 index 00000000..02a5b81e --- /dev/null +++ b/frontend/src/features/settings/components/StatusBadge.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import clsx from 'clsx'; + +export type FeatureStatus = 'stable' | 'beta' | 'new' | 'experimental' | 'roadmap'; + +interface StatusBadgeProps { + status: FeatureStatus; + label: string; +} + +const STATUS_STYLES: Record = { + stable: 'bg-neutral-100 text-neutral-600 border-neutral-200 dark:bg-neutral-800 dark:text-neutral-400 dark:border-neutral-700', + beta: 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800', + new: 'bg-green-50 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800', + experimental: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-300 dark:border-amber-800', + roadmap: 'bg-white text-neutral-500 border-neutral-300 dark:bg-neutral-900 dark:text-neutral-400 dark:border-neutral-600', +}; + +export const StatusBadge: React.FC = ({ status, label }) => ( + + {label} + +); diff --git a/frontend/src/features/settings/components/Switch.tsx b/frontend/src/features/settings/components/Switch.tsx new file mode 100644 index 00000000..a38eee50 --- /dev/null +++ b/frontend/src/features/settings/components/Switch.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import clsx from 'clsx'; + +interface SwitchProps { + checked: boolean; + onChange: (next: boolean) => void; + disabled?: boolean; + ariaLabel: string; +} + +/** + * Accessible toggle switch (role=switch, aria-checked, keyboard-flippable). + * Used by FeatureCard. Sized to match the spec (44×24px) — see also the + * larger toggles in EventsTab.tsx for comparison; those are checkboxes. + */ +export const Switch: React.FC = ({ checked, onChange, disabled = false, ariaLabel }) => ( + +); diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts index 3c8f7a54..9b5b9d25 100644 --- a/frontend/src/features/settings/index.ts +++ b/frontend/src/features/settings/index.ts @@ -4,6 +4,7 @@ export type { GeneralSettings, SecuritySettings, AnalyticsSettings, EventSetting export { useStatusTab } from './hooks/useStatusTab'; // Tab components +export { FeaturesTab } from './tabs/FeaturesTab'; export { GeneralTab } from './tabs/GeneralTab'; export { EventsTab } from './tabs/EventsTab'; export { StatusTab } from './tabs/StatusTab'; diff --git a/frontend/src/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx new file mode 100644 index 00000000..16435236 --- /dev/null +++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx @@ -0,0 +1,261 @@ +import React from 'react'; +import { + ToggleRight, + Save, + AlertCircle, + Images, + BellRing, + MessageSquare, + CalendarDays, + FileSignature, + Receipt, + BarChart3, + Users, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button, Card } from '../../../components/common'; +import { FeatureCard } from '../components/FeatureCard'; +import { SidebarPreview } from '../components/SidebarPreview'; +import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext'; +import type { FeatureStatus } from '../components/StatusBadge'; + +interface SectionProps { + title: string; + children: React.ReactNode; +} + +const Section: React.FC = ({ title, children }) => ( +
    +

    + {title} +

    +
      {children}
    +
    +); + +export const FeaturesTab: React.FC = () => { + const { t } = useTranslation(); + const { staged, setFlag, save, reset, isDirty, isSaving } = useFeatureFlags(); + + // The localized label shown in StatusBadge — short, uppercased internally. + const statusLabel = (status: FeatureStatus): string => { + const map: Record = { + stable: t('settings.features.status.stable', 'stable'), + beta: t('settings.features.status.beta', 'beta'), + new: t('settings.features.status.new', 'new'), + experimental: t('settings.features.status.experimental', 'experimental'), + roadmap: t('settings.features.status.roadmap', 'roadmap'), + }; + return map[status]; + }; + + // Localized "no sidebar item" caption used by the Reminder Emails card. + const sidebarHiddenLabel = t( + 'settings.features.sidebarHidden', + 'No sidebar item — runs in the background', + ); + + // "Coming soon" lock reason for unbuilt features. Keep wording neutral — + // we don't promise a release date. + const NOT_YET_AVAILABLE = t( + 'settings.features.notYetAvailable', + 'Not yet available — this toggle activates when the feature ships.', + ); + + return ( +
    + + {/* Header */} +
    +
    +
    + +
    +
    +

    + {t('settings.features.title', 'Features')} +

    +

    + {t( + 'settings.features.intro', + 'Turn product surfaces on or off. Enabled features appear in the left navigation and become available to your team. Some features are still in beta — flip them on to try them, off to hide them.', + )} +

    +
    +
    +
    + + {/* Core */} +
    + { /* locked */ }} + disabled + lockedReason={t( + 'settings.features.galleries.locked', + "Galleries are the foundation of PicPeak and can't be turned off.", + )} + /> +
    + + {/* Communication */} +
    + { /* locked */ }} + disabled + lockedReason={NOT_YET_AVAILABLE} + /> + + { /* locked */ }} + disabled + lockedReason={NOT_YET_AVAILABLE} + /> +
    + + {/* Scheduling */} +
    + { /* locked */ }} + disabled + lockedReason={NOT_YET_AVAILABLE} + /> +
    + + {/* Sales */} +
    + { /* locked */ }} + disabled + lockedReason={NOT_YET_AVAILABLE} + /> + + { /* locked */ }} + disabled + lockedReason={NOT_YET_AVAILABLE} + /> +
    + + {/* Insights & Access */} +
    + setFlag('analytics', next)} + /> + + setFlag('userManagement', next)} + warning={t( + 'settings.features.userManagement.warning', + 'Existing user accounts stay valid; the admin UI for managing them will be hidden until you re-enable this.', + )} + /> +
    +
    + + + + {/* Save bar */} +
    + {isDirty && ( + + + {t('settings.features.unsavedChanges', 'You have unsaved changes')} + + )} + + +
    +
    + ); +}; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 634838d8..079aa30f 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -123,7 +123,8 @@ "characters": "Zeichen", "saveChanges": "Änderungen speichern", "resetChanges": "Änderungen zurücksetzen", - "dismiss": "Schließen" + "dismiss": "Schließen", + "discard": "Verwerfen" }, "upload": { "photoCategory": "Fotokategorie", @@ -165,7 +166,8 @@ "eventTypes": "Veranstaltungstypen", "backup": "Backup & Wiederherstellung", "cmsPages": "CMS-Seiten", - "users": "Benutzer" + "users": "Benutzer", + "calendar": "Kalender" }, "eventTypes": { "title": "Veranstaltungsarten", @@ -1359,7 +1361,9 @@ "display": "Anzeige", "privacySecurity": "Datenschutz & Sicherheit", "integrations": "Integrationen", - "system": "System" + "system": "System", + "appearance": "Inhalt & Erscheinungsbild", + "communication": "Kommunikation" }, "apiTokens": { "title": "API-Tokens", @@ -1400,7 +1404,86 @@ "empty": "Noch keine Webhooks. Erstellen Sie oben einen, um Ereignisbenachrichtigungen zu erhalten." }, "sectionLabel": "Einstellungsbereich", - "navAriaLabel": "Einstellungsnavigation" + "navAriaLabel": "Einstellungsnavigation", + "eventTypes": { + "title": "Veranstaltungstypen" + }, + "cms": { + "title": "CMS-Seiten" + }, + "email": { + "title": "E-Mail-Einstellungen" + }, + "backup": { + "title": "Backup" + }, + "branding": { + "title": "Branding" + }, + "features": { + "title": "Funktionen", + "intro": "Schalten Sie Produktbereiche ein oder aus. Aktivierte Funktionen erscheinen in der linken Navigation und werden für Ihr Team verfügbar. Einige Funktionen sind noch im Beta-Status — schalten Sie sie ein, um sie auszuprobieren, oder aus, um sie zu verbergen.", + "notYetAvailable": "Noch nicht verfügbar — dieser Schalter wird aktiv, sobald die Funktion ausgeliefert wird.", + "sidebarHidden": "Kein Sidebar-Eintrag — läuft im Hintergrund", + "unsavedChanges": "Sie haben ungespeicherte Änderungen", + "preview": { + "title": "Sidebar-Vorschau", + "note": "Zeigt ungespeicherte Änderungen", + "legend": "Grün hinterlegte Einträge werden durch die Schalter oben gesteuert." + }, + "sections": { + "core": "Kern", + "communication": "Kommunikation", + "scheduling": "Terminplanung", + "sales": "Vertrieb", + "insights": "Auswertungen & Zugriff" + }, + "status": { + "stable": "stabil", + "beta": "beta", + "new": "neu", + "experimental": "experimentell", + "roadmap": "roadmap" + }, + "galleries": { + "title": "Galerien", + "description": "Der Kern von PicPeak. Immer verfügbar.", + "locked": "Galerien sind die Grundlage von PicPeak und können nicht deaktiviert werden." + }, + "reminderEmails": { + "title": "Erinnerungs-E-Mails", + "description": "Automatische Erinnerungen an Gäste vor Ablauf ihrer Galerie und an Admins über ausstehende Uploads." + }, + "messaging": { + "title": "Nachrichten", + "description": "In-App-Konversationen mit Gästen, an eine Galerie gebunden. E-Mail ist für die meisten Teams völlig ausreichend — das hier ist für Studios, die alles an einem Ort haben möchten.", + "sidebar": "Nachrichten" + }, + "calendar": { + "title": "Kalender", + "description": "Alle anstehenden und vergangenen Veranstaltungen in einer Monats-/Wochenansicht. Optional Buchungen von Kunden annehmen.", + "sidebar": "Kalender" + }, + "quotes": { + "title": "Angebote", + "description": "Senden Sie Angebote mit Einzelposten an Kunden. Diese können über einen öffentlichen Link angenommen oder abgelehnt werden; Zahlungen werden manuell verfolgt.", + "sidebar": "Angebote" + }, + "bills": { + "title": "Rechnungen", + "description": "Generieren Sie eine Rechnung aus jedem angenommenen Angebot. Manuell als bezahlt markieren — keine Integration mit Zahlungsanbietern.", + "sidebar": "Rechnungen" + }, + "analytics": { + "title": "Statistiken", + "description": "Speichernutzung, Galerie-Aufrufe, Download-Zahlen und Statistiken pro Veranstaltung." + }, + "userManagement": { + "title": "Benutzerverwaltung", + "description": "Multi-Admin-Unterstützung mit rollenbasierten Berechtigungen. Deaktivieren Sie dies, wenn Sie ein Einzelbetreiber sind.", + "warning": "Bestehende Benutzerkonten bleiben gültig; die Admin-Oberfläche für deren Verwaltung wird ausgeblendet, bis Sie dies wieder aktivieren." + } + } }, "branding": { "title": "Branding & Themen", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index a70715b7..136af39c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -121,9 +121,10 @@ "done": "Done", "retry": "Retry", "characters": "characters", - "saveChanges": "Save Changes", + "saveChanges": "Save changes", "resetChanges": "Reset Changes", - "dismiss": "Dismiss" + "dismiss": "Dismiss", + "discard": "Discard" }, "upload": { "photoCategory": "Photo Category", @@ -165,7 +166,8 @@ "emailSettings": "Email Settings", "backup": "Backup & Restore", "cmsPages": "CMS Pages", - "users": "Users" + "users": "Users", + "calendar": "Calendar" }, "archives": { "title": "Archives", @@ -998,7 +1000,9 @@ "display": "Display", "privacySecurity": "Privacy & Security", "integrations": "Integrations", - "system": "System" + "system": "System", + "appearance": "Content & Appearance", + "communication": "Communication" }, "apiTokens": { "title": "API Tokens", @@ -1039,7 +1043,86 @@ "empty": "No webhooks yet. Create one above to start receiving event notifications." }, "sectionLabel": "Settings section", - "navAriaLabel": "Settings navigation" + "navAriaLabel": "Settings navigation", + "eventTypes": { + "title": "Event Types" + }, + "cms": { + "title": "CMS Pages" + }, + "email": { + "title": "Email Settings" + }, + "backup": { + "title": "Backup" + }, + "branding": { + "title": "Branding" + }, + "features": { + "title": "Features", + "intro": "Turn product surfaces on or off. Enabled features appear in the left navigation and become available to your team. Some features are still in beta — flip them on to try them, off to hide them.", + "notYetAvailable": "Not yet available — this toggle activates when the feature ships.", + "sidebarHidden": "No sidebar item — runs in the background", + "unsavedChanges": "You have unsaved changes", + "preview": { + "title": "Sidebar preview", + "note": "Reflects unsaved changes", + "legend": "Green tinted items are controlled by toggles above." + }, + "sections": { + "core": "Core", + "communication": "Communication", + "scheduling": "Scheduling", + "sales": "Sales", + "insights": "Insights & Access" + }, + "status": { + "stable": "stable", + "beta": "beta", + "new": "new", + "experimental": "experimental", + "roadmap": "roadmap" + }, + "galleries": { + "title": "Galleries", + "description": "The core PicPeak surface. Always available.", + "locked": "Galleries are the foundation of PicPeak and can't be turned off." + }, + "reminderEmails": { + "title": "Reminder Emails", + "description": "Automatic nudges to guests before their gallery expires and to admins about pending uploads." + }, + "messaging": { + "title": "Messaging", + "description": "In-app threads with guests, attached to a gallery. Email is genuinely fine for most teams — this is for studios that want everything in one place.", + "sidebar": "Messages" + }, + "calendar": { + "title": "Calendar", + "description": "See all upcoming and past events on a month/week view. Optionally accept new bookings from clients.", + "sidebar": "Calendar" + }, + "quotes": { + "title": "Quotes", + "description": "Send line-itemed quotes to clients. They can accept or decline from a public link; payment is tracked manually.", + "sidebar": "Quotes" + }, + "bills": { + "title": "Bills", + "description": "Generate a bill from any accepted quote. Mark paid manually — no payment processor integration.", + "sidebar": "Bills" + }, + "analytics": { + "title": "Analytics", + "description": "Storage usage, gallery views, download counts, and per-event stats." + }, + "userManagement": { + "title": "User Management", + "description": "Multi-admin support with role-based permissions. Turn off if you're a single-operator studio.", + "warning": "Existing user accounts stay valid; the admin UI for managing them will be hidden until you re-enable this." + } + } }, "analytics": { "title": "Analytics Dashboard", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 5db2e568..1be2228a 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -123,7 +123,8 @@ "characters": "tekens", "saveChanges": "Wijzigingen opslaan", "resetChanges": "Wijzigingen ongedaan maken", - "dismiss": "Sluiten" + "dismiss": "Sluiten", + "discard": "Verwerpen" }, "upload": { "photoCategory": "Fotocategorie", @@ -165,7 +166,8 @@ "eventTypes": "Evenementtypes", "backup": "Back-up en herstel", "cmsPages": "CMS-pagina's", - "users": "Gebruikers" + "users": "Gebruikers", + "calendar": "Agenda" }, "archives": { "title": "Archieven", @@ -998,7 +1000,9 @@ "display": "Weergave", "privacySecurity": "Privacy & Beveiliging", "integrations": "Integraties", - "system": "Systeem" + "system": "Systeem", + "appearance": "Inhoud & Weergave", + "communication": "Communicatie" }, "apiTokens": { "title": "API-tokens", @@ -1039,7 +1043,86 @@ "empty": "Nog geen webhooks. Maak er een aan hierboven om evenementmeldingen te ontvangen." }, "sectionLabel": "Instellingensectie", - "navAriaLabel": "Instellingennavigatie" + "navAriaLabel": "Instellingennavigatie", + "eventTypes": { + "title": "Evenementtypes" + }, + "cms": { + "title": "CMS-pagina's" + }, + "email": { + "title": "E-mailinstellingen" + }, + "backup": { + "title": "Back-up" + }, + "branding": { + "title": "Huisstijl" + }, + "features": { + "title": "Functies", + "intro": "Schakel productonderdelen aan of uit. Ingeschakelde functies verschijnen in de linker navigatie en worden beschikbaar voor uw team. Sommige functies zijn nog in beta — schakel ze aan om ze te proberen, uit om ze te verbergen.", + "notYetAvailable": "Nog niet beschikbaar — deze schakelaar wordt actief zodra de functie wordt uitgebracht.", + "sidebarHidden": "Geen item in de zijbalk — draait op de achtergrond", + "unsavedChanges": "U heeft niet-opgeslagen wijzigingen", + "preview": { + "title": "Zijbalk-voorbeeld", + "note": "Toont niet-opgeslagen wijzigingen", + "legend": "Groen gekleurde items worden bestuurd door de schakelaars hierboven." + }, + "sections": { + "core": "Kern", + "communication": "Communicatie", + "scheduling": "Planning", + "sales": "Verkoop", + "insights": "Inzichten & Toegang" + }, + "status": { + "stable": "stabiel", + "beta": "beta", + "new": "nieuw", + "experimental": "experimenteel", + "roadmap": "roadmap" + }, + "galleries": { + "title": "Galerijen", + "description": "Het kernoppervlak van PicPeak. Altijd beschikbaar.", + "locked": "Galerijen zijn de basis van PicPeak en kunnen niet worden uitgeschakeld." + }, + "reminderEmails": { + "title": "Herinneringsmails", + "description": "Automatische herinneringen aan gasten voordat hun galerij verloopt en aan admins over openstaande uploads." + }, + "messaging": { + "title": "Berichten", + "description": "In-app gesprekken met gasten, gekoppeld aan een galerij. E-mail volstaat voor de meeste teams — dit is voor studios die alles op één plek willen.", + "sidebar": "Berichten" + }, + "calendar": { + "title": "Agenda", + "description": "Bekijk alle aankomende en eerdere evenementen in een maand- of weekoverzicht. Accepteer optioneel boekingen van klanten.", + "sidebar": "Agenda" + }, + "quotes": { + "title": "Offertes", + "description": "Stuur offertes met regelitems naar klanten. Ze kunnen accepteren of afwijzen via een openbare link; betaling wordt handmatig bijgehouden.", + "sidebar": "Offertes" + }, + "bills": { + "title": "Facturen", + "description": "Genereer een factuur uit elke geaccepteerde offerte. Markeer handmatig als betaald — geen integratie met betaalproviders.", + "sidebar": "Facturen" + }, + "analytics": { + "title": "Analyses", + "description": "Opslaggebruik, galerijweergaven, downloadtellingen en statistieken per evenement." + }, + "userManagement": { + "title": "Gebruikersbeheer", + "description": "Multi-admin ondersteuning met rolgebaseerde rechten. Schakel dit uit als u een eenmansstudio bent.", + "warning": "Bestaande gebruikersaccounts blijven geldig; de admin-interface om ze te beheren wordt verborgen totdat u dit weer inschakelt." + } + } }, "analytics": { "title": "Statistiekendashboard", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index e50afb72..502125ad 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -121,9 +121,10 @@ "done": "Concluído", "retry": "Tentar novamente", "characters": "caracteres", - "saveChanges": "Guardar alterações", + "saveChanges": "Salvar alterações", "resetChanges": "Repor alterações", - "dismiss": "Dispensar" + "dismiss": "Dispensar", + "discard": "Descartar" }, "upload": { "photoCategory": "Categoria da Foto", @@ -168,7 +169,8 @@ "eventTypes": "Tipos de evento", "backup": "Backup e Restauração", "cmsPages": "Páginas CMS", - "users": "Utilizadores" + "users": "Utilizadores", + "calendar": "Calendário" }, "archives": { "title": "Arquivos", @@ -1015,7 +1017,9 @@ "display": "Apresentação", "privacySecurity": "Privacidade e Segurança", "integrations": "Integrações", - "system": "Sistema" + "system": "Sistema", + "appearance": "Conteúdo e Aparência", + "communication": "Comunicação" }, "apiTokens": { "title": "Tokens de API", @@ -1056,7 +1060,86 @@ "empty": "Ainda sem webhooks. Crie um acima para começar a receber notificações." }, "sectionLabel": "Secção de configurações", - "navAriaLabel": "Navegação de configurações" + "navAriaLabel": "Navegação de configurações", + "eventTypes": { + "title": "Tipos de Evento" + }, + "cms": { + "title": "Páginas CMS" + }, + "email": { + "title": "Configurações de E-mail" + }, + "backup": { + "title": "Backup" + }, + "branding": { + "title": "Identidade Visual" + }, + "features": { + "title": "Recursos", + "intro": "Ative ou desative os recursos do produto. Os recursos ativados aparecem na navegação à esquerda e ficam disponíveis para sua equipe. Alguns recursos ainda estão em beta — ative-os para experimentar ou desative-os para ocultar.", + "notYetAvailable": "Ainda não disponível — este controle será ativado quando o recurso for lançado.", + "sidebarHidden": "Sem item na barra lateral — executa em segundo plano", + "unsavedChanges": "Você tem alterações não salvas", + "preview": { + "title": "Visualização da barra lateral", + "note": "Reflete alterações não salvas", + "legend": "Itens em verde são controlados pelos interruptores acima." + }, + "sections": { + "core": "Núcleo", + "communication": "Comunicação", + "scheduling": "Agendamento", + "sales": "Vendas", + "insights": "Análises & Acesso" + }, + "status": { + "stable": "estável", + "beta": "beta", + "new": "novo", + "experimental": "experimental", + "roadmap": "planejado" + }, + "galleries": { + "title": "Galerias", + "description": "A interface principal do PicPeak. Sempre disponível.", + "locked": "Galerias são a base do PicPeak e não podem ser desativadas." + }, + "reminderEmails": { + "title": "E-mails de Lembrete", + "description": "Lembretes automáticos para convidados antes que sua galeria expire e para administradores sobre uploads pendentes." + }, + "messaging": { + "title": "Mensagens", + "description": "Conversas no aplicativo com convidados, vinculadas a uma galeria. E-mail é suficiente para a maioria das equipes — isto é para estúdios que querem tudo em um só lugar.", + "sidebar": "Mensagens" + }, + "calendar": { + "title": "Calendário", + "description": "Veja todos os eventos futuros e passados em uma visualização mensal/semanal. Opcionalmente aceite novos agendamentos de clientes.", + "sidebar": "Calendário" + }, + "quotes": { + "title": "Orçamentos", + "description": "Envie orçamentos com itens detalhados aos clientes. Eles podem aceitar ou recusar por um link público; o pagamento é controlado manualmente.", + "sidebar": "Orçamentos" + }, + "bills": { + "title": "Faturas", + "description": "Gere uma fatura a partir de qualquer orçamento aceito. Marque como pago manualmente — sem integração com processadores de pagamento.", + "sidebar": "Faturas" + }, + "analytics": { + "title": "Análises", + "description": "Uso de armazenamento, visualizações de galerias, contagens de downloads e estatísticas por evento." + }, + "userManagement": { + "title": "Gerenciamento de Usuários", + "description": "Suporte a múltiplos administradores com permissões baseadas em função. Desative se você é um estúdio individual.", + "warning": "Contas de usuário existentes permanecem válidas; a interface administrativa para gerenciá-las ficará oculta até você reativar isto." + } + } }, "analytics": { "title": "Painel de Análise", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index dfebc4ce..1646a93b 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -123,7 +123,8 @@ "characters": "символов", "saveChanges": "Сохранить изменения", "resetChanges": "Сбросить изменения", - "dismiss": "Закрыть" + "dismiss": "Закрыть", + "discard": "Отменить" }, "upload": { "photoCategory": "Категория фото", @@ -171,7 +172,8 @@ "eventTypes": "Типы событий", "backup": "Резервное копирование", "cmsPages": "CMS-страницы", - "users": "Пользователи" + "users": "Пользователи", + "calendar": "Календарь" }, "archives": { "title": "Архивы", @@ -1032,7 +1034,9 @@ "display": "Отображение", "privacySecurity": "Конфиденциальность и безопасность", "integrations": "Интеграции", - "system": "Система" + "system": "Система", + "appearance": "Контент и оформление", + "communication": "Коммуникации" }, "apiTokens": { "title": "API-токены", @@ -1073,7 +1077,86 @@ "empty": "Вебхуков пока нет. Создайте один выше для получения уведомлений." }, "sectionLabel": "Раздел настроек", - "navAriaLabel": "Навигация по настройкам" + "navAriaLabel": "Навигация по настройкам", + "eventTypes": { + "title": "Типы событий" + }, + "cms": { + "title": "Страницы CMS" + }, + "email": { + "title": "Настройки эл. почты" + }, + "backup": { + "title": "Резервное копирование" + }, + "branding": { + "title": "Брендинг" + }, + "features": { + "title": "Функции", + "intro": "Включайте или отключайте разделы продукта. Включённые функции появляются в левой навигации и становятся доступны вашей команде. Некоторые функции пока в бета-версии — включите их, чтобы попробовать, или выключите, чтобы скрыть.", + "notYetAvailable": "Пока недоступно — этот переключатель станет активным, когда функция будет выпущена.", + "sidebarHidden": "Нет пункта в боковой панели — работает в фоне", + "unsavedChanges": "У вас есть несохранённые изменения", + "preview": { + "title": "Предпросмотр боковой панели", + "note": "Отражает несохранённые изменения", + "legend": "Элементы, выделенные зелёным, управляются переключателями выше." + }, + "sections": { + "core": "Основное", + "communication": "Коммуникации", + "scheduling": "Планирование", + "sales": "Продажи", + "insights": "Аналитика и доступ" + }, + "status": { + "stable": "стабильно", + "beta": "бета", + "new": "новое", + "experimental": "эксперимент", + "roadmap": "планируется" + }, + "galleries": { + "title": "Галереи", + "description": "Основная поверхность PicPeak. Всегда доступна.", + "locked": "Галереи — основа PicPeak, их нельзя отключить." + }, + "reminderEmails": { + "title": "Email-напоминания", + "description": "Автоматические напоминания гостям перед истечением срока действия галереи и администраторам о незавершённых загрузках." + }, + "messaging": { + "title": "Сообщения", + "description": "Внутренние чаты с гостями, привязанные к галерее. Email подходит большинству команд — это для студий, которые хотят держать всё в одном месте.", + "sidebar": "Сообщения" + }, + "calendar": { + "title": "Календарь", + "description": "Все предстоящие и прошедшие события в виде месяца/недели. Дополнительно — приём новых бронирований от клиентов.", + "sidebar": "Календарь" + }, + "quotes": { + "title": "Коммерческие предложения", + "description": "Отправляйте клиентам предложения с разбивкой по позициям. Они могут принять или отклонить по публичной ссылке; оплата отслеживается вручную.", + "sidebar": "Предложения" + }, + "bills": { + "title": "Счета", + "description": "Создавайте счёт из любого принятого предложения. Отмечайте как оплаченные вручную — без интеграции с платёжными провайдерами.", + "sidebar": "Счета" + }, + "analytics": { + "title": "Аналитика", + "description": "Использование хранилища, просмотры галерей, количество загрузок и статистика по событиям." + }, + "userManagement": { + "title": "Управление пользователями", + "description": "Поддержка нескольких администраторов с ролевыми правами. Отключите, если работаете в одиночку.", + "warning": "Существующие учётные записи остаются действительными; интерфейс управления ими будет скрыт до повторного включения." + } + } }, "analytics": { "title": "Панель аналитики", diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx index 35b9da95..e9057b2e 100644 --- a/frontend/src/pages/admin/SettingsPage.tsx +++ b/frontend/src/pages/admin/SettingsPage.tsx @@ -1,6 +1,8 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; import { + ToggleRight, Sliders, CalendarPlus, Activity, @@ -9,16 +11,22 @@ import { Image as ImageIcon, Search, Tags, + Tag, BarChart3, Flag, Code, KeyRound, Webhook, + Mail, + Palette, + FileText, + HardDrive, type LucideIcon, } from 'lucide-react'; import { Loading } from '../../components/common'; import { useSettingsState, + FeaturesTab, GeneralTab, EventsTab, StatusTab, @@ -33,8 +41,34 @@ import { ApiTokensTab, WebhooksTab, } from '../../features/settings'; +import { EmailConfigPage } from './EmailConfigPage'; +import { BrandingPage } from './BrandingPage'; +import { EventTypesPage } from './EventTypesPage'; +import { BackupManagement } from './BackupManagement'; +import { CMSPage } from './CMSPage'; -type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'thumbnails' | 'categories' | 'seo' | 'analytics' | 'moderation' | 'styling' | 'apiTokens' | 'webhooks'; +// Tab keys driving the inner-nav. Must include every key used in +// `navGroups` below and in the switch at the bottom of the component. +type TabType = + | 'features' + | 'general' + | 'events' + | 'eventTypes' + | 'branding' + | 'categories' + | 'thumbnails' + | 'styling' + | 'cms' + | 'email' + | 'moderation' + | 'security' + | 'imageSecurity' + | 'seo' + | 'apiTokens' + | 'webhooks' + | 'status' + | 'analytics' + | 'backup'; interface NavItem { key: TabType; @@ -47,9 +81,49 @@ interface NavGroup { items: NavItem[]; } +const ALL_TAB_KEYS: TabType[] = [ + 'features', 'general', 'events', 'eventTypes', + 'branding', 'categories', 'thumbnails', 'styling', 'cms', + 'email', 'moderation', + 'security', 'imageSecurity', 'seo', + 'apiTokens', 'webhooks', + 'status', 'analytics', 'backup', +]; + +function isValidTab(value: string | null): value is TabType { + return value !== null && (ALL_TAB_KEYS as string[]).includes(value); +} + export const SettingsPage: React.FC = () => { - const [activeTab, setActiveTab] = useState('general'); const { t } = useTranslation(); + const [searchParams, setSearchParams] = useSearchParams(); + + // Read ?tab=… on mount; default to Features per the redesign. + const initialTab: TabType = isValidTab(searchParams.get('tab')) + ? (searchParams.get('tab') as TabType) + : 'features'; + const [activeTab, setActiveTab] = useState(initialTab); + + // Keep URL in sync when the user clicks tabs (so deep-link / back-button + // works and copy-paste of the URL lands the recipient on the same tab). + useEffect(() => { + const current = searchParams.get('tab'); + if (current === activeTab) return; + const next = new URLSearchParams(searchParams); + next.set('tab', activeTab); + setSearchParams(next, { replace: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab]); + + // Reflect external URL changes (e.g. back/forward, redirect-to-tab) back + // into local state. + useEffect(() => { + const urlTab = searchParams.get('tab'); + if (isValidTab(urlTab) && urlTab !== activeTab) { + setActiveTab(urlTab); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); const { isLoading, @@ -98,47 +172,58 @@ export const SettingsPage: React.FC = () => { ); } - // Grouped nav: replaces the previous flat 13-tab horizontal bar that - // overflowed even on 1440px viewports. Categories follow the macOS - // System Settings / Stripe / GitHub pattern — scales to N tabs without - // horizontal scroll, gives visual taxonomy, and surfaces every option. + // Six-group inner nav. Items that previously lived as top-level admin + // routes (Email Settings, Branding, Event Types, Backup, CMS Pages) now + // appear inside their thematic group. The old top-level routes still + // resolve via redirects (App.tsx) so existing bookmarks keep working. const navGroups: NavGroup[] = [ { label: t('settings.groups.general', 'General'), items: [ - { key: 'general', label: t('settings.general.title'), icon: Sliders }, - { key: 'events', label: t('settings.events.title', 'Event Creation'), icon: CalendarPlus }, + { key: 'features', label: t('settings.features.title', 'Features'), icon: ToggleRight }, + { key: 'general', label: t('settings.general.title'), icon: Sliders }, + { key: 'events', label: t('settings.events.title', 'Event Creation'), icon: CalendarPlus }, + { key: 'eventTypes', label: t('settings.eventTypes.title', 'Event Types'), icon: Tag }, ], }, { - label: t('settings.groups.display', 'Display'), + label: t('settings.groups.appearance', 'Content & Appearance'), items: [ - { key: 'categories', label: t('settings.categories.title'), icon: Tags }, - { key: 'thumbnails', label: t('settings.thumbnails.title', 'Thumbnails'), icon: ImageIcon }, - { key: 'styling', label: t('settings.styling.title', 'Custom CSS'), icon: Code }, + { key: 'branding', label: t('settings.branding.title', 'Branding'), icon: Palette }, + { key: 'categories', label: t('settings.categories.title'), icon: Tags }, + { key: 'thumbnails', label: t('settings.thumbnails.title', 'Thumbnails'), icon: ImageIcon }, + { key: 'styling', label: t('settings.styling.title', 'Custom CSS'), icon: Code }, + { key: 'cms', label: t('settings.cms.title', 'CMS Pages'), icon: FileText }, + ], + }, + { + label: t('settings.groups.communication', 'Communication'), + items: [ + { key: 'email', label: t('settings.email.title', 'Email Settings'), icon: Mail }, + { key: 'moderation', label: t('settings.moderation.title', 'Moderation'), icon: Flag }, ], }, { label: t('settings.groups.privacySecurity', 'Privacy & Security'), items: [ - { key: 'security', label: t('settings.security.title'), icon: Lock }, + { key: 'security', label: t('settings.security.title'), icon: Lock }, { key: 'imageSecurity', label: t('settings.imageSecurity.title', 'Image Protection'), icon: Shield }, - { key: 'seo', label: t('settings.seo.title', 'SEO & Robots'), icon: Search }, - { key: 'moderation', label: t('settings.moderation.title', 'Moderation'), icon: Flag }, + { key: 'seo', label: t('settings.seo.title', 'SEO & Robots'), icon: Search }, ], }, { label: t('settings.groups.integrations', 'Integrations'), items: [ { key: 'apiTokens', label: t('settings.apiTokens.title', 'API Tokens'), icon: KeyRound }, - { key: 'webhooks', label: t('settings.webhooks.title', 'Webhooks'), icon: Webhook }, + { key: 'webhooks', label: t('settings.webhooks.title', 'Webhooks'), icon: Webhook }, ], }, { label: t('settings.groups.system', 'System'), items: [ - { key: 'status', label: t('settings.systemStatus.title'), icon: Activity }, - { key: 'analytics', label: t('settings.analytics.title'), icon: BarChart3 }, + { key: 'status', label: t('settings.systemStatus.title'), icon: Activity }, + { key: 'analytics', label: t('settings.analytics.title'), icon: BarChart3 }, + { key: 'backup', label: t('settings.backup.title', 'Backup'), icon: HardDrive }, ], }, ]; @@ -146,6 +231,13 @@ export const SettingsPage: React.FC = () => { const allItems = navGroups.flatMap((g) => g.items); const activeItem = allItems.find((i) => i.key === activeTab) ?? allItems[0]; + // For tabs that mount existing top-level pages OR bring their own + // header (FeaturesTab has its own icon+title+description block), skip + // the Settings shell's section heading so the layout doesn't double + // up. + const TABS_WITH_OWN_HEADER: TabType[] = ['features', 'email', 'branding', 'eventTypes', 'backup', 'cms']; + const showSectionHeading = !TABS_WITH_OWN_HEADER.includes(activeTab); + return (
    @@ -154,8 +246,7 @@ export const SettingsPage: React.FC = () => {
    - {/* Mobile: native select dropdown — keeps every option reachable - in one tap on touch devices, no horizontal scroll. */} + {/* Mobile: native select dropdown */}
    - {/* Desktop: grouped left rail. Sticky so the nav stays visible - while the right pane scrolls through long forms. */} + {/* Desktop: grouped left rail. */}
    - {/* Section heading echoes the active nav item — anchors the user - after they switch, especially after a mobile select change. */} -
    -
    - -

    - {activeItem.label} -

    + {showSectionHeading && ( +
    +
    + +

    + {activeItem.label} +

    +
    -
    + )} - {/* Tab Content */} - {activeTab === 'general' && ( - - )} + {activeTab === 'features' && } - {activeTab === 'events' && ( - - )} + {activeTab === 'general' && ( + + )} - {activeTab === 'status' && ( - - )} + {activeTab === 'events' && ( + + )} - {activeTab === 'security' && ( - - )} + {activeTab === 'eventTypes' && } + {activeTab === 'branding' && } + {activeTab === 'cms' && } + {activeTab === 'email' && } + {activeTab === 'backup' && } - {activeTab === 'seo' && ( - - )} + {activeTab === 'status' && ( + + )} - {activeTab === 'imageSecurity' && } + {activeTab === 'security' && ( + + )} - {activeTab === 'thumbnails' && } + {activeTab === 'seo' && ( + + )} - {activeTab === 'categories' && } + {activeTab === 'imageSecurity' && } + {activeTab === 'thumbnails' && } + {activeTab === 'categories' && } - {activeTab === 'analytics' && ( - - )} + {activeTab === 'analytics' && ( + + )} - {activeTab === 'moderation' && } - - {activeTab === 'styling' && } - - {activeTab === 'apiTokens' && } - {activeTab === 'webhooks' && } + {activeTab === 'moderation' && } + {activeTab === 'styling' && } + {activeTab === 'apiTokens' && } + {activeTab === 'webhooks' && }
    diff --git a/frontend/src/services/featureFlags.service.ts b/frontend/src/services/featureFlags.service.ts new file mode 100644 index 00000000..592686af --- /dev/null +++ b/frontend/src/services/featureFlags.service.ts @@ -0,0 +1,26 @@ +import { api } from '../config/api'; + +export type FeatureKey = + | 'galleries' + | 'reminderEmails' + | 'calendar' + | 'calendarBooking' + | 'quotes' + | 'bills' + | 'messaging' + | 'analytics' + | 'userManagement'; + +export type FeatureFlags = Record; + +export const featureFlagsService = { + async get(): Promise { + const response = await api.get('/admin/feature-flags'); + return response.data; + }, + + async update(flags: Partial): Promise { + const response = await api.put('/admin/feature-flags', flags); + return response.data; + }, +};