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; + }, +};