fix(ui): stop branding-theme text colour rendering headings invisible
Components that render headings with no explicit text-colour class inherit
`body { color: var(--color-text) }`, and the branding theme sets --color-text
on <html> app-wide -- so on a dark-toned theme they render near-invisible
(#f5f5f5 on #fff), including inside the admin panel in light mode.
Compliance-adjacent: /impressum and /datenschutz are two of the surfaces.
Convention copied from AccountingTab, the QA control that is visually
identical but not affected: h2 -> text-neutral-900 dark:text-neutral-100,
labels -> neutral-700/300, checkbox labels -> neutral-800/200, hints ->
neutral-500/400.
Fixed beyond the reported lines, after sweeping each file:
- LegalPage: the CMS prose wrapper and the single-segment 404 heading.
- CMSContentBlock: the multi-segment CMS 404 and the admin unknown-route 404
turn out to be the same component (App.tsx path="*"; there is no admin-level
catch-all). Its text already used var(--color-text); the actual defect was
.card hardcoding bg-white under themed text, so the surface was fixed, not
the text.
- SettingsBusinessProfilePage (11), CrmSettingsPage (15, incl. both shared
checkbox-label helpers covering ~20 rendered rows), ReminderTemplatesPage (7,
incl. text-theme/text-muted-theme on an admin page where they are wrong).
- The <select> elements on those tabs: Tailwind preflight sets color:inherit
on form controls, so they picked up the near-white body colour on a white
background. Same root cause, not previously reported.
Plus one line of defence-in-depth on the admin shell (AdminLayout): an
explicit text colour there stops the whole admin panel inheriting the themed
body colour. Components with their own class, including text-theme, still win.
Interpretation -- the robust fix was evaluated and rejected. Scoping the theme
tokens to gallery contexts is not feasible: the leak is deliberate product
behaviour (GlobalThemeProvider applies branding on every non-gallery page),
40 files read var(--color-*) with only 9 under components/gallery, and it
would break the customer portal, the public token pages, AdminLoginPage and
the Branding live preview. It also cannot be done at container level without
moving `body { color: ... }` and the whole .text-theme/.bg-surface/.card-themed
utility family, which are global by construction.
Known remaining instances, not converted: SystemHealthPage, CrmOverviewSection
and HoursSection use text-theme explicitly on admin surfaces, so they keep the
themed colour and stay affected. Outside the reported surfaces.
Refs testplan REPORT.md #14 (Part 8, S3/S4/S13).
This commit is contained in:
@@ -72,7 +72,13 @@ interface AdminLayoutInnerProps {
|
||||
|
||||
const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSidebarOpen, sidebarCollapsed, setSidebarCollapsed, mustChangePassword }) => {
|
||||
return (
|
||||
<div className="h-screen bg-neutral-50 dark:bg-neutral-950 flex overflow-hidden">
|
||||
// Explicit text colour on the admin shell: the branding theme sets
|
||||
// --color-text on <html> app-wide (GlobalThemeProvider applies it on every
|
||||
// non-gallery page, by design), so any admin component that forgot its own
|
||||
// colour class inherited it through `body { color: var(--color-text) }` and
|
||||
// rendered near-invisible on a dark-toned theme. Components with an
|
||||
// explicit class or `text-theme` still win over this.
|
||||
<div className="h-screen bg-neutral-50 dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 flex overflow-hidden">
|
||||
{/* Mandatory Password Change Modal */}
|
||||
{mustChangePassword && <MandatoryPasswordChangeModal />}
|
||||
|
||||
|
||||
@@ -83,7 +83,18 @@ export const CMSContentBlock: React.FC<CMSContentBlockProps> = ({ slug, fallback
|
||||
|
||||
<main className="flex-1 flex items-start justify-center px-4">
|
||||
<div className="max-w-2xl w-full">
|
||||
<Card padding="lg">
|
||||
{/*
|
||||
* The card surface has to follow the theme too: `.card` hardcodes
|
||||
* bg-white, so a dark-toned branding theme paired with the themed
|
||||
* text below rendered near-white text on a white card (QA S3/S4).
|
||||
*/}
|
||||
<Card
|
||||
padding="lg"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-surface-border)',
|
||||
}}
|
||||
>
|
||||
{/*
|
||||
* Heading + body now read from theme tokens so dark themes
|
||||
* (and force-dark mode) render correctly without dark: variants
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* ThemeContext.applyTheme() writes the branding theme's `--color-text` as an
|
||||
* inline style on <html>, so `body { color: var(--color-text) }` applies
|
||||
* everywhere — including the light admin chrome and the light-chromed public
|
||||
* legal pages. Any heading that ships without an explicit text-color class
|
||||
* therefore renders near-white on white as soon as the install picks a
|
||||
* dark-toned branding theme (QA S3 / S4 / S13).
|
||||
*
|
||||
* Source-inspection guard: every heading on the surfaces that were fixed must
|
||||
* declare its own colour rather than inheriting the themed body colour.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const SRC = path.resolve(__dirname, '../../..');
|
||||
|
||||
const read = (rel: string) => fs.readFileSync(path.join(SRC, rel), 'utf8');
|
||||
|
||||
// Headings must set a colour explicitly. `text-theme` / `text-muted-theme` are
|
||||
// deliberately NOT accepted — they resolve to the same leaking variables.
|
||||
const EXPLICIT_COLOR = /\btext-(neutral|white|amber|blue|red|green|primary|accent)\b|\btext-(neutral|amber|blue|red|green|primary)-\d/;
|
||||
|
||||
const HEADING_TAG = /<(h[1-4])(\s[^>]*?)?>/gs;
|
||||
|
||||
const HEADING_FILES = [
|
||||
'pages/admin/settings/SettingsBusinessProfilePage.tsx',
|
||||
'pages/admin/settings/CrmSettingsPage.tsx',
|
||||
'pages/admin/settings/ReminderTemplatesPage.tsx',
|
||||
'pages/public/LegalPage.tsx',
|
||||
];
|
||||
|
||||
describe('branding-theme text colour leak (QA S3 / S4 / S13)', () => {
|
||||
it.each(HEADING_FILES)('every heading in %s declares an explicit text colour', (rel) => {
|
||||
const source = read(rel);
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const match of source.matchAll(HEADING_TAG)) {
|
||||
const attrs = match[2] || '';
|
||||
const className = /className="([^"]*)"/.exec(attrs)?.[1] ?? '';
|
||||
if (!EXPLICIT_COLOR.test(className)) offenders.push(match[0]);
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives the LegalPage CMS body an explicit colour instead of the themed body colour', () => {
|
||||
const source = read('pages/public/LegalPage.tsx');
|
||||
expect(source).toMatch(/className="prose prose-neutral max-w-none text-neutral-\d00"/);
|
||||
});
|
||||
|
||||
it('keeps the CMS 404 card surface on the same theme tokens as its text', () => {
|
||||
// CMSContentBlock intentionally renders themed text (var(--color-text));
|
||||
// the card surface has to follow, because `.card` hardcodes bg-white.
|
||||
const source = read('components/common/CMSContentBlock.tsx');
|
||||
expect(source).toContain("backgroundColor: 'var(--color-surface)'");
|
||||
expect(source).toContain("color: 'var(--color-text)'");
|
||||
});
|
||||
});
|
||||
@@ -136,7 +136,7 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
|
||||
const setVal = (k: string, v: any) => setValues((s) => ({ ...s, [k]: v }));
|
||||
const checkbox = (k: string, label: string) => (
|
||||
<label className="flex items-center gap-2 text-sm py-1">
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200 py-1">
|
||||
<input type="checkbox" checked={!!values[k]} onChange={(e) => setVal(k, e.target.checked)} />
|
||||
<span>{t(`crmSettings.${k}.label`, label)}</span>
|
||||
</label>
|
||||
@@ -158,7 +158,7 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
const stored = values[k];
|
||||
const effective = stored === undefined || stored === null ? true : !!stored;
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-sm py-1">
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200 py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={effective}
|
||||
@@ -173,7 +173,7 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{t('crmSettings.title', 'CRM settings')}</h2>
|
||||
<h2 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('crmSettings.title', 'CRM settings')}</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('crmSettings.subtitle', 'Fine-tune quote and invoice behaviour.')}
|
||||
</p>
|
||||
@@ -194,7 +194,7 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
|
||||
{showQuotes && (
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-3">{t('crmSettings.section.quotes', 'Quotes')}</h3>
|
||||
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('crmSettings.section.quotes', 'Quotes')}</h3>
|
||||
{checkbox('crm_quotes_pdf_attachment_enabled', 'Attach quote PDF to email')}
|
||||
{checkbox('crm_quotes_skonto_enabled', 'Allow early-payment discount (Skonto) on quotes')}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||
@@ -217,7 +217,7 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
must tick before Accept fires. The text snapshot is
|
||||
recorded on the quote at acceptance time for audit. */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h4 className="font-semibold mb-2 text-sm">
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-2 text-sm">
|
||||
{t('crmSettings.section.quotesTos', 'Terms of Service / AGB step')}
|
||||
</h4>
|
||||
{checkbox('crm_quotes_tos_required', 'Require customers to tick "I accept the Terms of Service" before accepting')}
|
||||
@@ -229,7 +229,7 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
onChange={(e) => setVal('crm_quotes_tos_url', e.target.value)} />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('crmSettings.crm_quotes_tos_text.label', 'Inline Terms text shown on the quote page')}
|
||||
</label>
|
||||
<textarea
|
||||
@@ -247,7 +247,7 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
|
||||
{showInvoices && (
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-3">{t('crmSettings.section.invoices', 'Invoices')}</h3>
|
||||
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('crmSettings.section.invoices', 'Invoices')}</h3>
|
||||
{checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')}
|
||||
{checkbox('crm_invoice_round_total', 'Reconcile sub-cent rounding to a clean total (adds a "Rundung" row when per-line rounding drifts from qty × rate)')}
|
||||
|
||||
@@ -356,7 +356,7 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
on every new multi-installment plan. Per-document edits
|
||||
still override. */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h4 className="font-semibold mb-2 text-sm">
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-2 text-sm">
|
||||
{t('crmSettings.section.installmentDefaults', 'Default installment triggers')}
|
||||
</h4>
|
||||
<p className="text-xs text-neutral-500 mb-3">
|
||||
@@ -365,13 +365,13 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('crmSettings.crm_invoices_installment_trigger_first.label', 'First installment trigger')}
|
||||
</label>
|
||||
<select
|
||||
value={values.crm_invoices_installment_trigger_first ?? 'quote_accepted'}
|
||||
onChange={(e) => setVal('crm_invoices_installment_trigger_first', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm"
|
||||
>
|
||||
<option value="quote_accepted">{t('crmSettings.installmentDefaults.trigger.quote_accepted', 'At signing / creation')}</option>
|
||||
<option value="before_event">{t('crmSettings.installmentDefaults.trigger.before_event', 'Before event')}</option>
|
||||
@@ -401,7 +401,7 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
pickers — admin can override per document — but new
|
||||
drafts auto-prefill from these two settings. */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h4 className="font-semibold mb-2 text-sm">
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-2 text-sm">
|
||||
{t('crmSettings.section.paymentDefaults', 'Default payment conditions')}
|
||||
</h4>
|
||||
<p className="text-xs text-neutral-500 mb-3">
|
||||
@@ -410,11 +410,11 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('crmSettings.crm_invoices_default_payment_net_days_template_id.label', 'Default net days')}
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm"
|
||||
value={values.crm_invoices_default_payment_net_days_template_id ?? ''}
|
||||
onChange={(e) => setVal('crm_invoices_default_payment_net_days_template_id', e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
@@ -425,11 +425,11 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('crmSettings.crm_invoices_default_payment_timing_template_id.label', 'Default payment schedule')}
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm"
|
||||
value={values.crm_invoices_default_payment_timing_template_id ?? ''}
|
||||
onChange={(e) => setVal('crm_invoices_default_payment_timing_template_id', e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
@@ -450,7 +450,7 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
shape: 3 behaviour toggles, then a 2-column input grid, then
|
||||
the number-format input with helper text. */
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-3">{t('crmSettings.section.contracts', 'Contracts')}</h3>
|
||||
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('crmSettings.section.contracts', 'Contracts')}</h3>
|
||||
{/* Three of these four toggles use `!== false` semantics on
|
||||
the backend — a missing app_settings row behaves as if
|
||||
checked. `checkboxDefaultOn` mirrors that so the UI tells
|
||||
@@ -491,7 +491,7 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
component reads these via /public/settings and hides the
|
||||
matching tile when the value is explicit false. */
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-1">
|
||||
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
|
||||
{t('crmSettings.section.dashboardOverview', 'Dashboard CRM overview')}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-500 mb-3">
|
||||
|
||||
@@ -250,7 +250,11 @@ export const ReminderTemplatesPage: React.FC = () => {
|
||||
<Link to="/admin/settings/crm" className="p-2 -ml-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-theme">
|
||||
{/* Explicit neutral colours (not `text-theme` / `text-muted-theme`):
|
||||
those resolve to the gallery branding theme's --color-text, which
|
||||
is applied globally on <html> and renders near-white inside the
|
||||
light admin chrome (QA S13). */}
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||
{t('reminderTemplates.title', 'Pre-event reminder emails')}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -263,7 +267,7 @@ export const ReminderTemplatesPage: React.FC = () => {
|
||||
<WorkflowIcon className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('reminderTemplates.scheduleMoved.title', 'The reminder schedule is now in Workflows')}</p>
|
||||
<p className="mt-1 text-muted-theme">
|
||||
<p className="mt-1 text-neutral-600 dark:text-neutral-400">
|
||||
{t('reminderTemplates.scheduleMoved.body', 'Whether pre-event reminders are sent, and how many days before the event, is configured in the “Pre-event reminder” workflow. This page edits the email templates; per-event overrides stay on each event’s detail page.')}{' '}
|
||||
<Link to="/admin/workflows" className="underline font-medium">{t('reminderTemplates.scheduleMoved.link', 'Open Workflows')}</Link>
|
||||
</p>
|
||||
@@ -271,20 +275,20 @@ export const ReminderTemplatesPage: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="font-semibold text-sm mb-2">
|
||||
<h3 className="font-semibold text-sm text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
{t('reminderTemplates.globalSection', 'Global behaviour')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-theme mb-3">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
||||
{t('reminderTemplates.globalHelp',
|
||||
'Off by default — turn on to start sending pre-event reminders. The offset below is the default; each event can override on its detail page.')}
|
||||
</p>
|
||||
<div className="flex items-center gap-6 flex-wrap">
|
||||
<label className="inline-flex items-center gap-2 text-sm cursor-pointer">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200 cursor-pointer">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
{t('reminderTemplates.enableLabel', 'Send pre-event reminder emails')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="reminder-days-before" className="text-sm">
|
||||
<label htmlFor="reminder-days-before" className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{t('reminderTemplates.daysBeforeLabel', 'Days before the event')}
|
||||
</label>
|
||||
<Input id="reminder-days-before" type="number" min={0} max={365}
|
||||
@@ -452,7 +456,7 @@ export const ReminderTemplatesPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-theme">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('reminderTemplates.variablesHint',
|
||||
'Available variables: {{customer_name}}, {{event_name}}, {{event_date}}, {{event_type}}, {{days_before}}, {{business_name}} — substituted when the email is rendered.')}
|
||||
</p>
|
||||
|
||||
@@ -66,7 +66,7 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{t('businessProfile.title', 'Business profile')}</h2>
|
||||
<h2 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('businessProfile.title', 'Business profile')}</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('businessProfile.subtitle', 'Issuer block shown on every quote and invoice PDF.')}
|
||||
</p>
|
||||
@@ -82,7 +82,7 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-3">{t('businessProfile.section.company', 'Company')}</h3>
|
||||
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('businessProfile.section.company', 'Company')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Input label={t('businessProfile.field.companyName', 'Company name') as string} value={profile.companyName}
|
||||
onChange={(e) => setProfile({ ...profile, companyName: e.target.value })} />
|
||||
@@ -116,7 +116,7 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-3">{t('businessProfile.section.contact', 'Contact')}</h3>
|
||||
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('businessProfile.section.contact', 'Contact')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Input label={t('businessProfile.field.phone', 'Phone') as string} value={profile.phone}
|
||||
onChange={(e) => setProfile({ ...profile, phone: e.target.value })} />
|
||||
@@ -130,7 +130,7 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-3">{t('businessProfile.section.defaults', 'Defaults')}</h3>
|
||||
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('businessProfile.section.defaults', 'Defaults')}</h3>
|
||||
{/* Pointer so admins who look for the old VAT/hourly-rate fields here
|
||||
know where they went. */}
|
||||
<p className="mb-3 rounded-md border border-blue-200 dark:border-blue-900/50 bg-blue-50 dark:bg-blue-900/20 px-3 py-2 text-xs text-blue-800 dark:text-blue-300">
|
||||
@@ -147,7 +147,7 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
<select
|
||||
value={normalizeCurrency(profile.defaultCurrency)}
|
||||
onChange={(e) => setProfile({ ...profile, defaultCurrency: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
{currencyOptions(profile.defaultCurrency).map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
@@ -166,7 +166,7 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
<select
|
||||
value={profile.timezone || ''}
|
||||
onChange={(e) => setProfile({ ...profile, timezone: e.target.value || null })}
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">
|
||||
{t('businessProfile.field.timezoneSystemDefault', 'System default')} ({Intl.DateTimeFormat().resolvedOptions().timeZone})
|
||||
@@ -180,9 +180,9 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
Settings → Accounting (so all financial/VAT config lives in one
|
||||
place). See the callout above. */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('businessProfile.field.defaultQrFormat', 'Default invoice QR')}</label>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('businessProfile.field.defaultQrFormat', 'Default invoice QR')}</label>
|
||||
<select value={profile.defaultQrFormat} onChange={(e) => setProfile({ ...profile, defaultQrFormat: e.target.value as QrFormat })}
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm">
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm">
|
||||
<option value="none">{t('businessProfile.qrFormat.none', 'None')}</option>
|
||||
<option value="swiss">{t('businessProfile.qrFormat.swiss', 'Swiss QR-bill (CH / LI)')}</option>
|
||||
<option value="epc">{t('businessProfile.qrFormat.epc', 'EPC QR (SEPA / EUR)')}</option>
|
||||
@@ -204,12 +204,12 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
onChange={(e) => setProfile({ ...profile, pdfLogoHeight: Number(e.target.value) })} />
|
||||
{/* Folding marks dropdown. */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('businessProfile.field.pdfFoldingMarks', 'Folding marks on PDF page edge')}
|
||||
</label>
|
||||
<select value={profile.pdfFoldingMarks || 'none'}
|
||||
onChange={(e) => setProfile({ ...profile, pdfFoldingMarks: e.target.value as any })}
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm">
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm">
|
||||
<option value="none">{t('businessProfile.foldingMarks.none', 'None')}</option>
|
||||
<option value="half">{t('businessProfile.foldingMarks.half', 'Half (148.5mm) — for C5 envelopes')}</option>
|
||||
<option value="third">{t('businessProfile.foldingMarks.third', 'Thirds (105 + 210mm) — for DL / DIN long envelopes')}</option>
|
||||
@@ -280,7 +280,7 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock className="w-5 h-5 text-neutral-500" />
|
||||
<h3 className="font-semibold">{t('businessProfile.businessHours.title', 'Business hours')}</h3>
|
||||
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">{t('businessProfile.businessHours.title', 'Business hours')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('businessProfile.businessHours.subtitle',
|
||||
@@ -546,7 +546,7 @@ const PdfLogoUploader: React.FC<PdfLogoUploaderProps> = ({ profile, setProfile }
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('businessProfile.field.pdfLogoUpload', 'PDF letterhead logo (PNG, JPEG, or SVG)')}
|
||||
</label>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
@@ -655,7 +655,7 @@ const BankAccountsSection: React.FC<BankAccountsSectionProps> = ({ accounts }) =
|
||||
onChange={(e) => setDraft({ ...draft, bic: e.target.value })} />
|
||||
<Input label={t('businessProfile.bank.currency', 'Currency') as string} value={draft.currency}
|
||||
maxLength={3} onChange={(e) => setDraft({ ...draft, currency: e.target.value.toUpperCase() })} />
|
||||
<label className="flex items-center gap-2 text-sm pt-6">
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200 pt-6">
|
||||
<input type="checkbox" checked={draft.isDefault}
|
||||
onChange={(e) => setDraft({ ...draft, isDefault: e.target.checked })} />
|
||||
{t('businessProfile.bank.isDefault', 'Default for this currency')}
|
||||
@@ -679,7 +679,7 @@ const BankAccountsSection: React.FC<BankAccountsSectionProps> = ({ accounts }) =
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold">{t('businessProfile.section.banks', 'Bank accounts')}</h3>
|
||||
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">{t('businessProfile.section.banks', 'Bank accounts')}</h3>
|
||||
<Button size="sm" onClick={() => {
|
||||
if (openForm === 'new') closeForm();
|
||||
else { setDraft(EMPTY_DRAFT); setOpenForm('new'); }
|
||||
@@ -698,7 +698,7 @@ const BankAccountsSection: React.FC<BankAccountsSectionProps> = ({ accounts }) =
|
||||
<React.Fragment key={b.id}>
|
||||
<li className="py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{b.label || b.iban}
|
||||
<div className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{b.label || b.iban}
|
||||
{b.isDefault && <Star className="inline w-4 h-4 ml-1 text-amber-500" />}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 font-mono">{b.iban.replace(/(.{4})/g, '$1 ').trim()}{b.currency ? ` · ${b.currency}` : ''}</div>
|
||||
|
||||
@@ -94,7 +94,7 @@ export const LegalPage: React.FC = () => {
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
||||
<Card className="max-w-md w-full mx-4">
|
||||
<div className="text-center py-12 px-6">
|
||||
<h2 className="text-xl font-semibold mb-2">Page Not Found</h2>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 mb-2">Page Not Found</h2>
|
||||
<p className="text-neutral-600 mb-6">
|
||||
The page you're looking for doesn't exist.
|
||||
</p>
|
||||
@@ -132,8 +132,13 @@ export const LegalPage: React.FC = () => {
|
||||
<Card padding="lg">
|
||||
<h1 className="text-3xl font-bold text-neutral-900 mb-8">{page.title}</h1>
|
||||
|
||||
<div
|
||||
className="prose prose-neutral max-w-none"
|
||||
{/* This page's chrome is hardcoded light (bg-neutral-50 wrapper,
|
||||
white card). Without an explicit text color the CMS body
|
||||
inherits `body { color: var(--color-text) }`, which a
|
||||
dark-toned branding theme sets to near-white — leaving the
|
||||
Impressum / Datenschutz text invisible (QA S3). */}
|
||||
<div
|
||||
className="prose prose-neutral max-w-none text-neutral-800"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(page.content, {
|
||||
ALLOWED_TAGS: [
|
||||
|
||||
Reference in New Issue
Block a user