diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 0d8d9493..624d2ac1 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -134,6 +134,88 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) } }); +/** + * Customer-surface branding settings (#354 follow-up). + * + * Two toggles control what shows in the customer dashboard header: + * customer_show_logo (default true) + * customer_show_company_name (default true) + * + * The Calendar / Quotes / Bills feature globals that used to live here + * have moved to the maintainer's Features tab (feature_flags table). + * + * IMPORTANT: both routes MUST be registered before the generic + * `router.get('/:type', ...)` below — Express matches routes in + * registration order. + */ +router.get('/customer-surface', adminAuth, requirePermission('settings.view'), async (req, res) => { + try { + const rows = await db('app_settings') + .where('setting_type', 'customer_surface') + .select('setting_key', 'setting_value'); + + const settings = {}; + for (const r of rows) { + let value = r.setting_value; + if (value === null || value === undefined) { + settings[r.setting_key] = null; + continue; + } + if (typeof value !== 'string') { + settings[r.setting_key] = value; + } else { + try { settings[r.setting_key] = JSON.parse(value); } + catch { settings[r.setting_key] = value; } + } + } + + res.json(settings); + } catch (error) { + console.error('Customer surface settings fetch error:', error); + res.status(500).json({ error: 'Failed to fetch customer surface settings' }); + } +}); + +router.put('/customer-surface', adminAuth, requirePermission('settings.edit'), async (req, res) => { + try { + // Branding-only whitelist (calendar/quotes/bills feature globals + // moved to the Features tab / feature_flags table). + const allowed = [ + 'customer_show_logo', + 'customer_show_company_name', + ]; + const updates = []; + for (const key of allowed) { + if (Object.prototype.hasOwnProperty.call(req.body, key)) { + const value = !!req.body[key]; + updates.push({ setting_key: key, setting_value: JSON.stringify(value), setting_type: 'customer_surface' }); + } + } + + for (const u of updates) { + const existing = await db('app_settings').where('setting_key', u.setting_key).first(); + if (existing) { + await db('app_settings').where('setting_key', u.setting_key).update({ + setting_value: u.setting_value, + setting_type: u.setting_type, + updated_at: new Date(), + }); + } else { + await db('app_settings').insert({ ...u, created_at: new Date(), updated_at: new Date() }); + } + } + + // Clear the public-site cache so any consumer relying on it + // (e.g. customer login footer if it picks these up) refetches. + clearPublicSiteCache(); + + res.json({ message: 'Customer surface settings updated', updated: updates.map((u) => u.setting_key) }); + } catch (error) { + console.error('Customer surface settings save error:', error); + res.status(500).json({ error: 'Failed to save customer surface settings' }); + } +}); + // Get settings by type router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => { try { diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js index 5004c829..3f48593b 100644 --- a/backend/src/services/customerAccountsService.js +++ b/backend/src/services/customerAccountsService.js @@ -761,23 +761,36 @@ async function isCustomerPortalEnabled() { } /** - * Customer-surface global feature toggles. The customer-portal feature - * flag (read above) is the master switch; calendar / quotes / bills are - * locked behind their own maintainer-side flags (Settings → Features) - * but those surfaces aren't yet built — return false so the customer - * dashboard doesn't render placeholder tabs. + * Customer-surface global toggles. Branding visibility (logo / + * company name in the customer dashboard header) lives in + * app_settings under setting_type='customer_surface' and is edited + * from the Branding page (Customer dashboard card, gated by the + * customerPortal feature flag). * - * Branding visibility (logo / company name) is no longer per-instance - * configurable on the customer surface — the customer layout always - * shows the configured brand to keep parity with /admin. + * Calendar / Quotes / Bills feature globals are intentionally OFF + * here — those surfaces are now governed by the maintainer's + * feature_flags table (Settings → Features), not by app_settings. + * + * Returns sane defaults when the keys aren't present so an install + * missing migration 092 doesn't crash — branding defaults ON to + * match the visual state before the toggle existed. */ async function getCustomerSurfaceGlobals() { + const rows = await db('app_settings').where('setting_type', 'customer_surface').select('setting_key', 'setting_value'); + const map = {}; + for (const r of rows) { + let v = r.setting_value; + if (typeof v === 'string') { + try { v = JSON.parse(v); } catch { /* leave as-is */ } + } + map[r.setting_key] = v; + } return { calendarEnabled: false, quotesEnabled: false, billsEnabled: false, - showLogo: true, - showCompanyName: true, + showLogo: map.customer_show_logo !== false, // default true + showCompanyName: map.customer_show_company_name !== false, // default true }; } diff --git a/frontend/src/components/admin/CustomerDashboardBrandingCard.tsx b/frontend/src/components/admin/CustomerDashboardBrandingCard.tsx new file mode 100644 index 00000000..27a8c92d --- /dev/null +++ b/frontend/src/components/admin/CustomerDashboardBrandingCard.tsx @@ -0,0 +1,169 @@ +/** + * Customer dashboard branding card (#354 follow-up). + * + * Two toggles that govern what shows in the /customer/dashboard + * header — the logo and the company-name text. Persisted under + * setting_type='customer_surface' in app_settings via the dedicated + * /admin/settings/customer-surface endpoint, kept separate from the + * main BrandingPage save flow so toggling these doesn't drag the + * full branding payload through a save cycle. + * + * Mounted from BrandingPage and only rendered when the customerPortal + * feature flag is on — see CustomerDashboardBrandingSection in + * BrandingPage.tsx. + */ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { Save, Image as ImageIcon, Type, UserCog } from 'lucide-react'; +import { Button, Card, Loading } from '../common'; +import { api } from '../../config/api'; + +interface CustomerSurfaceSettings { + customer_show_logo: boolean; + customer_show_company_name: boolean; +} + +const DEFAULTS: CustomerSurfaceSettings = { + customer_show_logo: true, + customer_show_company_name: true, +}; + +// Migration 092 seeds these as JSON-encoded booleans. Treat anything +// other than literal false as on, matching the backend defaults so a +// brand-new install (no row yet) shows the same UI as an existing one. +function withDefaults(raw: Partial | null | undefined): CustomerSurfaceSettings { + return { + customer_show_logo: raw?.customer_show_logo !== false, + customer_show_company_name: raw?.customer_show_company_name !== false, + }; +} + +interface ToggleProps { + enabled: boolean; + onChange: () => void; + label: string; + hint?: string; + icon: React.ComponentType<{ className?: string }>; +} + +const Toggle: React.FC = ({ enabled, onChange, label, hint, icon: Icon }) => ( + +); + +export const CustomerDashboardBrandingCard: React.FC = () => { + const { t } = useTranslation(); + const qc = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ['admin-settings-customer-surface'], + queryFn: async () => { + const res = await api.get>('/admin/settings/customer-surface'); + return withDefaults(res.data); + }, + }); + + const [form, setForm] = useState(DEFAULTS); + useEffect(() => { if (data) setForm(data); }, [data]); + + const saveMutation = useMutation({ + mutationFn: () => api.put('/admin/settings/customer-surface', form), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin-settings-customer-surface'] }); + // The customer-side session response (/api/customer/auth/session) + // also bundles these as branding flags — invalidate so a customer + // tab refresh picks up the new visibility on the next focus. + qc.invalidateQueries({ queryKey: ['public-settings'] }); + toast.success(t('settings.customerSurface.saved', 'Customer dashboard branding saved')); + }, + onError: () => toast.error(t('settings.customerSurface.error', 'Could not save settings')), + }); + + const toggle = (key: keyof CustomerSurfaceSettings) => { + setForm((p) => ({ ...p, [key]: !p[key] })); + }; + + const isDirty = data + ? form.customer_show_logo !== data.customer_show_logo + || form.customer_show_company_name !== data.customer_show_company_name + : false; + + if (isLoading) { + return ( + +
+
+ ); + } + + return ( + +
+
+ +
+
+

+ {t('settings.customerSurface.brandingTitle', 'Customer dashboard header')} +

+

+ {t( + 'settings.customerSurface.brandingHint', + 'Controls what shows in the header of /customer/dashboard. Public galleries and admin surfaces are not affected.', + )} +

+
+
+ +
+ toggle('customer_show_logo')} + label={t('settings.customerSurface.showLogo', 'Show logo in customer header')} + hint={t('settings.customerSurface.showLogoHint', 'Uses the same branding logo configured above.')} + icon={ImageIcon} + /> + toggle('customer_show_company_name')} + label={t('settings.customerSurface.showCompanyName', 'Show company name in customer header')} + hint={t('settings.customerSurface.showCompanyNameHint', 'Hide if your logo already includes the company name.')} + icon={Type} + /> +
+ +
+ +
+
+ ); +}; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index fff99ead..a3898a12 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1499,6 +1499,17 @@ "title": "Kundenportal", "description": "Persistente Kunden-Logins. Wiederkehrende Kunden sehen alle zugeordneten Galerien an einem Ort — keine Passwörter pro Event. Grundlage für Kalender / Angebote / Rechnungen (die nur im Kundendashboard erscheinen, wenn diese Option aktiviert ist)." } + }, + "customerSurface": { + "brandingTitle": "Kundendashboard-Kopfzeile", + "brandingHint": "Bestimmt, was in der Kopfzeile von /customer/dashboard angezeigt wird. Öffentliche Galerien und Admin-Oberflächen bleiben unverändert.", + "showLogo": "Logo in der Kundenkopfzeile anzeigen", + "showLogoHint": "Verwendet das oben konfigurierte Branding-Logo.", + "showCompanyName": "Firmennamen in der Kundenkopfzeile anzeigen", + "showCompanyNameHint": "Ausblenden, wenn dein Logo den Firmennamen bereits enthält.", + "save": "Änderungen speichern", + "saved": "Branding des Kundendashboards gespeichert", + "error": "Einstellungen konnten nicht gespeichert werden" } }, "branding": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index bf690413..45c8a168 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1138,6 +1138,17 @@ "title": "Customer portal", "description": "Persistent customer logins. Recurring clients see all their assigned galleries from one place — no per-event passwords. Foundation for Calendar / Quotes / Bills (which only render in the customer dashboard when this is on)." } + }, + "customerSurface": { + "brandingTitle": "Customer dashboard header", + "brandingHint": "Controls what shows in the header of /customer/dashboard. Public galleries and admin surfaces are not affected.", + "showLogo": "Show logo in customer header", + "showLogoHint": "Uses the same branding logo configured above.", + "showCompanyName": "Show company name in customer header", + "showCompanyNameHint": "Hide if your logo already includes the company name.", + "save": "Save changes", + "saved": "Customer dashboard branding saved", + "error": "Could not save settings" } }, "analytics": { diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx index 66ab258a..6aaefee8 100644 --- a/frontend/src/pages/admin/BrandingPage.tsx +++ b/frontend/src/pages/admin/BrandingPage.tsx @@ -8,6 +8,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { settingsService, type BrandingSettings } from '../../services/settings.service'; import { useTranslation } from 'react-i18next'; import { buildResourceUrl } from '../../utils/url'; +import { useFeatureEnabled } from '../../contexts/FeatureFlagsContext'; +import { CustomerDashboardBrandingCard } from '../../components/admin/CustomerDashboardBrandingCard'; export const BrandingPage: React.FC = () => { const { t } = useTranslation(); @@ -905,7 +907,23 @@ export const BrandingPage: React.FC = () => { + + {/* Customer dashboard branding (#354) — only render when the + customerPortal feature flag is on, since these toggles only + affect /customer/* surfaces. */} + ); }; + +/** + * Customer-dashboard branding card. Pulled out so the BrandingPage + * stays readable and the feature-flag gate is local — no conditional + * hooks in the parent. + */ +const CustomerDashboardBrandingSection: React.FC = () => { + const customerPortalEnabled = useFeatureEnabled('customerPortal'); + if (!customerPortalEnabled) return null; + return ; +};