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 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;
+10 -6
View File
@@ -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 });
+17 -9
View File
@@ -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),
});
})
);