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/users', require('./src/routes/adminUsers'));
// Customer portal (#354). The customerPortal feature flag is enforced
// on the frontend via <RequireFeature flag="customerPortal" /> route
// guards (App.tsx) and AdminSidebar visibility — when the flag is off,
// users never reach these endpoints. Defence in depth is provided by
// customerAccountsService.isCustomerPortalEnabled() in the few backend
// paths that matter (e.g. adminEvents customer_account_ids handling).
// Admin routes are protected by adminAuth; customer routes by
// customerAuth — so no additional route-level gate is needed.
app.use('/api/admin/customers', require('./src/routes/adminCustomers'));
// in TWO places:
// 1. Frontend: RequireFeature guards + AdminSidebar visibility
// (handles navigation cleanly when an admin is using the app).
// 2. Backend: the requireCustomerPortalEnabled middleware below.
// Belt-and-braces — a stale tab, a saved bookmark, or any
// third-party API client trying to hit /api/customer/* or
// /api/admin/customers/* gets a 410 Gone the moment the toggle
// 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/* —
// distinct token type, distinct cookie, distinct middleware.
app.use('/api/customer/auth', require('./src/routes/customerAuth'));
app.use('/api/customer', require('./src/routes/customer'));
app.use('/api/customer/auth', requireCustomerPortalEnabled, require('./src/routes/customerAuth'));
app.use('/api/customer', requireCustomerPortalEnabled, require('./src/routes/customer'));
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
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;
// 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
logger.debug('Update event request', {
id,
+24 -3
View File
@@ -32,8 +32,14 @@ const KNOWN_FLAGS = [
'messaging',
'analytics',
'userManagement',
// Foundation flag for the customer-side surface (#354). See migration
// 094 for the seeding rule.
// Top-level "Clients" section (#354 follow-up). Parent flag that
// 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',
];
@@ -49,6 +55,7 @@ const DEFAULT_FLAGS = {
messaging: false,
analytics: true,
userManagement: true,
clients: false,
};
async function readAllFlags() {
@@ -69,13 +76,27 @@ function applyDependencyRules(flags) {
// Sub-features can't outlive their parents.
if (out.quotes === false) out.bills = 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;
}
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
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) {
logger.error('Failed to read feature flags', { error: error.message });
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,
hide_powered_by,
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
// hides the icon). Promo content is markdown (rendered via
// marked → DOMPurify on the frontend, no raw HTML accepted).
@@ -330,6 +340,13 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
? 'below_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 = {
company_name,
company_tagline,
@@ -350,6 +367,11 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_mode,
hide_powered_by,
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/
// undefined → '' so the column is always a known type. Only persist
// 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 -------------------------------------------------------------
// The customerPortal feature flag deliberately does NOT gate this route.
// Flipping the master toggle off in Settings → Features hides the admin
// UI surface (sidebar entry, /admin/customers page) but does not revoke
// access for customers who already accepted an invitation. To lock out
// existing customers, deactivate their accounts individually
// (customer_accounts.is_active = false) — which IS enforced below.
// Flag-gate note: this route IS now gated by the customerPortal feature
// flag via the requireCustomerPortalEnabled middleware mounted in
// server.js (`app.use('/api/customer/auth', requireCustomerPortalEnabled, …)`).
// When the admin flips the toggle off in Settings → Features, every
// customer-side endpoint — including login — returns 410. The previous
// 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', [
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
body('password').isString().notEmpty(),
+9
View File
@@ -85,6 +85,15 @@ router.get('/', async (req, res) => {
: settingsObject.branding_force_color_mode === 'light'
? 'light'
: 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,
default_language: settingsObject.general_default_language || 'en',
enable_analytics: settingsObject.general_enable_analytics !== false,
+42 -7
View File
@@ -1,5 +1,5 @@
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 { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
@@ -40,6 +40,7 @@ import {
} from './pages/customer';
import { CustomerAuthProvider } from './contexts/CustomerAuthContext';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { ClientsLayout } from './components/admin/ClientsLayout';
import { RequireFeature } from './components/admin/RequireFeature';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
@@ -90,6 +91,17 @@ function AnalyticsBootstrap() {
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() {
// Track dark mode for toast theming
const [toastTheme, setToastTheme] = useState<'light' | 'dark'>('light');
@@ -146,14 +158,37 @@ function App() {
<Route element={<RequireFeature flag="userManagement" />}>
<Route path="users" element={<UserManagementPage />} />
</Route>
{/* Customer accounts (#354) — admin-side management.
Hidden from sidebar + redirected away when the
customerPortal flag is off. */}
<Route element={<RequireFeature flag="customerPortal" />}>
<Route path="customers" element={<CustomerManagementPage />} />
<Route path="customers/:id" element={<CustomerDetailPage />} />
{/* Clients section (#354 follow-up). Parent route
gated by the top-level `clients` flag — when off
the sidebar entry is hidden and every /admin/clients/*
URL redirects to /admin/dashboard. Inside, the
ClientsLayout renders a Settings-style sub-nav
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>
{/* 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="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
+41 -7
View File
@@ -8,7 +8,7 @@ import {
Settings,
X,
Users,
UserCog,
Briefcase,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -27,7 +27,15 @@ interface NavItem {
href: string;
icon: React.ComponentType<{ className?: string }>;
permission?: string | false;
/** Single required flag — entry hidden when this is false. */
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).
@@ -47,12 +55,30 @@ const navigation: NavItem[] = [
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view', featureFlag: 'userManagement' },
// Customer accounts (#354) — separate from admin users (#users.view)
// by design: customers log in at /customer/login with their own
// cookie + token type. Hidden when the customerPortal feature flag
// is OFF (Settings → Features). The corresponding /customer/* routes
// also redirect away in that case (see RequireFeature in App.tsx).
{ nameKey: 'navigation.customers', href: '/admin/customers', icon: UserCog, permission: 'customers.view', featureFlag: 'customerPortal' },
// Clients section (#354 follow-up) — admin-side surface for the
// CRM-area sub-features. Today this entry leads to /admin/clients
// which renders a Settings-style sub-nav with one item (Accounts).
// When calendar / quotes / bills / messaging ship they slot in as
// additional sub-nav items inside ClientsLayout without needing
// 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 }) => {
@@ -64,6 +90,14 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
const filteredNavigation = navigation.filter((item) => {
if (item.permission && !hasPermission(item.permission as string)) 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;
});
@@ -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 }) => {
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');
// 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 [results, setResults] = useState<CustomerAccountSummary[]>([]);
const [isOpen, setIsOpen] = useState(false);
@@ -107,6 +110,14 @@ export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabl
[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 (
<div ref={containerRef} className="relative">
<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>
) : results.length === 0 ? (
<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>
) : (
<ul role="listbox">
@@ -120,7 +120,7 @@ export const CustomerDashboardBrandingCard: React.FC = () => {
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">
<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" />
</div>
<div>
+16 -11
View File
@@ -19,11 +19,14 @@ export const DEFAULT_FLAGS: FeatureFlags = {
messaging: false,
analytics: 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
// logins are opt-in. Migration 094 flips this to TRUE on existing
// installs (events>0) so the customer-portal foundation isn't
// silently disabled mid-deployment.
// logins are opt-in. Migration 095 flips this to TRUE on existing
// installs (events>0).
customerPortal: false,
};
@@ -56,13 +59,15 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
out.galleries = true; // foundation — always on
if (out.quotes === false) out.bills = false; // bills depend on quotes
if (out.calendar === false) out.calendarBooking = false; // booking depends on calendar
// Customer-portal-dependent flags: if the customer portal is OFF the
// customer-side dashboard never renders, so the calendar/quotes/bills/
// messaging customer-side surfaces have nowhere to live. Their server
// toggles can stay at whatever the admin set (so re-enabling the
// portal restores the previous state), but the dependency is
// documented here for the FeaturesTab UI to disable child cards
// visually when customerPortal is off.
// Clients parent flag is DERIVED from its children. Admins don't
// toggle it directly — enabling any CRM-area sub-feature
// (Accounts today; future Calendar / Quotes / Bills / Messaging)
// lights up the Clients sidebar section automatically, and
// disabling all of them hides it again.
out.clients = Boolean(
out.customerPortal
// future siblings: || out.calendar || out.quotes || out.bills || out.messaging
);
return out;
}
+12 -3
View File
@@ -186,9 +186,18 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
void loadFontForFamily(themeConfig.fontFamily);
}
if (themeConfig.headingFontFamily) {
root.style.setProperty('--heading-font-family', themeConfig.headingFontFamily);
void loadFontForFamily(themeConfig.headingFontFamily);
// "Same as body" is stored as headingFontFamily='' — in that case
// mirror the body family so a stale --heading-font-family (e.g.
// 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) {
@@ -46,12 +46,15 @@ export const FeatureCard: React.FC<FeatureCardProps> = ({
)}
>
<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
className={clsx(
'flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center',
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',
)}
>
@@ -60,8 +60,12 @@ export const SidebarPreview: React.FC<SidebarPreviewProps> = ({ staged }) => {
key={item.key}
className={clsx(
'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
? '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',
)}
>
@@ -73,7 +77,7 @@ export const SidebarPreview: React.FC<SidebarPreviewProps> = ({ staged }) => {
<p className="mt-3 text-xs text-neutral-500 dark:text-neutral-400">
{t(
'settings.features.preview.legend',
'Green tinted items are controlled by toggles above.',
'Accent-tinted items are controlled by toggles above.',
)}
</p>
</Card>
@@ -12,6 +12,7 @@ import {
BarChart3,
Users,
UserCog,
Briefcase,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../../../components/common';
@@ -69,7 +70,7 @@ export const FeaturesTab: React.FC = () => {
{/* Header */}
<div className="mb-6 pb-4 border-b border-neutral-200 dark:border-neutral-700">
<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" />
</div>
<div>
@@ -108,24 +109,30 @@ export const FeaturesTab: React.FC = () => {
/>
</Section>
{/* Customer accounts (#354) recurring customer logins. The
calendar / quotes / bills / messaging cards below are
customer-side surfaces; they only render in the customer
dashboard when this is on. */}
<Section title={t('settings.features.sections.customers', 'Customers')}>
{/* Clients (#354 follow-up). Visual grouping for the CRM-area
sub-features. The "Clients" sidebar section itself is gated
by a derived `clients` flag (computed from whether any
child below is on), so there's no explicit parent toggle
admins just enable the specific feature they want and the
section appears automatically. */}
<Section title={t('settings.features.sections.clients', 'Clients')}>
<FeatureCard
icon={UserCog}
title={t('settings.features.customerPortal.title', 'Customer portal')}
title={t('settings.features.customerPortal.title', 'Accounts')}
description={t(
'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"
statusLabel={statusLabel('beta')}
sidebarLabel={t('navigation.customers', 'Customers')}
sidebarLabel={t('clients.subnav.accounts', 'Accounts')}
enabled={staged.customerPortal}
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>
{/* Communication */}
+32 -4
View File
@@ -168,7 +168,7 @@
"cmsPages": "CMS-Seiten",
"users": "Benutzer",
"calendar": "Kalender",
"customers": "Kunden"
"clients": "Kunden"
},
"eventTypes": {
"title": "Veranstaltungsarten",
@@ -1449,7 +1449,8 @@
"scheduling": "Terminplanung",
"sales": "Vertrieb",
"insights": "Auswertungen & Zugriff",
"customers": "Kunden"
"customers": "Kunden",
"clients": "Kunden"
},
"status": {
"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."
},
"customerPortal": {
"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)."
"title": "Konten",
"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": {
@@ -1771,6 +1776,17 @@
"straight": "Gerade",
"angle": "Winkel",
"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": {
@@ -2843,5 +2859,17 @@
"success": "Kunde gelöscht",
"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",
"users": "Users",
"calendar": "Calendar",
"customers": "Customers"
"clients": "Clients"
},
"archives": {
"title": "Archives",
@@ -1088,7 +1088,8 @@
"scheduling": "Scheduling",
"sales": "Sales",
"insights": "Insights & Access",
"customers": "Customers"
"customers": "Customers",
"clients": "Clients"
},
"status": {
"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."
},
"customerPortal": {
"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)."
"title": "Accounts",
"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": {
@@ -1441,6 +1446,17 @@
"angle": "Angle",
"curve": "Curve",
"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": {
@@ -2843,5 +2859,17 @@
"success": "Customer erased",
"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",
"cmsPages": "Pages CMS",
"users": "Utilisateurs",
"customers": "Clients"
"clients": "Clients"
},
"archives": {
"title": "Archives",
@@ -1071,11 +1071,16 @@
"navAriaLabel": "Navigation des paramètres",
"features": {
"sections": {
"customers": "Clients"
"customers": "Clients",
"clients": "Clients"
},
"customerPortal": {
"title": "Portail client",
"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)."
"title": "Comptes",
"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": {
@@ -1379,6 +1384,17 @@
"straight": "Droit",
"angle": "Angle",
"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": {
@@ -2797,5 +2813,17 @@
"success": "Client effacé",
"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",
"users": "Gebruikers",
"calendar": "Agenda",
"customers": "Klanten"
"clients": "Klanten"
},
"archives": {
"title": "Archieven",
@@ -1088,7 +1088,8 @@
"scheduling": "Planning",
"sales": "Verkoop",
"insights": "Inzichten & Toegang",
"customers": "Klanten"
"customers": "Klanten",
"clients": "Klanten"
},
"status": {
"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."
},
"customerPortal": {
"title": "Klantenportaal",
"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)."
"title": "Accounts",
"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": {
@@ -1441,6 +1446,17 @@
"straight": "Recht",
"angle": "Hoek",
"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": {
@@ -2843,5 +2859,17 @@
"success": "Klant 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",
"users": "Utilizadores",
"calendar": "Calendário",
"customers": "Clientes"
"clients": "Clientes"
},
"archives": {
"title": "Arquivos",
@@ -1105,7 +1105,8 @@
"scheduling": "Agendamento",
"sales": "Vendas",
"insights": "Análises & Acesso",
"customers": "Clientes"
"customers": "Clientes",
"clients": "Clientes"
},
"status": {
"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."
},
"customerPortal": {
"title": "Portal do cliente",
"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)."
"title": "Contas",
"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": {
@@ -1458,6 +1463,17 @@
"straight": "Reto",
"angle": "Ângulo",
"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": {
@@ -2876,5 +2892,17 @@
"success": "Cliente apagado",
"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-страницы",
"users": "Пользователи",
"calendar": "Календарь",
"customers": "Клиенты"
"clients": "Клиенты"
},
"archives": {
"title": "Архивы",
@@ -1122,7 +1122,8 @@
"scheduling": "Планирование",
"sales": "Продажи",
"insights": "Аналитика и доступ",
"customers": "Клиенты"
"customers": "Клиенты",
"clients": "Клиенты"
},
"status": {
"stable": "стабильно",
@@ -1170,8 +1171,12 @@
"warning": "Существующие учётные записи остаются действительными; интерфейс управления ими будет скрыт до повторного включения."
},
"customerPortal": {
"title": "Клиентский портал",
"description": "Постоянные логины клиентов. Постоянные клиенты видят все назначенные галереи в одном месте — без паролей для каждого события. Основа для Календаря / Смет / Счетов (которые отображаются в клиентской панели только при включённой опции)."
"title": "Учётные записи",
"description": "Постоянные учётные записи клиентов. Постоянные клиенты видят все назначенные галереи в одном месте — без паролей для каждого события. Клиенты входят на /customer/login, а вы управляете ими в разделе Клиенты → Учётные записи."
},
"clients": {
"title": "Клиенты",
"description": "Главный переключатель раздела «Клиенты» в боковой панели. Выключено — раздел и все подфункции скрыты независимо от их отдельных переключателей. При повторном включении состояние подфункций восстанавливается."
}
},
"customerSurface": {
@@ -1475,6 +1480,17 @@
"straight": "Прямой",
"angle": "Угол",
"curve": "Кривая"
},
"loginLogo": {
"title": "Логотип на страницах входа",
"subtitle": "Применяется только к /admin/login и /customer/login. Галерея и админ-панель используют настройки логотипа выше.",
"frame": "Показывать тонированный фон за логотипом",
"frameHelp": "Если выключено, логотип будет на фоне страницы. Полезно для логотипов с собственным фоном.",
"size": "Размер логотипа на страницах входа",
"sizeSmall": "Маленький",
"sizeMedium": "Средний (по умолчанию)",
"sizeLarge": "Большой",
"sizeXLarge": "Очень большой"
}
},
"admin": {
@@ -2909,5 +2925,17 @@
"success": "Данные клиента стёрты",
"error": "Не удалось стереть данные клиента"
}
},
"clients": {
"title": "Клиенты",
"subtitle": "Учётные записи клиентов, расписание, сметы и счета для постоянных клиентов.",
"navAriaLabel": "Навигация по клиентам",
"empty": {
"title": "Нет включённых клиентских функций",
"body": "Включите «Учётные записи» (или другую подфункцию «Клиенты») в Настройки → Функции, чтобы начать."
},
"subnav": {
"accounts": "Учётные записи"
}
}
}
+53
View File
@@ -300,6 +300,59 @@
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,
* 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 { authService } from '../../services/auth.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { resolveLoginLogoClasses } from '../../utils/loginLogoSize';
import { api } from '../../config/api';
export const AdminLoginPage: React.FC = () => {
@@ -122,18 +123,32 @@ export const AdminLoginPage: React.FC = () => {
return (
<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">
{/* 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="w-[200px] h-[150px] mx-auto mb-6 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: '#eee6d2' }}
>
<img
src={resolvedLogoUrl}
alt={companyName}
className="w-[180px] h-[130px] object-contain"
/>
</div>
{(() => {
const cls = resolveLoginLogoClasses(settingsData?.branding_login_logo_size);
const showFrame = settingsData?.branding_login_logo_frame_enabled !== false;
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
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>
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>{t('adminLogin.subtitle')}</p>
</div>
+54
View File
@@ -34,6 +34,8 @@ export const BrandingPage: React.FC = () => {
logo_display_mode: 'logo_and_text',
hide_powered_by: false,
force_color_mode: null,
login_logo_frame_enabled: true,
login_logo_size: 'medium',
facebook_url: '',
instagram_url: '',
whatsapp_url: '',
@@ -670,6 +672,58 @@ export const BrandingPage: React.FC = () => {
</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 */}
<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>
@@ -125,7 +125,7 @@ export const CustomerDetailPage: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
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')),
});
@@ -152,7 +152,7 @@ export const CustomerDetailPage: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
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')),
});
@@ -176,7 +176,7 @@ export const CustomerDetailPage: React.FC = () => {
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<Link
to="/admin/customers"
to="/admin/clients/accounts"
className="p-2 -ml-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
aria-label={t('common.back', 'Back')}
>
@@ -444,7 +444,7 @@ export const CustomerManagementPage: React.FC = () => {
{filteredCustomers.map((c) => (
<tr key={c.id} className="border-t" style={{ borderColor: 'var(--color-surface-border)' }}>
<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)}
</Link>
</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'
}`}
>
{/* 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
className={`w-4 h-4 flex-shrink-0 ${
isActive
? 'text-accent'
? 'text-white'
: '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 && (
<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">
<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">
{activeItem.label}
</h2>
+23 -15
View File
@@ -175,24 +175,32 @@ export const CustomerLayout: React.FC = () => {
key={item.to}
to={item.to}
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"
style={isActive ? {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 12%, transparent)',
color: 'var(--color-accent)',
} : undefined}
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
isActive
? 'bg-accent-dark text-white'
: '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'
}`}
>
<Icon className={`w-5 h-5 flex-shrink-0 ${isActive ? '' : 'text-muted-theme'}`} />
<span className={`flex-1 truncate ${isActive ? '' : 'text-theme'}`}>
{t(item.labelKey, item.fallback)}
</span>
{/* Coming-soon pill for the gated entries. The pages
themselves are still placeholders even when the
admin has enabled access the badge keeps that
promise honest. */}
{/* Mirrors AdminSidebar's active state exactly: solid
accent-dark pill, white icon and label, no extra
flex grow on the label so the pill width matches
what the admin chrome renders. */}
<Icon
className={`w-5 h-5 mr-3 ${
isActive ? 'text-white' : 'text-neutral-400'
}`}
/>
{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 && (
<span
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold"
style={{
className={`ml-auto text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold ${
isActive ? 'bg-white/20 text-white' : ''
}`}
style={isActive ? undefined : {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 14%, transparent)',
color: 'var(--color-accent)',
}}
@@ -14,6 +14,7 @@ import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { useCustomerAuth } from '../../contexts/CustomerAuthContext';
import { customerService } from '../../services/customer.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { resolveLoginLogoClasses } from '../../utils/loginLogoSize';
export const CustomerLoginPage: React.FC = () => {
const { t } = useTranslation();
@@ -113,30 +114,34 @@ export const CustomerLoginPage: React.FC = () => {
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
>
<div className="w-full max-w-md">
{/* Logo / header matches AdminLoginPage's tinted square frame
so the brand presentation is identical across admin and
customer entry points. The frame itself is admin-controllable
via Branding "Show tinted frame behind login logo" same
toggle drives both login pages. */}
{/* Logo / header matches AdminLoginPage. The frame and size
are admin-controllable via Branding "Login pages logo"
settings; both toggles apply to /admin/login and
/customer/login exclusively (the rest of the app uses its
own logo_size). */}
<div className="text-center mb-8">
{settingsData?.branding_login_logo_frame_enabled !== false ? (
<div
className="w-[200px] h-[150px] mx-auto mb-6 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: '#eee6d2' }}
>
{(() => {
const cls = resolveLoginLogoClasses(settingsData?.branding_login_logo_size);
const showFrame = settingsData?.branding_login_logo_frame_enabled !== false;
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
src={resolvedLogoUrl}
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)' }}>
{t('customer.login.title', 'Customer login')}
</h1>
@@ -10,12 +10,15 @@ export type FeatureKey =
| 'messaging'
| 'analytics'
| 'userManagement'
// Foundation flag for the customer-side surface (#354). Gates the
// /customer/* routes (login, dashboard, profile, accept-invite,
// reset-password) and the admin Customers management page. The
// calendar / calendarBooking / quotes / bills / messaging flags
// above hang off this — they only appear in the customer dashboard
// when customerPortal is also ON.
// Top-level "Clients" section (#354 follow-up). Parent flag that
// 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
// (login, dashboard, profile, accept-invite, reset-password) and
// the Accounts sub-page under Clients in the admin UI.
| 'customerPortal';
export type FeatureFlags = Record<FeatureKey, boolean>;
@@ -25,6 +25,12 @@ export interface PublicSettings {
* AdminDarkModeContext + ThemeContext both honor this.
*/
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".
branding_facebook_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).
*/
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
// icon individually; promo_markdown empty hides the slot for events
// in 'inherit' mode. Position controls global default placement.
@@ -309,7 +316,14 @@ export const settingsService = {
? 'dark'
: rawSettings.branding_force_color_mode === '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 = () => {
// Remove old global gallery authentication
// Legacy global keys (pre-multi-gallery shape).
localStorage.removeItem('gallery_event');
localStorage.removeItem('gallery_token'); // Remove old global token format
// 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
localStorage.removeItem('gallery_token');
sessionStorage.removeItem('gallery_event');
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');
// 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'];