Merge pull request #458 from Luca-Timo/fix/customer-functions

fix(customer-portal): post-merge fixes for event save, theme fonts, and customer→gallery handoff
This commit is contained in:
Paul Nothaft
2026-05-11 20:07:11 +02:00
committed by GitHub
37 changed files with 1012 additions and 187 deletions
@@ -0,0 +1,38 @@
/**
* Migration: Add the `clients` top-level feature flag.
*
* Introduces a parent flag for the "Clients" sidebar section, which
* groups customer accounts today and will host calendar / quotes /
* bills / messaging in future PRs. The existing `customerPortal` flag
* is unchanged and continues to gate the /customer/* surface plus the
* Accounts sub-page; it now lives logically beneath `clients` in the
* Features tab.
*
* Initial value: mirrors the install's current `customerPortal` value
* so an admin who had the customer portal enabled keeps seeing the
* Clients sidebar entry after upgrade, and an admin who had it off
* doesn't suddenly see a new sidebar entry.
*
* Idempotent: re-runs are no-ops.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('feature_flags'))) return;
const existing = await knex('feature_flags').where({ key: 'clients' }).first();
if (existing) return;
const portalRow = await knex('feature_flags').where({ key: 'customerPortal' }).first();
let initialValue = false;
if (portalRow) {
const raw = portalRow.value;
initialValue = raw === true || raw === 1 || raw === '1' || raw === 'true';
}
await knex('feature_flags').insert({ key: 'clients', value: initialValue });
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('feature_flags'))) return;
await knex('feature_flags').where({ key: 'clients' }).del();
};
+21 -10
View File
@@ -569,18 +569,29 @@ app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
app.use('/api/admin/events', require('./src/routes/adminEventRename')); app.use('/api/admin/events', require('./src/routes/adminEventRename'));
app.use('/api/admin/users', require('./src/routes/adminUsers')); app.use('/api/admin/users', require('./src/routes/adminUsers'));
// Customer portal (#354). The customerPortal feature flag is enforced // Customer portal (#354). The customerPortal feature flag is enforced
// on the frontend via <RequireFeature flag="customerPortal" /> route // in TWO places:
// guards (App.tsx) and AdminSidebar visibility — when the flag is off, // 1. Frontend: RequireFeature guards + AdminSidebar visibility
// users never reach these endpoints. Defence in depth is provided by // (handles navigation cleanly when an admin is using the app).
// customerAccountsService.isCustomerPortalEnabled() in the few backend // 2. Backend: the requireCustomerPortalEnabled middleware below.
// paths that matter (e.g. adminEvents customer_account_ids handling). // Belt-and-braces — a stale tab, a saved bookmark, or any
// Admin routes are protected by adminAuth; customer routes by // third-party API client trying to hit /api/customer/* or
// customerAuth — so no additional route-level gate is needed. // /api/admin/customers/* gets a 410 Gone the moment the toggle
app.use('/api/admin/customers', require('./src/routes/adminCustomers')); // is flipped off. Includes /api/customer/auth/login: flag off
// = nobody can log in until the admin re-enables, including
// already-issued customers (their sessions still have valid
// JWTs but every API call returns 410 → frontend boots them
// out). PR #458 deliberate departure from the prior design
// that left login alive when the rest of the surface was off.
const {
requireCustomerPortalEnabled,
requireCustomerPortalEnabledAdmin,
} = require('./src/middleware/requireCustomerPortal');
app.use('/api/admin/customers', requireCustomerPortalEnabledAdmin, require('./src/routes/adminCustomers'));
// Customer-side surface (#354). Strictly separate from /api/admin/* — // Customer-side surface (#354). Strictly separate from /api/admin/* —
// distinct token type, distinct cookie, distinct middleware. // distinct token type, distinct cookie, distinct middleware.
app.use('/api/customer/auth', require('./src/routes/customerAuth')); app.use('/api/customer/auth', requireCustomerPortalEnabled, require('./src/routes/customerAuth'));
app.use('/api/customer', require('./src/routes/customer')); app.use('/api/customer', requireCustomerPortalEnabled, require('./src/routes/customer'));
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes')); app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens')); app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks')); app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
@@ -0,0 +1,62 @@
/**
* Customer portal feature-flag gate.
*
* Blocks every /api/customer/* and /api/admin/customers/* endpoint
* when the `customerPortal` flag is off. Returns 410 Gone so the
* frontend can distinguish "feature has been disabled" from "you
* don't have access" (which would be 403) — useful for the customer
* dashboard's auto-redirect on a soft-kill scenario.
*
* Reads the flag via customerAccountsService.isCustomerPortalEnabled
* (which itself reads from the maintainer's feature_flags table),
* so a single source of truth.
*/
const customerAccountsService = require('../services/customerAccountsService');
const logger = require('../utils/logger');
async function isEnabled() {
try {
return await customerAccountsService.isCustomerPortalEnabled();
} catch (err) {
// Defensive: if the lookup throws (DB unavailable, table missing
// mid-migration), fail closed so an enabled-by-default fallback
// can't accidentally expose customer surfaces during boot.
logger.warn('requireCustomerPortal: feature flag lookup failed, treating as off', {
error: err?.message,
});
return false;
}
}
/**
* Customer-facing endpoints. Returns 410 with a code the frontend
* can interpret to clear stale session storage + redirect to
* /admin/login.
*/
async function requireCustomerPortalEnabled(req, res, next) {
if (await isEnabled()) return next();
return res.status(410).json({
error: 'Customer portal is disabled',
code: 'CUSTOMER_PORTAL_DISABLED',
});
}
/**
* Admin-facing /api/admin/customers/* endpoints. Same gate, same
* status code — keeps the contract consistent across both halves of
* the customer-portal surface. The sidebar UI already hides the
* entry, but a stale tab or direct API call must also be blocked.
*/
async function requireCustomerPortalEnabledAdmin(req, res, next) {
if (await isEnabled()) return next();
return res.status(410).json({
error: 'Customer portal is disabled',
code: 'CUSTOMER_PORTAL_DISABLED',
});
}
module.exports = {
requireCustomerPortalEnabled,
requireCustomerPortalEnabledAdmin,
};
+7
View File
@@ -1262,6 +1262,13 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
} }
delete updates.regenerate_client_token; delete updates.regenerate_client_token;
// customer_account_ids (#354) is a body-only field consumed
// separately below by customerAccountsService.setAssignmentsForEvent
// — it isn't a column on the events table, so spreading it into
// the UPDATE statement throws "column does not exist" and crashes
// the entire edit with 500 Failed to update event.
delete updates.customer_account_ids;
// Log the update request for debugging // Log the update request for debugging
logger.debug('Update event request', { logger.debug('Update event request', {
id, id,
+24 -3
View File
@@ -32,8 +32,14 @@ const KNOWN_FLAGS = [
'messaging', 'messaging',
'analytics', 'analytics',
'userManagement', 'userManagement',
// Foundation flag for the customer-side surface (#354). See migration // Top-level "Clients" section (#354 follow-up). Parent flag that
// 094 for the seeding rule. // gates the /admin/clients/* sidebar entry. customerPortal,
// calendar, quotes, bills and messaging are conceptually its
// children — when `clients` is off none of them surface in the
// admin UI even if their individual flags are on.
'clients',
// Customer-side portal surface (#354). Gates /customer/* routes
// and the Accounts sub-page under Clients. See migration 095.
'customerPortal', 'customerPortal',
]; ];
@@ -49,6 +55,7 @@ const DEFAULT_FLAGS = {
messaging: false, messaging: false,
analytics: true, analytics: true,
userManagement: true, userManagement: true,
clients: false,
}; };
async function readAllFlags() { async function readAllFlags() {
@@ -69,13 +76,27 @@ function applyDependencyRules(flags) {
// Sub-features can't outlive their parents. // Sub-features can't outlive their parents.
if (out.quotes === false) out.bills = false; if (out.quotes === false) out.bills = false;
if (out.calendar === false) out.calendarBooking = false; if (out.calendar === false) out.calendarBooking = false;
// Clients parent flag is DERIVED from its children. Admins don't
// toggle it directly in the Features tab — they enable a specific
// sub-feature (Accounts today; Calendar/Quotes/Bills/Messaging
// later) and the Clients sidebar section lights up automatically.
// Computing the value here (rather than only on writes) means GET
// /admin/feature-flags also returns a consistent state if the DB
// ever drifts (e.g. partial migration run).
out.clients = Boolean(
out.customerPortal
// future siblings (out.calendar || out.quotes || out.bills || out.messaging) go here
);
return out; return out;
} }
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => { router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
const flags = await readAllFlags(); const flags = await readAllFlags();
res.json(flags); // Always run the rules so derived flags (e.g. `clients`) and
// hard invariants (galleries always on) are consistent even if
// the DB row is stale or missing.
res.json(applyDependencyRules(flags));
} catch (error) { } catch (error) {
logger.error('Failed to read feature flags', { error: error.message }); logger.error('Failed to read feature flags', { error: error.message });
res.status(500).json({ error: 'Failed to read feature flags' }); res.status(500).json({ error: 'Failed to read feature flags' });
+22
View File
@@ -303,6 +303,16 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_mode, logo_display_mode,
hide_powered_by, hide_powered_by,
force_color_mode, force_color_mode,
// Login-page-only branding (#354 follow-up). Both toggles apply
// exclusively to /admin/login and /customer/login — the gallery
// and admin chrome use their own logo_size / logo_max_height.
// - login_logo_frame_enabled: true (default) renders the tinted
// square behind the logo; false drops it.
// - login_logo_size: 'small' | 'medium' | 'large' | 'xlarge'
// matches the gallery logo_size token set but applies only to
// the two login screens.
login_logo_frame_enabled,
login_logo_size,
// Footer overhaul (#441 + #440). Socials are URL strings (empty // Footer overhaul (#441 + #440). Socials are URL strings (empty
// hides the icon). Promo content is markdown (rendered via // hides the icon). Promo content is markdown (rendered via
// marked → DOMPurify on the frontend, no raw HTML accepted). // marked → DOMPurify on the frontend, no raw HTML accepted).
@@ -330,6 +340,13 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
? 'below_footer' ? 'below_footer'
: 'above_footer'; : 'above_footer';
// Normalize login_logo_size to the same token set as logo_size.
// Anything else falls back to 'medium' on the next render.
const allowedLoginLogoSizes = ['small', 'medium', 'large', 'xlarge'];
const normalizedLoginLogoSize = allowedLoginLogoSizes.includes(login_logo_size)
? login_logo_size
: undefined;
const brandingSettings = { const brandingSettings = {
company_name, company_name,
company_tagline, company_tagline,
@@ -350,6 +367,11 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_mode, logo_display_mode,
hide_powered_by, hide_powered_by,
force_color_mode: normalizedForceColorMode, force_color_mode: normalizedForceColorMode,
// Login-only knobs (only persist when the request actually
// included the key, so a partial PUT from another tab doesn't
// accidentally clear them).
...(login_logo_frame_enabled !== undefined && { login_logo_frame_enabled }),
...(normalizedLoginLogoSize !== undefined && { login_logo_size: normalizedLoginLogoSize }),
// Footer overhaul (#441 + #440). String fields normalize empty/ // Footer overhaul (#441 + #440). String fields normalize empty/
// undefined → '' so the column is always a known type. Only persist // undefined → '' so the column is always a known type. Only persist
// when the request actually included the key (partial PUTs). // when the request actually included the key (partial PUTs).
+9 -6
View File
@@ -48,12 +48,15 @@ const TOKEN_TTL_SECONDS = 24 * 60 * 60; // mirrors admin tokens
// ---- login ------------------------------------------------------------- // ---- login -------------------------------------------------------------
// The customerPortal feature flag deliberately does NOT gate this route. // Flag-gate note: this route IS now gated by the customerPortal feature
// Flipping the master toggle off in Settings → Features hides the admin // flag via the requireCustomerPortalEnabled middleware mounted in
// UI surface (sidebar entry, /admin/customers page) but does not revoke // server.js (`app.use('/api/customer/auth', requireCustomerPortalEnabled, …)`).
// access for customers who already accepted an invitation. To lock out // When the admin flips the toggle off in Settings → Features, every
// existing customers, deactivate their accounts individually // customer-side endpoint — including login — returns 410. The previous
// (customer_accounts.is_active = false) — which IS enforced below. // design left login reachable while the rest of the surface was gated;
// that was confusing and asymmetric. Single source of truth wins.
// To lock out a specific customer without disabling the feature for
// everyone, deactivate the account (customer_accounts.is_active = false).
router.post('/login', [ router.post('/login', [
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'), body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
body('password').isString().notEmpty(), body('password').isString().notEmpty(),
+9
View File
@@ -85,6 +85,15 @@ router.get('/', async (req, res) => {
: settingsObject.branding_force_color_mode === 'light' : settingsObject.branding_force_color_mode === 'light'
? 'light' ? 'light'
: null, : null,
// Login-page-only branding (#354 follow-up). Applies exclusively
// to /admin/login and /customer/login — the rest of the app keeps
// using branding_logo_size / branding_logo_max_height. Default
// true / 'medium' preserves the visual state shipped before the
// toggles existed.
branding_login_logo_frame_enabled: settingsObject.branding_login_logo_frame_enabled !== false,
branding_login_logo_size: ['small', 'medium', 'large', 'xlarge'].includes(settingsObject.branding_login_logo_size)
? settingsObject.branding_login_logo_size
: 'medium',
theme_config: settingsObject.theme_config || null, theme_config: settingsObject.theme_config || null,
default_language: settingsObject.general_default_language || 'en', default_language: settingsObject.general_default_language || 'en',
enable_analytics: settingsObject.general_enable_analytics !== false, enable_analytics: settingsObject.general_enable_analytics !== false,
+42 -7
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate, useParams } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastContainer } from 'react-toastify'; import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
@@ -40,6 +40,7 @@ import {
} from './pages/customer'; } from './pages/customer';
import { CustomerAuthProvider } from './contexts/CustomerAuthContext'; import { CustomerAuthProvider } from './contexts/CustomerAuthContext';
import { AdminLayout, AdminAuthWrapper } from './components/admin'; import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { ClientsLayout } from './components/admin/ClientsLayout';
import { RequireFeature } from './components/admin/RequireFeature'; import { RequireFeature } from './components/admin/RequireFeature';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common'; import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper'; import { MaintenanceWrapper } from './components/MaintenanceWrapper';
@@ -90,6 +91,17 @@ function AnalyticsBootstrap() {
return null; return null;
} }
/**
* Backward-compat redirect for /admin/customers/:id → /admin/clients/accounts/:id.
* Needed because <Navigate to="..."> can't interpolate route params and we
* want stale bookmarks / email links to keep working after the Clients
* section reorg.
*/
function RedirectCustomerDetail() {
const { id } = useParams();
return <Navigate to={`/admin/clients/accounts/${id}`} replace />;
}
function App() { function App() {
// Track dark mode for toast theming // Track dark mode for toast theming
const [toastTheme, setToastTheme] = useState<'light' | 'dark'>('light'); const [toastTheme, setToastTheme] = useState<'light' | 'dark'>('light');
@@ -146,14 +158,37 @@ function App() {
<Route element={<RequireFeature flag="userManagement" />}> <Route element={<RequireFeature flag="userManagement" />}>
<Route path="users" element={<UserManagementPage />} /> <Route path="users" element={<UserManagementPage />} />
</Route> </Route>
{/* Customer accounts (#354) — admin-side management. {/* Clients section (#354 follow-up). Parent route
Hidden from sidebar + redirected away when the gated by the top-level `clients` flag — when off
customerPortal flag is off. */} the sidebar entry is hidden and every /admin/clients/*
<Route element={<RequireFeature flag="customerPortal" />}> URL redirects to /admin/dashboard. Inside, the
<Route path="customers" element={<CustomerManagementPage />} /> ClientsLayout renders a Settings-style sub-nav
<Route path="customers/:id" element={<CustomerDetailPage />} /> and the active sub-feature's page through an
Outlet. Each sub-route is feature-flagged
independently. */}
<Route element={<RequireFeature flag="clients" />}>
<Route path="clients" element={<ClientsLayout />}>
<Route element={<RequireFeature flag="customerPortal" />}>
<Route path="accounts" element={<CustomerManagementPage />} />
<Route path="accounts/:id" element={<CustomerDetailPage />} />
</Route>
{/* Default: send /admin/clients (no sub-path) to
the first available sub-feature. Today that's
always accounts; when calendar/quotes ship they
get their own routes here and the empty-state
in ClientsLayout handles the rare "parent on,
all children off" case. */}
<Route index element={<Navigate to="/admin/clients/accounts" replace />} />
</Route>
</Route> </Route>
{/* Old /admin/customers paths now live under
/admin/clients/accounts. Kept indefinitely as
redirects so existing bookmarks and email links
don't 404. */}
<Route path="customers" element={<Navigate to="/admin/clients/accounts" replace />} />
<Route path="customers/:id" element={<RedirectCustomerDetail />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} /> <Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
+41 -7
View File
@@ -8,7 +8,7 @@ import {
Settings, Settings,
X, X,
Users, Users,
UserCog, Briefcase,
} from 'lucide-react'; } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -27,7 +27,15 @@ interface NavItem {
href: string; href: string;
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
permission?: string | false; permission?: string | false;
/** Single required flag — entry hidden when this is false. */
featureFlag?: FeatureKey; featureFlag?: FeatureKey;
/**
* "At least one of these must be on" — used by the Clients section
* to hide the sidebar entry when the parent flag is on but no
* child sub-feature is enabled. Empty arrays are treated as no
* constraint.
*/
featureFlagsAny?: FeatureKey[];
} }
// Sidebar shape after the Settings reorg (#feature-flags-settings-reorg). // Sidebar shape after the Settings reorg (#feature-flags-settings-reorg).
@@ -47,12 +55,30 @@ const navigation: NavItem[] = [
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' }, { 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.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view', featureFlag: 'userManagement' }, { nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view', featureFlag: 'userManagement' },
// Customer accounts (#354) — separate from admin users (#users.view) // Clients section (#354 follow-up) — admin-side surface for the
// by design: customers log in at /customer/login with their own // CRM-area sub-features. Today this entry leads to /admin/clients
// cookie + token type. Hidden when the customerPortal feature flag // which renders a Settings-style sub-nav with one item (Accounts).
// is OFF (Settings → Features). The corresponding /customer/* routes // When calendar / quotes / bills / messaging ship they slot in as
// also redirect away in that case (see RequireFeature in App.tsx). // additional sub-nav items inside ClientsLayout without needing
{ nameKey: 'navigation.customers', href: '/admin/customers', icon: UserCog, permission: 'customers.view', featureFlag: 'customerPortal' }, // their own top-level sidebar entry.
//
// Gate uses the parent `clients` flag (master). The Accounts page
// itself is independently gated by `customerPortal` inside the
// route tree — that nested check is invisible from here.
//
// `permission: 'customers.view'` is the only Clients-area
// permission today; future sub-features (booking, billing) get
// their own permission keys and the gate here grows into an OR.
{
nameKey: 'navigation.clients', href: '/admin/clients', icon: Briefcase,
permission: 'customers.view',
featureFlag: 'clients',
// Hide the entry when the parent is on but no sub-feature is —
// there's nothing inside ClientsLayout to link to. Add future
// sub-flags (calendar, quotes, bills, messaging) here as they
// ship; the entry reappears the moment any of them is enabled.
featureFlagsAny: ['customerPortal'],
},
]; ];
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => { export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => {
@@ -64,6 +90,14 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
const filteredNavigation = navigation.filter((item) => { const filteredNavigation = navigation.filter((item) => {
if (item.permission && !hasPermission(item.permission as string)) return false; if (item.permission && !hasPermission(item.permission as string)) return false;
if (item.featureFlag && !flags[item.featureFlag]) return false; if (item.featureFlag && !flags[item.featureFlag]) return false;
// featureFlagsAny: entry is hidden when none of the listed
// sub-flags are on, even if the parent flag IS on. Used by
// the Clients section so the sidebar entry only appears when
// there's at least one sub-feature it can link to.
if (item.featureFlagsAny && item.featureFlagsAny.length > 0
&& !item.featureFlagsAny.some((k) => flags[k])) {
return false;
}
return true; return true;
}); });
@@ -0,0 +1,164 @@
/**
* Clients section layout (#354 follow-up).
*
* Wraps /admin/clients/* routes with a Settings-style left sub-nav.
* Today the only sub-nav entry is "Accounts" — when calendar / quotes
* / bills / messaging ship they get added to `navItems` below and
* mounted as nested routes in App.tsx. No placeholder UI; absent
* entries simply don't render.
*
* Visual pattern intentionally mirrors SettingsPage: 220px left rail
* on desktop, native <select> on mobile, accent-dark pill for the
* active item with white icon + label.
*/
import React from 'react';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Briefcase, UserCog } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
interface NavItem {
key: string;
to: string;
label: string;
icon: LucideIcon;
/**
* Feature flag that must be ON for this entry to render. The
* parent `clients` flag has already been verified by the
* RequireFeature gate around this layout, so children only need
* to declare their own sub-flag here.
*/
featureFlag: FeatureKey;
}
export const ClientsLayout: React.FC = () => {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { flags } = useFeatureFlags();
const navItems: NavItem[] = [
{
key: 'accounts',
to: '/admin/clients/accounts',
label: t('clients.subnav.accounts', 'Accounts'),
icon: UserCog,
featureFlag: 'customerPortal',
},
// Add future sub-features here as they ship:
// { key: 'calendar', to: '/admin/clients/calendar', ... featureFlag: 'calendar' }
// { key: 'quotes', to: '/admin/clients/quotes', ... featureFlag: 'quotes' }
// { key: 'bills', to: '/admin/clients/bills', ... featureFlag: 'bills' }
// etc. The empty-state below disappears automatically once any of
// these is enabled.
];
const enabledItems = navItems.filter((item) => flags[item.featureFlag]);
// When the parent `clients` flag is on but no sub-feature is enabled,
// there's nothing to render. Settings → Features is one click away
// and tells the admin exactly what to flip on.
if (enabledItems.length === 0) {
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{t('clients.title', 'Clients')}
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing for recurring clients.')}
</p>
</div>
<div className="rounded-xl border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900 p-8 text-center">
<Briefcase className="w-10 h-10 mx-auto mb-3 text-neutral-400" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
{t('clients.empty.title', 'No client features enabled')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t(
'clients.empty.body',
'Enable Accounts (or another Clients sub-feature) under Settings → Features to get started.',
)}
</p>
</div>
</div>
);
}
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{t('clients.title', 'Clients')}
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing for recurring clients.')}
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-6 lg:gap-8">
{/* Mobile: native select dropdown — keeps every option reachable
in one tap on touch devices, no horizontal scroll. */}
<div className="lg:hidden">
<label htmlFor="clients-section" className="sr-only">
{t('clients.navAriaLabel', 'Clients navigation')}
</label>
<select
id="clients-section"
value={location.pathname}
onChange={(e) => navigate(e.target.value)}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm font-medium text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
{enabledItems.map((item) => (
<option key={item.key} value={item.to}>{item.label}</option>
))}
</select>
</div>
{/* Desktop: sticky left rail */}
<aside className="hidden lg:block">
<nav
aria-label={t('clients.navAriaLabel', 'Clients navigation')}
className="sticky top-6 space-y-1"
>
{enabledItems.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.key}
to={item.to}
className={({ isActive }) =>
`group w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-accent-dark text-white'
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`
}
>
{({ isActive }) => (
<>
<Icon
className={`w-4 h-4 flex-shrink-0 ${
isActive
? 'text-white'
: 'text-neutral-500 dark:text-neutral-400 group-hover:text-neutral-700 dark:group-hover:text-neutral-200'
}`}
/>
<span className="truncate">{item.label}</span>
</>
)}
</NavLink>
);
})}
</nav>
</aside>
<div className="min-w-0">
<Outlet />
</div>
</div>
</div>
);
};
@@ -33,14 +33,17 @@ const labelFor = (c: { email: string; displayName?: string | null; companyName?:
export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabled }) => { export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabled }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// Rules of Hooks: the feature-flag gate (early-return) is moved to
// the very end of this hook list (see end of function). The previous
// shape did `if (!customerPortalEnabled) return null` BEFORE the
// useState/useRef/useEffect calls below, which caused the hook count
// to differ between renders the moment the React Query for
// /admin/feature-flags resolved (first render: enabled=false from
// DEFAULT_FLAGS → return null; second render: enabled=true → hooks
// run → "Rendered more hooks than during the previous render"
// crash). That tanked the entire /admin/events/new page through
// the global error boundary. PR #458 reviewer flag.
const customerPortalEnabled = useFeatureEnabled('customerPortal'); const customerPortalEnabled = useFeatureEnabled('customerPortal');
// Gate the entire picker on the customerPortal feature flag. When off,
// the backend returns 410 on /admin/customers/search anyway, but hiding
// the UI here keeps the event form clean and removes the dangling
// "Customer accounts" label that would otherwise appear above an
// empty/error placeholder.
if (!customerPortalEnabled) return null;
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState<CustomerAccountSummary[]>([]); const [results, setResults] = useState<CustomerAccountSummary[]>([]);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -107,6 +110,14 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
[t] [t]
); );
// Feature-flag gate (deliberately placed AFTER all hooks — see the
// long comment at the top of this component for why). When the
// customerPortal flag is off the backend returns 410 on
// /admin/customers/search anyway, but hiding the UI here keeps the
// event form clean and removes the dangling "Customer accounts"
// label that would otherwise appear above an empty placeholder.
if (!customerPortalEnabled) return null;
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
<label className="block text-sm font-medium text-theme mb-1"> <label className="block text-sm font-medium text-theme mb-1">
@@ -175,7 +186,7 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
</div> </div>
) : results.length === 0 ? ( ) : results.length === 0 ? (
<div className="px-3 py-3 text-sm text-muted-theme"> <div className="px-3 py-3 text-sm text-muted-theme">
{t('events.customerPicker.noResults', 'No matches. Invite this customer from /admin/customers first.')} {t('events.customerPicker.noResults', 'No matches. Invite this customer from Clients → Accounts first.')}
</div> </div>
) : ( ) : (
<ul role="listbox"> <ul role="listbox">
@@ -120,7 +120,7 @@ export const CustomerDashboardBrandingCard: React.FC = () => {
return ( return (
<Card padding="md"> <Card padding="md">
<div className="flex items-start gap-3 mb-4"> <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"> <div className="w-10 h-10 rounded-lg bg-accent-soft text-on-accent-soft flex items-center justify-center flex-shrink-0">
<UserCog className="w-5 h-5" /> <UserCog className="w-5 h-5" />
</div> </div>
<div> <div>
+16 -11
View File
@@ -19,11 +19,14 @@ export const DEFAULT_FLAGS: FeatureFlags = {
messaging: false, messaging: false,
analytics: true, analytics: true,
userManagement: true, userManagement: true,
// Customer portal (#354) defaults OFF on a fresh install — picpeak // Top-level Clients section (#354 follow-up). Migration 097 mirrors
// the install's current customerPortal value so admins who already
// had the portal enabled keep seeing the section after upgrade.
clients: false,
// Customer portal (#354). Defaults OFF on a fresh install — picpeak
// ships as a focused gallery delivery tool, recurring-customer // ships as a focused gallery delivery tool, recurring-customer
// logins are opt-in. Migration 094 flips this to TRUE on existing // logins are opt-in. Migration 095 flips this to TRUE on existing
// installs (events>0) so the customer-portal foundation isn't // installs (events>0).
// silently disabled mid-deployment.
customerPortal: false, customerPortal: false,
}; };
@@ -56,13 +59,15 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
out.galleries = true; // foundation — always on out.galleries = true; // foundation — always on
if (out.quotes === false) out.bills = false; // bills depend on quotes if (out.quotes === false) out.bills = false; // bills depend on quotes
if (out.calendar === false) out.calendarBooking = false; // booking depends on calendar if (out.calendar === false) out.calendarBooking = false; // booking depends on calendar
// Customer-portal-dependent flags: if the customer portal is OFF the // Clients parent flag is DERIVED from its children. Admins don't
// customer-side dashboard never renders, so the calendar/quotes/bills/ // toggle it directly — enabling any CRM-area sub-feature
// messaging customer-side surfaces have nowhere to live. Their server // (Accounts today; future Calendar / Quotes / Bills / Messaging)
// toggles can stay at whatever the admin set (so re-enabling the // lights up the Clients sidebar section automatically, and
// portal restores the previous state), but the dependency is // disabling all of them hides it again.
// documented here for the FeaturesTab UI to disable child cards out.clients = Boolean(
// visually when customerPortal is off. out.customerPortal
// future siblings: || out.calendar || out.quotes || out.bills || out.messaging
);
return out; return out;
} }
+12 -3
View File
@@ -186,9 +186,18 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
void loadFontForFamily(themeConfig.fontFamily); void loadFontForFamily(themeConfig.fontFamily);
} }
if (themeConfig.headingFontFamily) { // "Same as body" is stored as headingFontFamily='' — in that case
root.style.setProperty('--heading-font-family', themeConfig.headingFontFamily); // mirror the body family so a stale --heading-font-family (e.g.
void loadFontForFamily(themeConfig.headingFontFamily); // from a previously-visited gallery with a serif heading theme)
// doesn't bleed into pages that picked the matched-fonts option.
// Without this fall-through the CSS variable retained the last
// explicit value across theme switches, which is why the customer
// profile and admin login rendered serif headings even though the
// active theme had "Same as body" selected.
const effectiveHeadingFont = themeConfig.headingFontFamily || themeConfig.fontFamily;
if (effectiveHeadingFont) {
root.style.setProperty('--heading-font-family', effectiveHeadingFont);
void loadFontForFamily(effectiveHeadingFont);
} }
if (themeConfig.borderRadius) { if (themeConfig.borderRadius) {
@@ -46,12 +46,15 @@ export const FeatureCard: React.FC<FeatureCardProps> = ({
)} )}
> >
<div className="flex items-start gap-4 p-5"> <div className="flex items-start gap-4 p-5">
{/* Icon tile */} {/* Icon tile — enabled state uses the admin's CI accent (via
.bg-accent-soft + .text-on-accent-soft) so it follows the
configured brand palette. The foreground token resolves to
a high-contrast colour in both light and dark mode. */}
<div <div
className={clsx( className={clsx(
'flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center', 'flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center',
enabled enabled
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' ? 'bg-accent-soft text-on-accent-soft'
: 'bg-neutral-100 text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400', : 'bg-neutral-100 text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400',
)} )}
> >
@@ -60,8 +60,12 @@ export const SidebarPreview: React.FC<SidebarPreviewProps> = ({ staged }) => {
key={item.key} key={item.key}
className={clsx( className={clsx(
'inline-flex items-center gap-2 px-2.5 py-1.5 rounded-md text-xs font-medium border', 'inline-flex items-center gap-2 px-2.5 py-1.5 rounded-md text-xs font-medium border',
// Feature-driven pills pick up the admin's CI accent via
// .bg-accent-soft / .border-accent-soft, with
// .text-on-accent-soft as the legible foreground (the
// accent token itself washes out on its own tint).
item.featureDriven item.featureDriven
? 'border-primary-200 bg-primary-50 text-primary-800 dark:border-primary-800 dark:bg-primary-900/30 dark:text-primary-200' ? 'border-accent-soft bg-accent-soft text-on-accent-soft'
: 'border-neutral-200 bg-neutral-50 text-neutral-700 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300', : 'border-neutral-200 bg-neutral-50 text-neutral-700 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
)} )}
> >
@@ -73,7 +77,7 @@ export const SidebarPreview: React.FC<SidebarPreviewProps> = ({ staged }) => {
<p className="mt-3 text-xs text-neutral-500 dark:text-neutral-400"> <p className="mt-3 text-xs text-neutral-500 dark:text-neutral-400">
{t( {t(
'settings.features.preview.legend', 'settings.features.preview.legend',
'Green tinted items are controlled by toggles above.', 'Accent-tinted items are controlled by toggles above.',
)} )}
</p> </p>
</Card> </Card>
@@ -12,6 +12,7 @@ import {
BarChart3, BarChart3,
Users, Users,
UserCog, UserCog,
Briefcase,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Card } from '../../../components/common'; import { Button, Card } from '../../../components/common';
@@ -69,7 +70,7 @@ export const FeaturesTab: React.FC = () => {
{/* Header */} {/* Header */}
<div className="mb-6 pb-4 border-b border-neutral-200 dark:border-neutral-700"> <div className="mb-6 pb-4 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<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"> <div className="w-10 h-10 rounded-lg bg-accent-soft text-on-accent-soft flex items-center justify-center">
<ToggleRight className="w-5 h-5" /> <ToggleRight className="w-5 h-5" />
</div> </div>
<div> <div>
@@ -108,24 +109,30 @@ export const FeaturesTab: React.FC = () => {
/> />
</Section> </Section>
{/* Customer accounts (#354) — recurring customer logins. The {/* Clients (#354 follow-up). Visual grouping for the CRM-area
calendar / quotes / bills / messaging cards below are sub-features. The "Clients" sidebar section itself is gated
customer-side surfaces; they only render in the customer by a derived `clients` flag (computed from whether any
dashboard when this is on. */} child below is on), so there's no explicit parent toggle —
<Section title={t('settings.features.sections.customers', 'Customers')}> admins just enable the specific feature they want and the
section appears automatically. */}
<Section title={t('settings.features.sections.clients', 'Clients')}>
<FeatureCard <FeatureCard
icon={UserCog} icon={UserCog}
title={t('settings.features.customerPortal.title', 'Customer portal')} title={t('settings.features.customerPortal.title', 'Accounts')}
description={t( description={t(
'settings.features.customerPortal.description', 'settings.features.customerPortal.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).', 'Persistent customer logins. Recurring clients see all their assigned galleries from one place — no per-event passwords. Customers log in at /customer/login and you manage them under Clients → Accounts.',
)} )}
status="beta" status="beta"
statusLabel={statusLabel('beta')} statusLabel={statusLabel('beta')}
sidebarLabel={t('navigation.customers', 'Customers')} sidebarLabel={t('clients.subnav.accounts', 'Accounts')}
enabled={staged.customerPortal} enabled={staged.customerPortal}
onToggle={(next) => setFlag('customerPortal', next)} onToggle={(next) => setFlag('customerPortal', next)}
/> />
{/* Future sub-features (Calendar / Quotes / Bills / Messaging)
slot in here as FeatureCard entries when they ship. No
placeholder cards today — the Clients section just shows
what's actually built. */}
</Section> </Section>
{/* Communication */} {/* Communication */}
+32 -4
View File
@@ -168,7 +168,7 @@
"cmsPages": "CMS-Seiten", "cmsPages": "CMS-Seiten",
"users": "Benutzer", "users": "Benutzer",
"calendar": "Kalender", "calendar": "Kalender",
"customers": "Kunden" "clients": "Kunden"
}, },
"eventTypes": { "eventTypes": {
"title": "Veranstaltungsarten", "title": "Veranstaltungsarten",
@@ -1449,7 +1449,8 @@
"scheduling": "Terminplanung", "scheduling": "Terminplanung",
"sales": "Vertrieb", "sales": "Vertrieb",
"insights": "Auswertungen & Zugriff", "insights": "Auswertungen & Zugriff",
"customers": "Kunden" "customers": "Kunden",
"clients": "Kunden"
}, },
"status": { "status": {
"stable": "stabil", "stable": "stabil",
@@ -1497,8 +1498,12 @@
"warning": "Bestehende Benutzerkonten bleiben gültig; die Admin-Oberfläche für deren Verwaltung wird ausgeblendet, bis Sie dies wieder aktivieren." "warning": "Bestehende Benutzerkonten bleiben gültig; die Admin-Oberfläche für deren Verwaltung wird ausgeblendet, bis Sie dies wieder aktivieren."
}, },
"customerPortal": { "customerPortal": {
"title": "Kundenportal", "title": "Konten",
"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 zugewiesenen Galerien an einem Ort — keine Passwörter pro Event. Kunden melden sich unter /customer/login an, verwaltet werden sie unter Kunden → Konten."
},
"clients": {
"title": "Kunden",
"description": "Hauptschalter für den Kunden-Bereich in der Seitenleiste. Aus blendet den Bereich und alle Unterfunktionen aus, unabhängig von deren individuellen Schaltern beim erneuten Einschalten wird der vorherige Zustand wiederhergestellt."
} }
}, },
"customerSurface": { "customerSurface": {
@@ -1771,6 +1776,17 @@
"straight": "Gerade", "straight": "Gerade",
"angle": "Winkel", "angle": "Winkel",
"curve": "Kurve" "curve": "Kurve"
},
"loginLogo": {
"title": "Logo auf Login-Seiten",
"subtitle": "Wirkt nur auf /admin/login und /customer/login. Galerie und Admin-Chrome verwenden die Logo-Einstellungen oben.",
"frame": "Hellen Rahmen hinter dem Logo anzeigen",
"frameHelp": "Wenn aus, sitzt das Logo direkt auf dem Seitenhintergrund. Nützlich für Logos, die bereits einen eigenen Hintergrund haben.",
"size": "Logogröße auf den Login-Seiten",
"sizeSmall": "Klein",
"sizeMedium": "Mittel (Standard)",
"sizeLarge": "Groß",
"sizeXLarge": "Sehr groß"
} }
}, },
"admin": { "admin": {
@@ -2843,5 +2859,17 @@
"success": "Kunde gelöscht", "success": "Kunde gelöscht",
"error": "Kunde konnte nicht gelöscht werden" "error": "Kunde konnte nicht gelöscht werden"
} }
},
"clients": {
"title": "Kunden",
"subtitle": "Kundenkonten, Termine, Angebote und Rechnungen für wiederkehrende Kunden.",
"navAriaLabel": "Kunden-Navigation",
"empty": {
"title": "Keine Kunden-Funktionen aktiviert",
"body": "Aktiviere „Konten\" (oder eine andere Kunden-Unterfunktion) unter Einstellungen → Funktionen, um loszulegen."
},
"subnav": {
"accounts": "Konten"
}
} }
} }
+32 -4
View File
@@ -168,7 +168,7 @@
"cmsPages": "CMS Pages", "cmsPages": "CMS Pages",
"users": "Users", "users": "Users",
"calendar": "Calendar", "calendar": "Calendar",
"customers": "Customers" "clients": "Clients"
}, },
"archives": { "archives": {
"title": "Archives", "title": "Archives",
@@ -1088,7 +1088,8 @@
"scheduling": "Scheduling", "scheduling": "Scheduling",
"sales": "Sales", "sales": "Sales",
"insights": "Insights & Access", "insights": "Insights & Access",
"customers": "Customers" "customers": "Customers",
"clients": "Clients"
}, },
"status": { "status": {
"stable": "stable", "stable": "stable",
@@ -1136,8 +1137,12 @@
"warning": "Existing user accounts stay valid; the admin UI for managing them will be hidden until you re-enable this." "warning": "Existing user accounts stay valid; the admin UI for managing them will be hidden until you re-enable this."
}, },
"customerPortal": { "customerPortal": {
"title": "Customer portal", "title": "Accounts",
"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. Customers log in at /customer/login and you manage them under Clients → Accounts."
},
"clients": {
"title": "Clients",
"description": "Master switch for the Clients sidebar section. Off hides the section and every sub-feature below regardless of their individual toggles — re-enable to restore them to whatever you set last."
} }
}, },
"customerSurface": { "customerSurface": {
@@ -1441,6 +1446,17 @@
"angle": "Angle", "angle": "Angle",
"curve": "Curve", "curve": "Curve",
"none": "None" "none": "None"
},
"loginLogo": {
"title": "Login pages logo",
"subtitle": "Controls only /admin/login and /customer/login. Gallery and admin chrome use the logo settings above.",
"frame": "Show tinted frame behind the logo",
"frameHelp": "When off, the logo sits directly on the page background. Useful for logos that already include their own backdrop.",
"size": "Logo size on login pages",
"sizeSmall": "Small",
"sizeMedium": "Medium (default)",
"sizeLarge": "Large",
"sizeXLarge": "Extra large"
} }
}, },
"admin": { "admin": {
@@ -2843,5 +2859,17 @@
"success": "Customer erased", "success": "Customer erased",
"error": "Could not erase customer" "error": "Could not erase customer"
} }
},
"clients": {
"title": "Clients",
"subtitle": "Customer accounts, scheduling, quotes and billing for recurring clients.",
"navAriaLabel": "Clients navigation",
"empty": {
"title": "No client features enabled",
"body": "Enable Accounts (or another Clients sub-feature) under Settings → Features to get started."
},
"subnav": {
"accounts": "Accounts"
}
} }
} }
+32 -4
View File
@@ -169,7 +169,7 @@
"backup": "Sauvegarde et Restauration", "backup": "Sauvegarde et Restauration",
"cmsPages": "Pages CMS", "cmsPages": "Pages CMS",
"users": "Utilisateurs", "users": "Utilisateurs",
"customers": "Clients" "clients": "Clients"
}, },
"archives": { "archives": {
"title": "Archives", "title": "Archives",
@@ -1071,11 +1071,16 @@
"navAriaLabel": "Navigation des paramètres", "navAriaLabel": "Navigation des paramètres",
"features": { "features": {
"sections": { "sections": {
"customers": "Clients" "customers": "Clients",
"clients": "Clients"
}, },
"customerPortal": { "customerPortal": {
"title": "Portail client", "title": "Comptes",
"description": "Connexions client persistantes. Les clients récurrents voient toutes leurs galeries assignées au même endroit — plus de mots de passe par événement. Base pour Calendrier / Devis / Factures (qui n'apparaissent dans le tableau de bord client que lorsque cette option est activée)." "description": "Connexions client persistantes. Les clients récurrents voient toutes leurs galeries assignées au même endroit — pas de mots de passe par événement. Les clients se connectent sur /customer/login et vous les gérez sous Clients → Comptes."
},
"clients": {
"title": "Clients",
"description": "Interrupteur principal pour la section Clients de la barre latérale. Désactivé, masque la section et toutes les sous-fonctionnalités quelles que soient leurs bascules individuelles — réactiver les restaure dans leur état précédent."
} }
}, },
"customerSurface": { "customerSurface": {
@@ -1379,6 +1384,17 @@
"straight": "Droit", "straight": "Droit",
"angle": "Angle", "angle": "Angle",
"curve": "Courbe" "curve": "Courbe"
},
"loginLogo": {
"title": "Logo des pages de connexion",
"subtitle": "S'applique uniquement à /admin/login et /customer/login. La galerie et l'interface admin utilisent les paramètres de logo ci-dessus.",
"frame": "Afficher le cadre teinté derrière le logo",
"frameHelp": "Lorsque désactivé, le logo se pose directement sur l'arrière-plan de la page. Utile pour les logos qui ont déjà leur propre fond.",
"size": "Taille du logo sur les pages de connexion",
"sizeSmall": "Petite",
"sizeMedium": "Moyenne (par défaut)",
"sizeLarge": "Grande",
"sizeXLarge": "Très grande"
} }
}, },
"admin": { "admin": {
@@ -2797,5 +2813,17 @@
"success": "Client effacé", "success": "Client effacé",
"error": "Impossible d'effacer le client" "error": "Impossible d'effacer le client"
} }
},
"clients": {
"title": "Clients",
"subtitle": "Comptes clients, planification, devis et facturation pour les clients récurrents.",
"navAriaLabel": "Navigation Clients",
"empty": {
"title": "Aucune fonctionnalité client activée",
"body": "Activez Comptes (ou une autre sous-fonctionnalité Clients) dans Paramètres → Fonctionnalités pour commencer."
},
"subnav": {
"accounts": "Comptes"
}
} }
} }
+32 -4
View File
@@ -168,7 +168,7 @@
"cmsPages": "CMS-pagina's", "cmsPages": "CMS-pagina's",
"users": "Gebruikers", "users": "Gebruikers",
"calendar": "Agenda", "calendar": "Agenda",
"customers": "Klanten" "clients": "Klanten"
}, },
"archives": { "archives": {
"title": "Archieven", "title": "Archieven",
@@ -1088,7 +1088,8 @@
"scheduling": "Planning", "scheduling": "Planning",
"sales": "Verkoop", "sales": "Verkoop",
"insights": "Inzichten & Toegang", "insights": "Inzichten & Toegang",
"customers": "Klanten" "customers": "Klanten",
"clients": "Klanten"
}, },
"status": { "status": {
"stable": "stabiel", "stable": "stabiel",
@@ -1136,8 +1137,12 @@
"warning": "Bestaande gebruikersaccounts blijven geldig; de admin-interface om ze te beheren wordt verborgen totdat u dit weer inschakelt." "warning": "Bestaande gebruikersaccounts blijven geldig; de admin-interface om ze te beheren wordt verborgen totdat u dit weer inschakelt."
}, },
"customerPortal": { "customerPortal": {
"title": "Klantenportaal", "title": "Accounts",
"description": "Permanente klantlogins. Terugkerende klanten zien al hun toegewezen galerijen op één plek — geen wachtwoorden per evenement. Basis voor Agenda / Offertes / Facturen (die alleen in het klantdashboard verschijnen als dit aan staat)." "description": "Permanente klantlogins. Terugkerende klanten zien al hun toegewezen galerijen op één plek — geen wachtwoorden per evenement. Klanten loggen in op /customer/login en je beheert ze onder Klanten → Accounts."
},
"clients": {
"title": "Klanten",
"description": "Hoofdschakelaar voor het Klanten-onderdeel in de zijbalk. Uitgeschakeld verbergt het onderdeel en alle subfuncties ongeacht hun individuele schakelaars — opnieuw inschakelen herstelt de vorige status."
} }
}, },
"customerSurface": { "customerSurface": {
@@ -1441,6 +1446,17 @@
"straight": "Recht", "straight": "Recht",
"angle": "Hoek", "angle": "Hoek",
"curve": "Boog" "curve": "Boog"
},
"loginLogo": {
"title": "Logo op loginpagina",
"subtitle": "Geldt alleen voor /admin/login en /customer/login. Galerij en beheer gebruiken de logo-instellingen hierboven.",
"frame": "Getint kader achter het logo tonen",
"frameHelp": "Wanneer uitgeschakeld staat het logo direct op de pagina-achtergrond. Handig voor logo's met een eigen achtergrond.",
"size": "Logogrootte op loginpagina's",
"sizeSmall": "Klein",
"sizeMedium": "Middel (standaard)",
"sizeLarge": "Groot",
"sizeXLarge": "Extra groot"
} }
}, },
"admin": { "admin": {
@@ -2843,5 +2859,17 @@
"success": "Klant gewist", "success": "Klant gewist",
"error": "Klant kon niet worden gewist" "error": "Klant kon niet worden gewist"
} }
},
"clients": {
"title": "Klanten",
"subtitle": "Klantaccounts, planning, offertes en facturering voor terugkerende klanten.",
"navAriaLabel": "Klanten-navigatie",
"empty": {
"title": "Geen klantfuncties ingeschakeld",
"body": "Schakel Accounts (of een andere Klanten-subfunctie) in onder Instellingen → Functies om te beginnen."
},
"subnav": {
"accounts": "Accounts"
}
} }
} }
+32 -4
View File
@@ -171,7 +171,7 @@
"cmsPages": "Páginas CMS", "cmsPages": "Páginas CMS",
"users": "Utilizadores", "users": "Utilizadores",
"calendar": "Calendário", "calendar": "Calendário",
"customers": "Clientes" "clients": "Clientes"
}, },
"archives": { "archives": {
"title": "Arquivos", "title": "Arquivos",
@@ -1105,7 +1105,8 @@
"scheduling": "Agendamento", "scheduling": "Agendamento",
"sales": "Vendas", "sales": "Vendas",
"insights": "Análises & Acesso", "insights": "Análises & Acesso",
"customers": "Clientes" "customers": "Clientes",
"clients": "Clientes"
}, },
"status": { "status": {
"stable": "estável", "stable": "estável",
@@ -1153,8 +1154,12 @@
"warning": "Contas de usuário existentes permanecem válidas; a interface administrativa para gerenciá-las ficará oculta até você reativar isto." "warning": "Contas de usuário existentes permanecem válidas; a interface administrativa para gerenciá-las ficará oculta até você reativar isto."
}, },
"customerPortal": { "customerPortal": {
"title": "Portal do cliente", "title": "Contas",
"description": "Logins persistentes para clientes. Clientes recorrentes veem todas as galerias atribuídas em um só lugar — sem senhas por evento. Base para Calendário / Orçamentos / Faturas (que só aparecem no painel do cliente quando isto está ativo)." "description": "Logins persistentes para clientes. Clientes recorrentes veem todas as galerias atribuídas em um só lugar — sem senhas por evento. Os clientes entram em /customer/login e você os gerencia em Clientes → Contas."
},
"clients": {
"title": "Clientes",
"description": "Chave principal da seção Clientes na barra lateral. Desligado oculta a seção e todas as sub-funcionalidades independentemente dos seus controles individuais — reativar restaura o estado anterior."
} }
}, },
"customerSurface": { "customerSurface": {
@@ -1458,6 +1463,17 @@
"straight": "Reto", "straight": "Reto",
"angle": "Ângulo", "angle": "Ângulo",
"curve": "Curva" "curve": "Curva"
},
"loginLogo": {
"title": "Logo nas páginas de login",
"subtitle": "Aplica-se apenas a /admin/login e /customer/login. A galeria e a interface admin usam as configurações de logo acima.",
"frame": "Mostrar moldura tonalizada atrás do logo",
"frameHelp": "Quando desativado, o logo fica diretamente sobre o fundo da página. Útil para logos que já incluem seu próprio fundo.",
"size": "Tamanho do logo nas páginas de login",
"sizeSmall": "Pequeno",
"sizeMedium": "Médio (padrão)",
"sizeLarge": "Grande",
"sizeXLarge": "Extra grande"
} }
}, },
"admin": { "admin": {
@@ -2876,5 +2892,17 @@
"success": "Cliente apagado", "success": "Cliente apagado",
"error": "Não foi possível apagar o cliente" "error": "Não foi possível apagar o cliente"
} }
},
"clients": {
"title": "Clientes",
"subtitle": "Contas de clientes, agendamento, orçamentos e faturamento para clientes recorrentes.",
"navAriaLabel": "Navegação de Clientes",
"empty": {
"title": "Nenhuma funcionalidade de cliente ativada",
"body": "Ative Contas (ou outra sub-funcionalidade de Clientes) em Configurações → Recursos para começar."
},
"subnav": {
"accounts": "Contas"
}
} }
} }
+32 -4
View File
@@ -174,7 +174,7 @@
"cmsPages": "CMS-страницы", "cmsPages": "CMS-страницы",
"users": "Пользователи", "users": "Пользователи",
"calendar": "Календарь", "calendar": "Календарь",
"customers": "Клиенты" "clients": "Клиенты"
}, },
"archives": { "archives": {
"title": "Архивы", "title": "Архивы",
@@ -1122,7 +1122,8 @@
"scheduling": "Планирование", "scheduling": "Планирование",
"sales": "Продажи", "sales": "Продажи",
"insights": "Аналитика и доступ", "insights": "Аналитика и доступ",
"customers": "Клиенты" "customers": "Клиенты",
"clients": "Клиенты"
}, },
"status": { "status": {
"stable": "стабильно", "stable": "стабильно",
@@ -1170,8 +1171,12 @@
"warning": "Существующие учётные записи остаются действительными; интерфейс управления ими будет скрыт до повторного включения." "warning": "Существующие учётные записи остаются действительными; интерфейс управления ими будет скрыт до повторного включения."
}, },
"customerPortal": { "customerPortal": {
"title": "Клиентский портал", "title": "Учётные записи",
"description": "Постоянные логины клиентов. Постоянные клиенты видят все назначенные галереи в одном месте — без паролей для каждого события. Основа для Календаря / Смет / Счетов (которые отображаются в клиентской панели только при включённой опции)." "description": "Постоянные учётные записи клиентов. Постоянные клиенты видят все назначенные галереи в одном месте — без паролей для каждого события. Клиенты входят на /customer/login, а вы управляете ими в разделе Клиенты → Учётные записи."
},
"clients": {
"title": "Клиенты",
"description": "Главный переключатель раздела «Клиенты» в боковой панели. Выключено — раздел и все подфункции скрыты независимо от их отдельных переключателей. При повторном включении состояние подфункций восстанавливается."
} }
}, },
"customerSurface": { "customerSurface": {
@@ -1475,6 +1480,17 @@
"straight": "Прямой", "straight": "Прямой",
"angle": "Угол", "angle": "Угол",
"curve": "Кривая" "curve": "Кривая"
},
"loginLogo": {
"title": "Логотип на страницах входа",
"subtitle": "Применяется только к /admin/login и /customer/login. Галерея и админ-панель используют настройки логотипа выше.",
"frame": "Показывать тонированный фон за логотипом",
"frameHelp": "Если выключено, логотип будет на фоне страницы. Полезно для логотипов с собственным фоном.",
"size": "Размер логотипа на страницах входа",
"sizeSmall": "Маленький",
"sizeMedium": "Средний (по умолчанию)",
"sizeLarge": "Большой",
"sizeXLarge": "Очень большой"
} }
}, },
"admin": { "admin": {
@@ -2909,5 +2925,17 @@
"success": "Данные клиента стёрты", "success": "Данные клиента стёрты",
"error": "Не удалось стереть данные клиента" "error": "Не удалось стереть данные клиента"
} }
},
"clients": {
"title": "Клиенты",
"subtitle": "Учётные записи клиентов, расписание, сметы и счета для постоянных клиентов.",
"navAriaLabel": "Навигация по клиентам",
"empty": {
"title": "Нет включённых клиентских функций",
"body": "Включите «Учётные записи» (или другую подфункцию «Клиенты») в Настройки → Функции, чтобы начать."
},
"subnav": {
"accounts": "Учётные записи"
}
} }
} }
+53
View File
@@ -300,6 +300,59 @@
color: var(--color-accent); color: var(--color-accent);
} }
.text-accent-dark {
color: var(--color-accent-dark);
}
/*
* Low-opacity accent fills used as "icon tile" / "tinted chip"
* backgrounds (Features tab feature icons, Sidebar Preview pills,
* Customer dashboard branding card header). Picks up the admin's CI
* accent automatically — previously these places used Tailwind's
* hardcoded `primary-50` palette which stayed green regardless of
* branding settings.
*
* Mix weights bumped from 12% / 28% to 18% / 55% so the tint reads
* as "tinted" rather than "barely there" on dark backgrounds. The
* pill foreground intentionally falls back to a high-contrast theme
* colour rather than the accent itself (.text-accent-dark on an
* accent-soft bg disappears because both come from the same
* source).
*
* color-mix is supported on every browser we ship (Chromium 111+,
* Safari 16.2+, Firefox 113+ — see baseline). Older browsers fall
* back to the var(--color-accent-dark) below.
*/
.bg-accent-soft {
background-color: var(--color-accent-dark);
background-color: color-mix(in srgb, var(--color-accent-dark) 18%, transparent);
}
.dark .bg-accent-soft {
background-color: var(--color-accent-dark);
background-color: color-mix(in srgb, var(--color-accent-dark) 55%, transparent);
}
.border-accent-soft {
border-color: var(--color-accent-dark);
border-color: color-mix(in srgb, var(--color-accent-dark) 45%, transparent);
}
.dark .border-accent-soft {
border-color: color-mix(in srgb, var(--color-accent-dark) 70%, transparent);
}
/*
* Foreground for content sitting on .bg-accent-soft. Uses the
* theme's text token (already high-contrast against both light
* backgrounds and dark surfaces) instead of the accent itself —
* accent-on-accent-soft was the reason the Sidebar Preview pills
* read as "barely visible".
*/
.text-on-accent-soft {
color: var(--color-accent-dark);
}
.dark .text-on-accent-soft {
color: #ffffff;
}
/* /*
* Selected-tile state for picker grids (Gallery Layout, Filter Bar Style, * Selected-tile state for picker grids (Gallery Layout, Filter Bar Style,
* Header Style, Hero Divider, Theme Presets). Used in place of the dim * Header Style, Hero Divider, Theme Presets). Used in place of the dim
+26 -11
View File
@@ -8,6 +8,7 @@ import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { useAdminAuth } from '../../contexts'; import { useAdminAuth } from '../../contexts';
import { authService } from '../../services/auth.service'; import { authService } from '../../services/auth.service';
import { usePublicSettings } from '../../hooks/usePublicSettings'; import { usePublicSettings } from '../../hooks/usePublicSettings';
import { resolveLoginLogoClasses } from '../../utils/loginLogoSize';
import { api } from '../../config/api'; import { api } from '../../config/api';
export const AdminLoginPage: React.FC = () => { export const AdminLoginPage: React.FC = () => {
@@ -122,18 +123,32 @@ export const AdminLoginPage: React.FC = () => {
return ( return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}> <div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{/* Logo/Header */} {/* Logo/Header — frame visibility and size are admin-controllable
via Branding → "Login pages logo" settings. Both knobs apply
to /admin/login and /customer/login exclusively. */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div {(() => {
className="w-[200px] h-[150px] mx-auto mb-6 rounded-2xl flex items-center justify-center" const cls = resolveLoginLogoClasses(settingsData?.branding_login_logo_size);
style={{ backgroundColor: '#eee6d2' }} const showFrame = settingsData?.branding_login_logo_frame_enabled !== false;
> return showFrame ? (
<img <div
src={resolvedLogoUrl} className={`${cls.frameOuter} mx-auto mb-6 rounded-2xl flex items-center justify-center`}
alt={companyName} style={{ backgroundColor: '#eee6d2' }}
className="w-[180px] h-[130px] object-contain" >
/> <img
</div> src={resolvedLogoUrl}
alt={companyName}
className={`${cls.frameInner} object-contain`}
/>
</div>
) : (
<img
src={resolvedLogoUrl}
alt={companyName}
className={`${cls.bare} object-contain mx-auto mb-6`}
/>
);
})()}
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>{t('adminLogin.title')}</h1> <h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>{t('adminLogin.title')}</h1>
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>{t('adminLogin.subtitle')}</p> <p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>{t('adminLogin.subtitle')}</p>
</div> </div>
+54
View File
@@ -34,6 +34,8 @@ export const BrandingPage: React.FC = () => {
logo_display_mode: 'logo_and_text', logo_display_mode: 'logo_and_text',
hide_powered_by: false, hide_powered_by: false,
force_color_mode: null, force_color_mode: null,
login_logo_frame_enabled: true,
login_logo_size: 'medium',
facebook_url: '', facebook_url: '',
instagram_url: '', instagram_url: '',
whatsapp_url: '', whatsapp_url: '',
@@ -670,6 +672,58 @@ export const BrandingPage: React.FC = () => {
</div> </div>
</div> </div>
{/* Login-page-only logo controls (#354 follow-up). These do
NOT affect gallery/admin headers — those keep their own
logo_size knob above. */}
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
<h3 className="text-md font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
{t('branding.loginLogo.title', 'Login pages logo')}
</h3>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-4">
{t('branding.loginLogo.subtitle', 'Controls only /admin/login and /customer/login. Gallery and admin chrome use the logo settings above.')}
</p>
<div className="space-y-4">
{/* Frame toggle */}
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={brandingSettings.login_logo_frame_enabled !== false}
onChange={(e) => handleBrandingChange('login_logo_frame_enabled', e.target.checked)}
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('branding.loginLogo.frame', 'Show tinted frame behind the logo')}
</span>
<p className="text-xs text-neutral-600 dark:text-neutral-400">
{t(
'branding.loginLogo.frameHelp',
'When off, the logo sits directly on the page background. Useful for logos that already include their own backdrop.',
)}
</p>
</div>
</label>
{/* Size selector */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.loginLogo.size', 'Logo size on login pages')}
</label>
<select
value={brandingSettings.login_logo_size || 'medium'}
onChange={(e) => handleBrandingChange('login_logo_size', e.target.value as 'small' | 'medium' | 'large' | 'xlarge')}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="small">{t('branding.loginLogo.sizeSmall', 'Small')}</option>
<option value="medium">{t('branding.loginLogo.sizeMedium', 'Medium (default)')}</option>
<option value="large">{t('branding.loginLogo.sizeLarge', 'Large')}</option>
<option value="xlarge">{t('branding.loginLogo.sizeXLarge', 'Extra large')}</option>
</select>
</div>
</div>
</div>
{/* White Label Settings */} {/* White Label Settings */}
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700"> <div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
<h3 className="text-md font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('branding.whiteLabel', 'White Label')}</h3> <h3 className="text-md font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('branding.whiteLabel', 'White Label')}</h3>
@@ -125,7 +125,7 @@ export const CustomerDetailPage: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] }); queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['admin-customers'] }); queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
toast.success(t('customers.deactivate.success', 'Customer deactivated')); toast.success(t('customers.deactivate.success', 'Customer deactivated'));
navigate('/admin/customers'); navigate('/admin/clients/accounts');
}, },
onError: () => toast.error(t('customers.deactivate.error', 'Could not deactivate customer')), onError: () => toast.error(t('customers.deactivate.error', 'Could not deactivate customer')),
}); });
@@ -152,7 +152,7 @@ export const CustomerDetailPage: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] }); queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['admin-customers'] }); queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
toast.success(t('customers.erase.success', 'Customer erased')); toast.success(t('customers.erase.success', 'Customer erased'));
navigate('/admin/customers'); navigate('/admin/clients/accounts');
}, },
onError: () => toast.error(t('customers.erase.error', 'Could not erase customer')), onError: () => toast.error(t('customers.erase.error', 'Could not erase customer')),
}); });
@@ -176,7 +176,7 @@ export const CustomerDetailPage: React.FC = () => {
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<Link <Link
to="/admin/customers" to="/admin/clients/accounts"
className="p-2 -ml-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700" className="p-2 -ml-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
aria-label={t('common.back', 'Back')} aria-label={t('common.back', 'Back')}
> >
@@ -444,7 +444,7 @@ export const CustomerManagementPage: React.FC = () => {
{filteredCustomers.map((c) => ( {filteredCustomers.map((c) => (
<tr key={c.id} className="border-t" style={{ borderColor: 'var(--color-surface-border)' }}> <tr key={c.id} className="border-t" style={{ borderColor: 'var(--color-surface-border)' }}>
<td className="px-3 py-3"> <td className="px-3 py-3">
<Link to={`/admin/customers/${c.id}`} className="text-theme hover:underline"> <Link to={`/admin/clients/accounts/${c.id}`} className="text-theme hover:underline">
{renderCustomerName(c)} {renderCustomerName(c)}
</Link> </Link>
</td> </td>
+10 -2
View File
@@ -296,10 +296,14 @@ export const SettingsPage: React.FC = () => {
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800' : 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`} }`}
> >
{/* Active-state icon paints white to sit on the
accent-dark pill (matches the label colour
and avoids the accent-on-accent low-contrast
that the prior `text-accent` produced). */}
<Icon <Icon
className={`w-4 h-4 flex-shrink-0 ${ className={`w-4 h-4 flex-shrink-0 ${
isActive isActive
? 'text-accent' ? 'text-white'
: 'text-neutral-500 dark:text-neutral-400 group-hover:text-neutral-700 dark:group-hover:text-neutral-200' : 'text-neutral-500 dark:text-neutral-400 group-hover:text-neutral-700 dark:group-hover:text-neutral-200'
}`} }`}
/> />
@@ -318,7 +322,11 @@ export const SettingsPage: React.FC = () => {
{showSectionHeading && ( {showSectionHeading && (
<div className="mb-4 lg:mb-6 pb-3 border-b border-neutral-200 dark:border-neutral-700"> <div className="mb-4 lg:mb-6 pb-3 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<activeItem.icon className="w-5 h-5 text-accent" /> {/* Section heading icon stays neutral so the Settings
chrome reads as one consistent palette — no stray
accent flecks. The active sidebar pill is the only
place that uses the accent fill. */}
<activeItem.icon className="w-5 h-5 text-neutral-700 dark:text-neutral-300" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"> <h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{activeItem.label} {activeItem.label}
</h2> </h2>
+23 -15
View File
@@ -175,24 +175,32 @@ export const CustomerLayout: React.FC = () => {
key={item.to} key={item.to}
to={item.to} to={item.to}
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2" className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
style={isActive ? { isActive
backgroundColor: 'color-mix(in srgb, var(--color-accent) 12%, transparent)', ? 'bg-accent-dark text-white'
color: 'var(--color-accent)', : 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-100'
} : undefined} }`}
> >
<Icon className={`w-5 h-5 flex-shrink-0 ${isActive ? '' : 'text-muted-theme'}`} /> {/* Mirrors AdminSidebar's active state exactly: solid
<span className={`flex-1 truncate ${isActive ? '' : 'text-theme'}`}> accent-dark pill, white icon and label, no extra
{t(item.labelKey, item.fallback)} flex grow on the label so the pill width matches
</span> what the admin chrome renders. */}
{/* Coming-soon pill for the gated entries. The pages <Icon
themselves are still placeholders even when the className={`w-5 h-5 mr-3 ${
admin has enabled access — the badge keeps that isActive ? 'text-white' : 'text-neutral-400'
promise honest. */} }`}
/>
{t(item.labelKey, item.fallback)}
{/* Coming-soon pill for the gated entries. Pushed to
the right of the label with ml-auto so it doesn't
add the flex-1 stretching that was throwing off
the active-pill visual. */}
{item.feature && ( {item.feature && (
<span <span
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold" className={`ml-auto text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold ${
style={{ isActive ? 'bg-white/20 text-white' : ''
}`}
style={isActive ? undefined : {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 14%, transparent)', backgroundColor: 'color-mix(in srgb, var(--color-accent) 14%, transparent)',
color: 'var(--color-accent)', color: 'var(--color-accent)',
}} }}
@@ -14,6 +14,7 @@ import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { useCustomerAuth } from '../../contexts/CustomerAuthContext'; import { useCustomerAuth } from '../../contexts/CustomerAuthContext';
import { customerService } from '../../services/customer.service'; import { customerService } from '../../services/customer.service';
import { usePublicSettings } from '../../hooks/usePublicSettings'; import { usePublicSettings } from '../../hooks/usePublicSettings';
import { resolveLoginLogoClasses } from '../../utils/loginLogoSize';
export const CustomerLoginPage: React.FC = () => { export const CustomerLoginPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -113,30 +114,34 @@ export const CustomerLoginPage: React.FC = () => {
style={{ backgroundColor: 'var(--color-background, #fafafa)' }} style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
> >
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{/* Logo / header — matches AdminLoginPage's tinted square frame {/* Logo / header — matches AdminLoginPage. The frame and size
so the brand presentation is identical across admin and are admin-controllable via Branding → "Login pages logo"
customer entry points. The frame itself is admin-controllable settings; both toggles apply to /admin/login and
via Branding → "Show tinted frame behind login logo" — same /customer/login exclusively (the rest of the app uses its
toggle drives both login pages. */} own logo_size). */}
<div className="text-center mb-8"> <div className="text-center mb-8">
{settingsData?.branding_login_logo_frame_enabled !== false ? ( {(() => {
<div const cls = resolveLoginLogoClasses(settingsData?.branding_login_logo_size);
className="w-[200px] h-[150px] mx-auto mb-6 rounded-2xl flex items-center justify-center" const showFrame = settingsData?.branding_login_logo_frame_enabled !== false;
style={{ backgroundColor: '#eee6d2' }} return showFrame ? (
> <div
className={`${cls.frameOuter} mx-auto mb-6 rounded-2xl flex items-center justify-center`}
style={{ backgroundColor: '#eee6d2' }}
>
<img
src={resolvedLogoUrl}
alt={companyName}
className={`${cls.frameInner} object-contain`}
/>
</div>
) : (
<img <img
src={resolvedLogoUrl} src={resolvedLogoUrl}
alt={companyName} alt={companyName}
className="w-[180px] h-[130px] object-contain" className={`${cls.bare} object-contain mx-auto mb-6`}
/> />
</div> );
) : ( })()}
<img
src={resolvedLogoUrl}
alt={companyName}
className="h-24 w-auto object-contain mx-auto mb-6"
/>
)}
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}> <h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>
{t('customer.login.title', 'Customer login')} {t('customer.login.title', 'Customer login')}
</h1> </h1>
@@ -10,12 +10,15 @@ export type FeatureKey =
| 'messaging' | 'messaging'
| 'analytics' | 'analytics'
| 'userManagement' | 'userManagement'
// Foundation flag for the customer-side surface (#354). Gates the // Top-level "Clients" section (#354 follow-up). Parent flag that
// /customer/* routes (login, dashboard, profile, accept-invite, // gates the /admin/clients/* sidebar entry. customerPortal,
// reset-password) and the admin Customers management page. The // calendar, quotes, bills and messaging are conceptually its
// calendar / calendarBooking / quotes / bills / messaging flags // children — when `clients` is off none of them surface in the
// above hang off this — they only appear in the customer dashboard // admin UI even if their individual flags are on.
// when customerPortal is also ON. | 'clients'
// Customer-side portal surface (#354). Gates /customer/* routes
// (login, dashboard, profile, accept-invite, reset-password) and
// the Accounts sub-page under Clients in the admin UI.
| 'customerPortal'; | 'customerPortal';
export type FeatureFlags = Record<FeatureKey, boolean>; export type FeatureFlags = Record<FeatureKey, boolean>;
@@ -25,6 +25,12 @@ export interface PublicSettings {
* AdminDarkModeContext + ThemeContext both honor this. * AdminDarkModeContext + ThemeContext both honor this.
*/ */
branding_force_color_mode?: 'dark' | 'light' | null; branding_force_color_mode?: 'dark' | 'light' | null;
/**
* Login-page-only branding (#354 follow-up). Applies exclusively to
* /admin/login and /customer/login. Defaults: frame on, size 'medium'.
*/
branding_login_logo_frame_enabled?: boolean;
branding_login_logo_size?: 'small' | 'medium' | 'large' | 'xlarge';
// Footer overhaul (#441 + #440). Empty strings mean "hide". // Footer overhaul (#441 + #440). Empty strings mean "hide".
branding_facebook_url?: string; branding_facebook_url?: string;
branding_instagram_url?: string; branding_instagram_url?: string;
+15 -1
View File
@@ -25,6 +25,13 @@ export interface BrandingSettings {
* `colorMode` override is ignored. `null` means no force (default behavior). * `colorMode` override is ignored. `null` means no force (default behavior).
*/ */
force_color_mode?: 'dark' | 'light' | null; force_color_mode?: 'dark' | 'light' | null;
/**
* Login-page-only branding (#354 follow-up). Applies exclusively to
* /admin/login and /customer/login. Frame default is true; size
* default is 'medium' (matches the visual state before the toggle).
*/
login_logo_frame_enabled?: boolean;
login_logo_size?: 'small' | 'medium' | 'large' | 'xlarge';
// Footer overhaul (#441 + #440). Empty strings hide each social // Footer overhaul (#441 + #440). Empty strings hide each social
// icon individually; promo_markdown empty hides the slot for events // icon individually; promo_markdown empty hides the slot for events
// in 'inherit' mode. Position controls global default placement. // in 'inherit' mode. Position controls global default placement.
@@ -309,7 +316,14 @@ export const settingsService = {
? 'dark' ? 'dark'
: rawSettings.branding_force_color_mode === 'light' : rawSettings.branding_force_color_mode === 'light'
? 'light' ? 'light'
: null : null,
// Login-only knobs (#354). Default to frame ON, size 'medium' so
// installs that haven't toggled them keep the visual state shipped
// before the controls existed.
login_logo_frame_enabled: this._parseBoolean(rawSettings.branding_login_logo_frame_enabled, true),
login_logo_size: ['small', 'medium', 'large', 'xlarge'].includes(rawSettings.branding_login_logo_size)
? rawSettings.branding_login_logo_size
: 'medium'
}; };
}, },
+31 -36
View File
@@ -1,42 +1,37 @@
// Cleanup function to remove old gallery authentication data /**
* Cleanup helper for legacy gallery-auth artefacts.
*
* Removes pre-multi-gallery storage:
* - global `gallery_token` / `gallery_event` keys in localStorage AND
* sessionStorage (the old single-gallery shape).
* - the bare `gallery_token` cookie (now replaced by slug-scoped
* `gallery_token_<slug>` cookies).
*
* Does NOT wipe slug-scoped sessionStorage entries any more the
* previous version did, which broke the customer-dashboard gallery
* handoff. CustomerDashboardPage stores
* `sessionStorage.gallery_token_<slug>` immediately before navigating
* to /gallery/<slug>; GalleryAuthProvider then mounts and ran this
* cleanup as its first effect, wiping the just-set entry and forcing
* the user back to the per-event password prompt even though their
* customer JWT had just been exchanged for a valid gallery JWT.
*
* Slug-scoped storage is owned by GalleryAuthProvider itself (cleared
* on logout, token invalidation, archived event) this helper has
* no business sweeping it.
*/
export const cleanupOldGalleryAuth = () => { export const cleanupOldGalleryAuth = () => {
// Remove old global gallery authentication // Legacy global keys (pre-multi-gallery shape).
localStorage.removeItem('gallery_event'); localStorage.removeItem('gallery_event');
localStorage.removeItem('gallery_token'); // Remove old global token format localStorage.removeItem('gallery_token');
// Remove any corrupted or old gallery tokens from localStorage
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.startsWith('gallery_token') || key.startsWith('gallery_event'))) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => {
localStorage.removeItem(key);
});
// Remove old gallery token from cookies if it exists
document.cookie = 'gallery_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// Also clear session storage
sessionStorage.removeItem('gallery_event'); sessionStorage.removeItem('gallery_event');
sessionStorage.removeItem('gallery_token'); sessionStorage.removeItem('gallery_token');
// Legacy bare cookie (path=/, no slug suffix). Slug-scoped
// `gallery_token_<slug>` cookies are kept — they're how the customer
// dashboard hands off auth to /gallery/<slug>.
document.cookie = 'gallery_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// gallery_active_slug is a UI hint, not auth. Safe to drop.
sessionStorage.removeItem('gallery_active_slug'); sessionStorage.removeItem('gallery_active_slug');
// Remove slug-specific session storage entries as well
try {
const sessionKeysToRemove: string[] = [];
for (let i = 0; i < sessionStorage.length; i += 1) {
const key = sessionStorage.key(i);
if (key && (key.startsWith('gallery_event_') || key.startsWith('gallery_token_'))) {
sessionKeysToRemove.push(key);
}
}
sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key));
} catch {
// Session storage may be unavailable; ignore cleanup failures
}
}; };
+51
View File
@@ -0,0 +1,51 @@
/**
* Login-page logo sizing (#354 follow-up).
*
* Used ONLY on /admin/login and /customer/login. The rest of the app
* (gallery headers, admin chrome) keeps its own `branding_logo_size` /
* `branding_logo_max_height` knobs kept separate so admins can have a
* compact logo in the in-app header but a hero-sized one on the login
* splash.
*
* Returns Tailwind class strings for both modes:
* - `frame`: dimensions of the tinted square frame + the inner image.
* - `bare`: height/width of the standalone image when the frame is off.
*/
export type LoginLogoSize = 'small' | 'medium' | 'large' | 'xlarge';
interface LoginLogoClasses {
frameOuter: string; // tinted square dimensions
frameInner: string; // logo image inside the frame
bare: string; // logo image when frame is off
}
const SIZE_MAP: Record<LoginLogoSize, LoginLogoClasses> = {
small: {
frameOuter: 'w-[140px] h-[105px]',
frameInner: 'w-[120px] h-[90px]',
bare: 'h-16 w-auto',
},
medium: {
// Matches the visual state shipped before the size toggle existed.
frameOuter: 'w-[200px] h-[150px]',
frameInner: 'w-[180px] h-[130px]',
bare: 'h-24 w-auto',
},
large: {
frameOuter: 'w-[260px] h-[195px]',
frameInner: 'w-[240px] h-[175px]',
bare: 'h-32 w-auto',
},
xlarge: {
frameOuter: 'w-[320px] h-[240px]',
frameInner: 'w-[300px] h-[220px]',
bare: 'h-40 w-auto',
},
};
export function resolveLoginLogoClasses(size: LoginLogoSize | string | undefined | null): LoginLogoClasses {
if (size && size in SIZE_MAP) return SIZE_MAP[size as LoginLogoSize];
return SIZE_MAP.medium;
}
export const LOGIN_LOGO_SIZES: LoginLogoSize[] = ['small', 'medium', 'large', 'xlarge'];