From 05c1e8d18ba1e2029e2da62d6ffb4c701b725660 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:48:54 +0200 Subject: [PATCH] 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. --- backend/src/routes/publicContracts.js | 21 ++++++++++++++- backend/src/routes/publicPaymentCheck.js | 16 +++++++----- backend/src/routes/publicQuotes.js | 26 ++++++++++++------- frontend/src/hooks/usePublicDarkMode.ts | 25 +++++++++++++----- .../src/pages/public/ContractResponsePage.tsx | 17 ++++++++++-- .../src/pages/public/PaymentCheckPage.tsx | 10 ++++--- .../src/pages/public/QuoteResponsePage.tsx | 22 ++++++++++------ frontend/src/services/contracts.service.ts | 3 +++ frontend/src/services/paymentCheck.service.ts | 2 ++ frontend/src/services/quotes.service.ts | 2 ++ 10 files changed, 109 insertions(+), 35 deletions(-) diff --git a/backend/src/routes/publicContracts.js b/backend/src/routes/publicContracts.js index f7f71d9d..374f45c1 100644 --- a/backend/src/routes/publicContracts.js +++ b/backend/src/routes/publicContracts.js @@ -24,6 +24,15 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout const { validateFileType } = require('../utils/fileSecurityUtils'); const contractService = require('../services/contractService'); 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 { loadActionToken, preMulterTokenGuard } = require('../utils/publicTokenGuards'); const { db } = require('../database/db'); @@ -69,7 +78,7 @@ const signedPdfUpload = multer({ * The IP / signature image paths are NEVER exposed publicly even after * 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 blocksBySection = {}; for (const s of orderedSections) blocksBySection[s] = []; @@ -143,6 +152,12 @@ function publicContractView(contract, inclusions, customer, profile, locale) { city: profile.city, email: profile.email, 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, }; } @@ -168,12 +183,16 @@ router.get( // re-enforces both, so client tampering only changes the UX. const allowPdfUpload = (await getAppSetting('crm_contracts_allow_pdf_upload')) !== false; 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( data.contract, data.inclusions, customer, profile, data.contract.language || 'de', + brandingLogoUrl, + brandingLogoUrlDark, ); view.allowPdfUpload = allowPdfUpload; view.requireDrawnSignature = requireDrawnSignature; diff --git a/backend/src/routes/publicPaymentCheck.js b/backend/src/routes/publicPaymentCheck.js index 63f49aaa..ce5ebdfc 100644 --- a/backend/src/routes/publicPaymentCheck.js +++ b/backend/src/routes/publicPaymentCheck.js @@ -50,16 +50,20 @@ router.get( const { getAppSetting } = require('../utils/appSettings'); const profile = await db('business_profile').where({ id: 1 }).first(); 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 ? { companyName: profile.company_name || '', email: profile.email || '', website: profile.website || '', - logoUrl: (() => { - const raw = (brandingLogoUrl && String(brandingLogoUrl).trim()) || null; - if (!raw) return null; - if (raw.startsWith('/') || /^https?:\/\//i.test(raw)) return raw; - return `/uploads/${raw.replace(/^uploads\//, '')}`; - })(), + // Light + dark branding logos — the page picks per its colour mode. + logoUrl: toUrl(brandingLogoUrl), + logoUrlDark: toUrl(brandingLogoUrlDark), } : null; return successResponse(res, { invoice: view, issuer }); diff --git a/backend/src/routes/publicQuotes.js b/backend/src/routes/publicQuotes.js index a84766ae..0deb3b7b 100644 --- a/backend/src/routes/publicQuotes.js +++ b/backend/src/routes/publicQuotes.js @@ -23,6 +23,15 @@ const { loadActionToken } = require('../utils/publicTokenGuards'); 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. const previewLimiter = rateLimit({ 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, }); -function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl) { +function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl, brandingLogoUrlDark) { return { quoteNumber: quote.quote_number, 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 // serves both light + dark modes correctly, so falling back // to a PDF-only image would override that with a light - // version that doesn't read in dark mode. - logoUrl: (() => { - const raw = (brandingLogoUrl && String(brandingLogoUrl).trim()) || null; - if (!raw) return null; - if (raw.startsWith('/') || /^https?:\/\//i.test(raw)) return raw; - return `/uploads/${raw.replace(/^uploads\//, '')}`; - })(), + // version that doesn't read in dark mode. Both light + dark + // branding URLs are surfaced so the page can pick the one that + // matches its resolved colour mode (see usePublicDarkMode). + logoUrl: normalizeBrandingLogoUrl(brandingLogoUrl), + logoUrlDark: normalizeBrandingLogoUrl(brandingLogoUrlDark), } : null, }; } @@ -137,9 +144,10 @@ router.get( // — admins typically upload one logo via Settings → Branding and // expect it to flow through the customer-facing pages too. const brandingLogoUrl = await getAppSetting('branding_logo_url', null); + const brandingLogoUrlDark = await getAppSetting('branding_logo_url_dark', null); 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), }); }) ); diff --git a/frontend/src/hooks/usePublicDarkMode.ts b/frontend/src/hooks/usePublicDarkMode.ts index 84d83688..a61fbd04 100644 --- a/frontend/src/hooks/usePublicDarkMode.ts +++ b/frontend/src/hooks/usePublicDarkMode.ts @@ -14,16 +14,28 @@ * Shared by QuoteResponsePage + PaymentCheckPage. Adding a third * public-page consumer? Reuse this hook. */ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; 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 forced = publicSettings?.branding_force_color_mode; + const [isDark, setIsDark] = useState(() => { + if (forced === 'dark') return true; + if (forced === 'light') return false; + return typeof window !== 'undefined' + && window.matchMedia('(prefers-color-scheme: dark)').matches; + }); useEffect(() => { const root = document.documentElement; - const forced = publicSettings?.branding_force_color_mode; - const apply = (isDark: boolean) => { - if (isDark) root.classList.add('dark'); + const apply = (dark: boolean) => { + setIsDark(dark); + if (dark) root.classList.add('dark'); else root.classList.remove('dark'); }; if (forced === 'dark') { @@ -39,5 +51,6 @@ export function usePublicDarkMode() { const listener = (e: MediaQueryListEvent) => apply(e.matches); mql.addEventListener('change', listener); return () => mql.removeEventListener('change', listener); - }, [publicSettings?.branding_force_color_mode]); + }, [forced]); + return { isDark }; } diff --git a/frontend/src/pages/public/ContractResponsePage.tsx b/frontend/src/pages/public/ContractResponsePage.tsx index f8bca614..ffff42bf 100644 --- a/frontend/src/pages/public/ContractResponsePage.tsx +++ b/frontend/src/pages/public/ContractResponsePage.tsx @@ -81,8 +81,9 @@ export const ContractResponsePage: React.FC = () => { // Honour branding dark/light mode the same way QuoteResponsePage // does — without this the page renders in light regardless of admin // settings. The wrapper styling below still has `dark:` variants - // so the page reads cleanly in either mode. - usePublicDarkMode(); + // so the page reads cleanly in either mode. `isDark` drives the + // theme-aware logo pick in the header. + const { isDark } = usePublicDarkMode(); const canvasRef = useRef(null); const padRef = useRef(null); @@ -233,6 +234,18 @@ export const ContractResponsePage: React.FC = () => {
{/* Issuer header — same shape as QuoteResponsePage. */}
+ {(() => { + const logo = isDark + ? (c.issuer?.logoUrlDark || c.issuer?.logoUrl) + : (c.issuer?.logoUrl || c.issuer?.logoUrlDark); + return logo ? ( + {c.issuer?.companyName + ) : null; + })()} {c.issuer?.companyName && (

{c.issuer.companyName}

)} diff --git a/frontend/src/pages/public/PaymentCheckPage.tsx b/frontend/src/pages/public/PaymentCheckPage.tsx index 0bcdbd2c..2a3a376a 100644 --- a/frontend/src/pages/public/PaymentCheckPage.tsx +++ b/frontend/src/pages/public/PaymentCheckPage.tsx @@ -260,12 +260,16 @@ export const PaymentCheckPage: React.FC = () => { }; 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 (
- {issuer.logoUrl && ( + {logo && ( {issuer.companyName diff --git a/frontend/src/pages/public/QuoteResponsePage.tsx b/frontend/src/pages/public/QuoteResponsePage.tsx index d270d578..44449d07 100644 --- a/frontend/src/pages/public/QuoteResponsePage.tsx +++ b/frontend/src/pages/public/QuoteResponsePage.tsx @@ -39,7 +39,8 @@ export const QuoteResponsePage: React.FC = () => { // Apply dark mode per the branding settings (forced dark/light) // or fall back to the OS preference. Without this the public // 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({ queryKey: ['public-quote', token], @@ -153,13 +154,18 @@ export const QuoteResponsePage: React.FC = () => { the backend storage directory directly. */} {quote.issuer && (
- {quote.issuer.logoUrl && ( - {quote.issuer.companyName - )} + {(() => { + const logo = isDark + ? (quote.issuer.logoUrlDark || quote.issuer.logoUrl) + : (quote.issuer.logoUrl || quote.issuer.logoUrlDark); + return logo ? ( + {quote.issuer.companyName + ) : null; + })()}

{quote.issuer.companyName}

{quote.issuer.website && (

{quote.issuer.website}

diff --git a/frontend/src/services/contracts.service.ts b/frontend/src/services/contracts.service.ts index 3ed37b8e..974e28e9 100644 --- a/frontend/src/services/contracts.service.ts +++ b/frontend/src/services/contracts.service.ts @@ -429,6 +429,9 @@ export interface PublicContractView { city: string | null; email: string | null; website: string | null; + /** Light + dark branding logo URLs; the page picks per its colour mode. */ + logoUrl?: string | null; + logoUrlDark?: string | null; } | null; /** Admin-set behaviour flags surfaced for the public sign page. * Server re-enforces both — these only drive the UI. */ diff --git a/frontend/src/services/paymentCheck.service.ts b/frontend/src/services/paymentCheck.service.ts index 88e54324..d8252db9 100644 --- a/frontend/src/services/paymentCheck.service.ts +++ b/frontend/src/services/paymentCheck.service.ts @@ -32,6 +32,8 @@ export interface PaymentCheckIssuer { email?: string; website?: string; logoUrl: string | null; + /** Dark-mode branding logo; the page picks per its colour mode. */ + logoUrlDark?: string | null; } export interface PaymentCheckResponse { invoice: PaymentCheckView; diff --git a/frontend/src/services/quotes.service.ts b/frontend/src/services/quotes.service.ts index ffc00aec..9c136424 100644 --- a/frontend/src/services/quotes.service.ts +++ b/frontend/src/services/quotes.service.ts @@ -390,6 +390,8 @@ export interface PublicQuoteView { footerLine: string; /** Absolute or /uploads/-prefixed URL set by the public route. */ logoUrl?: string | null; + /** Dark-mode branding logo; the page picks per its colour mode. */ + logoUrlDark?: string | null; } | null; }