feat(branding): Customer dashboard header toggles in Branding page
Adds back the "Show logo" / "Show company name" toggles for the customer dashboard, scoped to /customer/* surfaces only. Lives as a dedicated card at the bottom of Settings → Branding, gated by the customerPortal feature flag so admins who haven't enabled the portal don't see it. * Backend: restored GET/PUT /admin/settings/customer-surface endpoints, whitelisted only to the two branding keys (customer_show_logo, customer_show_company_name). The calendar/quotes/bills feature globals that used to live on this endpoint are now driven by the Features tab (feature_flags table). * customerAccountsService.getCustomerSurfaceGlobals() reads from app_settings again so /api/customer/auth/session honours the toggles in its branding payload. * New CustomerDashboardBrandingCard component with its own save flow — separate from the main BrandingPage payload so flipping a toggle doesn't replay the full branding mutation. Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
@@ -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
|
// Get settings by type
|
||||||
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -761,23 +761,36 @@ async function isCustomerPortalEnabled() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customer-surface global feature toggles. The customer-portal feature
|
* Customer-surface global toggles. Branding visibility (logo /
|
||||||
* flag (read above) is the master switch; calendar / quotes / bills are
|
* company name in the customer dashboard header) lives in
|
||||||
* locked behind their own maintainer-side flags (Settings → Features)
|
* app_settings under setting_type='customer_surface' and is edited
|
||||||
* but those surfaces aren't yet built — return false so the customer
|
* from the Branding page (Customer dashboard card, gated by the
|
||||||
* dashboard doesn't render placeholder tabs.
|
* customerPortal feature flag).
|
||||||
*
|
*
|
||||||
* Branding visibility (logo / company name) is no longer per-instance
|
* Calendar / Quotes / Bills feature globals are intentionally OFF
|
||||||
* configurable on the customer surface — the customer layout always
|
* here — those surfaces are now governed by the maintainer's
|
||||||
* shows the configured brand to keep parity with /admin.
|
* 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() {
|
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 {
|
return {
|
||||||
calendarEnabled: false,
|
calendarEnabled: false,
|
||||||
quotesEnabled: false,
|
quotesEnabled: false,
|
||||||
billsEnabled: false,
|
billsEnabled: false,
|
||||||
showLogo: true,
|
showLogo: map.customer_show_logo !== false, // default true
|
||||||
showCompanyName: true,
|
showCompanyName: map.customer_show_company_name !== false, // default true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<CustomerSurfaceSettings> | 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<ToggleProps> = ({ enabled, onChange, label, hint, icon: Icon }) => (
|
||||||
|
<label className="flex items-start justify-between gap-4 py-3 cursor-pointer">
|
||||||
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
|
<Icon className="w-5 h-5 mt-0.5 text-neutral-500 dark:text-neutral-400 flex-shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{label}</div>
|
||||||
|
{hint && <p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={enabled}
|
||||||
|
onClick={onChange}
|
||||||
|
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
|
style={{ backgroundColor: enabled ? 'var(--color-accent, #5C8762)' : '#cbd5e1' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
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<Partial<CustomerSurfaceSettings>>('/admin/settings/customer-surface');
|
||||||
|
return withDefaults(res.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [form, setForm] = useState<CustomerSurfaceSettings>(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 (
|
||||||
|
<Card padding="md">
|
||||||
|
<div className="py-6 flex justify-center"><Loading size="md" /></div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card padding="md">
|
||||||
|
<div className="flex items-start gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300 flex items-center justify-center flex-shrink-0">
|
||||||
|
<UserCog className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('settings.customerSurface.brandingTitle', 'Customer dashboard header')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||||
|
{t(
|
||||||
|
'settings.customerSurface.brandingHint',
|
||||||
|
'Controls what shows in the header of /customer/dashboard. Public galleries and admin surfaces are not affected.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||||
|
<Toggle
|
||||||
|
enabled={form.customer_show_logo}
|
||||||
|
onChange={() => 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
|
||||||
|
enabled={form.customer_show_company_name}
|
||||||
|
onChange={() => 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
leftIcon={<Save className="w-4 h-4" />}
|
||||||
|
isLoading={saveMutation.isPending}
|
||||||
|
disabled={!isDirty || saveMutation.isPending}
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
>
|
||||||
|
{t('settings.customerSurface.save', 'Save changes')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1499,6 +1499,17 @@
|
|||||||
"title": "Kundenportal",
|
"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)."
|
"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": {
|
"branding": {
|
||||||
|
|||||||
@@ -1138,6 +1138,17 @@
|
|||||||
"title": "Customer portal",
|
"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)."
|
"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": {
|
"analytics": {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { settingsService, type BrandingSettings } from '../../services/settings.service';
|
import { settingsService, type BrandingSettings } from '../../services/settings.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { buildResourceUrl } from '../../utils/url';
|
import { buildResourceUrl } from '../../utils/url';
|
||||||
|
import { useFeatureEnabled } from '../../contexts/FeatureFlagsContext';
|
||||||
|
import { CustomerDashboardBrandingCard } from '../../components/admin/CustomerDashboardBrandingCard';
|
||||||
|
|
||||||
export const BrandingPage: React.FC = () => {
|
export const BrandingPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -905,7 +907,23 @@ export const BrandingPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Customer dashboard branding (#354) — only render when the
|
||||||
|
customerPortal feature flag is on, since these toggles only
|
||||||
|
affect /customer/* surfaces. */}
|
||||||
|
<CustomerDashboardBrandingSection />
|
||||||
</div>
|
</div>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 <CustomerDashboardBrandingCard />;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user