fix(branding): theme-aware logo on customer-facing public pages

The public quote, contract-signing, and payment-check pages baked a single
light logo (the contract page showed none), so the dark page rendered a
dark-text logo on a dark background.

- usePublicDarkMode now returns { isDark } (reactive) alongside applying
  the .dark class, so pages can pick a theme-aware asset.
- The three public routes now surface both branding logo URLs (logoUrl +
  logoUrlDark) in the issuer block; the contract issuer gains a logo too.
- QuoteResponsePage, ContractResponsePage, and the payment-check
  BrandingHeader pick the dark variant when isDark, falling back to
  whichever exists. Covers the accept/accepted states of each page.
This commit is contained in:
Luca
2026-06-03 16:48:54 +02:00
parent a5011b1ea2
commit 05c1e8d18b
10 changed files with 109 additions and 35 deletions
+20 -1
View File
@@ -24,6 +24,15 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout
const { validateFileType } = require('../utils/fileSecurityUtils'); const { validateFileType } = require('../utils/fileSecurityUtils');
const contractService = require('../services/contractService'); const contractService = require('../services/contractService');
const { getAppSetting } = require('../utils/appSettings'); const { getAppSetting } = require('../utils/appSettings');
// Normalise a Settings → Branding logo value (absolute URL, /-rooted path,
// or bare `uploads/...` filename) into a URL the public page can load.
function normalizeBrandingLogoUrl(raw) {
const value = (raw && String(raw).trim()) || null;
if (!value) return null;
if (value.startsWith('/') || /^https?:\/\//i.test(value)) return value;
return `/uploads/${value.replace(/^uploads\//, '')}`;
}
const { clientIpForAudit } = require('../utils/clientIp'); const { clientIpForAudit } = require('../utils/clientIp');
const { loadActionToken, preMulterTokenGuard } = require('../utils/publicTokenGuards'); const { loadActionToken, preMulterTokenGuard } = require('../utils/publicTokenGuards');
const { db } = require('../database/db'); const { db } = require('../database/db');
@@ -69,7 +78,7 @@ const signedPdfUpload = multer({
* The IP / signature image paths are NEVER exposed publicly even after * The IP / signature image paths are NEVER exposed publicly even after
* signing — they're audit evidence. * signing — they're audit evidence.
*/ */
function publicContractView(contract, inclusions, customer, profile, locale) { function publicContractView(contract, inclusions, customer, profile, locale, brandingLogoUrl, brandingLogoUrlDark) {
const orderedSections = ['basics', 'scope', 'privacy', 'commercial', 'nda', 'closing']; const orderedSections = ['basics', 'scope', 'privacy', 'commercial', 'nda', 'closing'];
const blocksBySection = {}; const blocksBySection = {};
for (const s of orderedSections) blocksBySection[s] = []; for (const s of orderedSections) blocksBySection[s] = [];
@@ -143,6 +152,12 @@ function publicContractView(contract, inclusions, customer, profile, locale) {
city: profile.city, city: profile.city,
email: profile.email, email: profile.email,
website: profile.website, website: profile.website,
// Light + dark branding logos (Settings → Branding), so the public
// sign page renders the logo that reads in its resolved colour mode.
// Mirrors publicQuotes — the print-only business_profile.logo_path is
// intentionally NOT used here.
logoUrl: normalizeBrandingLogoUrl(brandingLogoUrl),
logoUrlDark: normalizeBrandingLogoUrl(brandingLogoUrlDark),
} : null, } : null,
}; };
} }
@@ -168,12 +183,16 @@ router.get(
// re-enforces both, so client tampering only changes the UX. // re-enforces both, so client tampering only changes the UX.
const allowPdfUpload = (await getAppSetting('crm_contracts_allow_pdf_upload')) !== false; const allowPdfUpload = (await getAppSetting('crm_contracts_allow_pdf_upload')) !== false;
const requireDrawnSignature = (await getAppSetting('crm_contracts_require_drawn_signature')) === true; const requireDrawnSignature = (await getAppSetting('crm_contracts_require_drawn_signature')) === true;
const brandingLogoUrl = await getAppSetting('branding_logo_url', null);
const brandingLogoUrlDark = await getAppSetting('branding_logo_url_dark', null);
const view = publicContractView( const view = publicContractView(
data.contract, data.contract,
data.inclusions, data.inclusions,
customer, customer,
profile, profile,
data.contract.language || 'de', data.contract.language || 'de',
brandingLogoUrl,
brandingLogoUrlDark,
); );
view.allowPdfUpload = allowPdfUpload; view.allowPdfUpload = allowPdfUpload;
view.requireDrawnSignature = requireDrawnSignature; view.requireDrawnSignature = requireDrawnSignature;
+10 -6
View File
@@ -50,16 +50,20 @@ router.get(
const { getAppSetting } = require('../utils/appSettings'); const { getAppSetting } = require('../utils/appSettings');
const profile = await db('business_profile').where({ id: 1 }).first(); const profile = await db('business_profile').where({ id: 1 }).first();
const brandingLogoUrl = await getAppSetting('branding_logo_url', null); const brandingLogoUrl = await getAppSetting('branding_logo_url', null);
const brandingLogoUrlDark = await getAppSetting('branding_logo_url_dark', null);
const toUrl = (raw) => {
const value = (raw && String(raw).trim()) || null;
if (!value) return null;
if (value.startsWith('/') || /^https?:\/\//i.test(value)) return value;
return `/uploads/${value.replace(/^uploads\//, '')}`;
};
const issuer = profile ? { const issuer = profile ? {
companyName: profile.company_name || '', companyName: profile.company_name || '',
email: profile.email || '', email: profile.email || '',
website: profile.website || '', website: profile.website || '',
logoUrl: (() => { // Light + dark branding logos — the page picks per its colour mode.
const raw = (brandingLogoUrl && String(brandingLogoUrl).trim()) || null; logoUrl: toUrl(brandingLogoUrl),
if (!raw) return null; logoUrlDark: toUrl(brandingLogoUrlDark),
if (raw.startsWith('/') || /^https?:\/\//i.test(raw)) return raw;
return `/uploads/${raw.replace(/^uploads\//, '')}`;
})(),
} : null; } : null;
return successResponse(res, { invoice: view, issuer }); return successResponse(res, { invoice: view, issuer });
+17 -9
View File
@@ -23,6 +23,15 @@ const { loadActionToken } = require('../utils/publicTokenGuards');
const router = express.Router(); const router = express.Router();
// Normalise a Settings → Branding logo value (absolute URL, /-rooted path,
// or bare `uploads/...` filename) into a URL the public page can load.
function normalizeBrandingLogoUrl(raw) {
const value = (raw && String(raw).trim()) || null;
if (!value) return null;
if (value.startsWith('/') || /^https?:\/\//i.test(value)) return value;
return `/uploads/${value.replace(/^uploads\//, '')}`;
}
// Rate-limit: 30 token previews per IP per minute, 10 responses. // Rate-limit: 30 token previews per IP per minute, 10 responses.
const previewLimiter = rateLimit({ const previewLimiter = rateLimit({
windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false,
@@ -31,7 +40,7 @@ const respondLimiter = rateLimit({
windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false,
}); });
function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl) { function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl, brandingLogoUrlDark) {
return { return {
quoteNumber: quote.quote_number, quoteNumber: quote.quote_number,
status: quote.status, status: quote.status,
@@ -98,13 +107,11 @@ function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosTe
// there). On the web page the existing site branding already // there). On the web page the existing site branding already
// serves both light + dark modes correctly, so falling back // serves both light + dark modes correctly, so falling back
// to a PDF-only image would override that with a light // to a PDF-only image would override that with a light
// version that doesn't read in dark mode. // version that doesn't read in dark mode. Both light + dark
logoUrl: (() => { // branding URLs are surfaced so the page can pick the one that
const raw = (brandingLogoUrl && String(brandingLogoUrl).trim()) || null; // matches its resolved colour mode (see usePublicDarkMode).
if (!raw) return null; logoUrl: normalizeBrandingLogoUrl(brandingLogoUrl),
if (raw.startsWith('/') || /^https?:\/\//i.test(raw)) return raw; logoUrlDark: normalizeBrandingLogoUrl(brandingLogoUrlDark),
return `/uploads/${raw.replace(/^uploads\//, '')}`;
})(),
} : null, } : null,
}; };
} }
@@ -137,9 +144,10 @@ router.get(
// — admins typically upload one logo via Settings → Branding and // — admins typically upload one logo via Settings → Branding and
// expect it to flow through the customer-facing pages too. // expect it to flow through the customer-facing pages too.
const brandingLogoUrl = await getAppSetting('branding_logo_url', null); const brandingLogoUrl = await getAppSetting('branding_logo_url', null);
const brandingLogoUrlDark = await getAppSetting('branding_logo_url_dark', null);
return successResponse(res, { return successResponse(res, {
quote: publicQuoteView(data.quote, data.lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl), quote: publicQuoteView(data.quote, data.lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl, brandingLogoUrlDark),
}); });
}) })
); );
+19 -6
View File
@@ -14,16 +14,28 @@
* Shared by QuoteResponsePage + PaymentCheckPage. Adding a third * Shared by QuoteResponsePage + PaymentCheckPage. Adding a third
* public-page consumer? Reuse this hook. * public-page consumer? Reuse this hook.
*/ */
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { usePublicSettings } from './usePublicSettings'; import { usePublicSettings } from './usePublicSettings';
export function usePublicDarkMode() { /**
* Returns `{ isDark }` (reactive) in addition to applying the `.dark`
* class, so callers can pick a theme-aware asset (e.g. the dark-mode
* logo) without re-deriving the mode themselves.
*/
export function usePublicDarkMode(): { isDark: boolean } {
const { data: publicSettings } = usePublicSettings(); const { data: publicSettings } = usePublicSettings();
const forced = publicSettings?.branding_force_color_mode;
const [isDark, setIsDark] = useState<boolean>(() => {
if (forced === 'dark') return true;
if (forced === 'light') return false;
return typeof window !== 'undefined'
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
});
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
const forced = publicSettings?.branding_force_color_mode; const apply = (dark: boolean) => {
const apply = (isDark: boolean) => { setIsDark(dark);
if (isDark) root.classList.add('dark'); if (dark) root.classList.add('dark');
else root.classList.remove('dark'); else root.classList.remove('dark');
}; };
if (forced === 'dark') { if (forced === 'dark') {
@@ -39,5 +51,6 @@ export function usePublicDarkMode() {
const listener = (e: MediaQueryListEvent) => apply(e.matches); const listener = (e: MediaQueryListEvent) => apply(e.matches);
mql.addEventListener('change', listener); mql.addEventListener('change', listener);
return () => mql.removeEventListener('change', listener); return () => mql.removeEventListener('change', listener);
}, [publicSettings?.branding_force_color_mode]); }, [forced]);
return { isDark };
} }
@@ -81,8 +81,9 @@ export const ContractResponsePage: React.FC = () => {
// Honour branding dark/light mode the same way QuoteResponsePage // Honour branding dark/light mode the same way QuoteResponsePage
// does — without this the page renders in light regardless of admin // does — without this the page renders in light regardless of admin
// settings. The wrapper styling below still has `dark:` variants // settings. The wrapper styling below still has `dark:` variants
// so the page reads cleanly in either mode. // so the page reads cleanly in either mode. `isDark` drives the
usePublicDarkMode(); // theme-aware logo pick in the header.
const { isDark } = usePublicDarkMode();
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const padRef = useRef<SignaturePad | null>(null); const padRef = useRef<SignaturePad | null>(null);
@@ -233,6 +234,18 @@ export const ContractResponsePage: React.FC = () => {
<div className="max-w-3xl mx-auto py-8 px-4"> <div className="max-w-3xl mx-auto py-8 px-4">
{/* Issuer header — same shape as QuoteResponsePage. */} {/* Issuer header — same shape as QuoteResponsePage. */}
<div className="text-center mb-6"> <div className="text-center mb-6">
{(() => {
const logo = isDark
? (c.issuer?.logoUrlDark || c.issuer?.logoUrl)
: (c.issuer?.logoUrl || c.issuer?.logoUrlDark);
return logo ? (
<img
src={logo}
alt={c.issuer?.companyName || 'Logo'}
className="mx-auto mb-3 h-16 object-contain"
/>
) : null;
})()}
{c.issuer?.companyName && ( {c.issuer?.companyName && (
<h2 className="text-xl font-bold">{c.issuer.companyName}</h2> <h2 className="text-xl font-bold">{c.issuer.companyName}</h2>
)} )}
@@ -260,12 +260,16 @@ export const PaymentCheckPage: React.FC = () => {
}; };
const BrandingHeader: React.FC<{ issuer: PaymentCheckIssuer | null }> = ({ issuer }) => { const BrandingHeader: React.FC<{ issuer: PaymentCheckIssuer | null }> = ({ issuer }) => {
if (!issuer || (!issuer.logoUrl && !issuer.companyName)) return null; const { isDark } = usePublicDarkMode();
if (!issuer || (!issuer.logoUrl && !issuer.logoUrlDark && !issuer.companyName)) return null;
const logo = isDark
? (issuer.logoUrlDark || issuer.logoUrl)
: (issuer.logoUrl || issuer.logoUrlDark);
return ( return (
<header className="text-center mb-8"> <header className="text-center mb-8">
{issuer.logoUrl && ( {logo && (
<img <img
src={issuer.logoUrl} src={logo}
alt={issuer.companyName || 'Logo'} alt={issuer.companyName || 'Logo'}
className="mx-auto h-16 w-auto object-contain mb-3" className="mx-auto h-16 w-auto object-contain mb-3"
/> />
@@ -39,7 +39,8 @@ export const QuoteResponsePage: React.FC = () => {
// Apply dark mode per the branding settings (forced dark/light) // Apply dark mode per the branding settings (forced dark/light)
// or fall back to the OS preference. Without this the public // or fall back to the OS preference. Without this the public
// quote page renders in light mode regardless of admin settings. // quote page renders in light mode regardless of admin settings.
usePublicDarkMode(); // `isDark` drives the theme-aware logo pick below.
const { isDark } = usePublicDarkMode();
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['public-quote', token], queryKey: ['public-quote', token],
@@ -153,13 +154,18 @@ export const QuoteResponsePage: React.FC = () => {
the backend storage directory directly. */} the backend storage directory directly. */}
{quote.issuer && ( {quote.issuer && (
<div className="text-center mb-6"> <div className="text-center mb-6">
{quote.issuer.logoUrl && ( {(() => {
<img const logo = isDark
src={quote.issuer.logoUrl} ? (quote.issuer.logoUrlDark || quote.issuer.logoUrl)
alt={quote.issuer.companyName || 'Logo'} : (quote.issuer.logoUrl || quote.issuer.logoUrlDark);
className="mx-auto mb-3 h-16 object-contain" return logo ? (
/> <img
)} src={logo}
alt={quote.issuer.companyName || 'Logo'}
className="mx-auto mb-3 h-16 object-contain"
/>
) : null;
})()}
<h2 className="text-xl font-bold">{quote.issuer.companyName}</h2> <h2 className="text-xl font-bold">{quote.issuer.companyName}</h2>
{quote.issuer.website && ( {quote.issuer.website && (
<p className="text-sm text-neutral-500 dark:text-neutral-400">{quote.issuer.website}</p> <p className="text-sm text-neutral-500 dark:text-neutral-400">{quote.issuer.website}</p>
@@ -429,6 +429,9 @@ export interface PublicContractView {
city: string | null; city: string | null;
email: string | null; email: string | null;
website: string | null; website: string | null;
/** Light + dark branding logo URLs; the page picks per its colour mode. */
logoUrl?: string | null;
logoUrlDark?: string | null;
} | null; } | null;
/** Admin-set behaviour flags surfaced for the public sign page. /** Admin-set behaviour flags surfaced for the public sign page.
* Server re-enforces both — these only drive the UI. */ * Server re-enforces both — these only drive the UI. */
@@ -32,6 +32,8 @@ export interface PaymentCheckIssuer {
email?: string; email?: string;
website?: string; website?: string;
logoUrl: string | null; logoUrl: string | null;
/** Dark-mode branding logo; the page picks per its colour mode. */
logoUrlDark?: string | null;
} }
export interface PaymentCheckResponse { export interface PaymentCheckResponse {
invoice: PaymentCheckView; invoice: PaymentCheckView;
+2
View File
@@ -390,6 +390,8 @@ export interface PublicQuoteView {
footerLine: string; footerLine: string;
/** Absolute or /uploads/-prefixed URL set by the public route. */ /** Absolute or /uploads/-prefixed URL set by the public route. */
logoUrl?: string | null; logoUrl?: string | null;
/** Dark-mode branding logo; the page picks per its colour mode. */
logoUrlDark?: string | null;
} | null; } | null;
} }