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:
@@ -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<boolean>(() => {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<HTMLCanvasElement>(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">
|
||||
{/* Issuer header — same shape as QuoteResponsePage. */}
|
||||
<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 && (
|
||||
<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 }) => {
|
||||
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 (
|
||||
<header className="text-center mb-8">
|
||||
{issuer.logoUrl && (
|
||||
{logo && (
|
||||
<img
|
||||
src={issuer.logoUrl}
|
||||
src={logo}
|
||||
alt={issuer.companyName || 'Logo'}
|
||||
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)
|
||||
// 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 && (
|
||||
<div className="text-center mb-6">
|
||||
{quote.issuer.logoUrl && (
|
||||
<img
|
||||
src={quote.issuer.logoUrl}
|
||||
alt={quote.issuer.companyName || 'Logo'}
|
||||
className="mx-auto mb-3 h-16 object-contain"
|
||||
/>
|
||||
)}
|
||||
{(() => {
|
||||
const logo = isDark
|
||||
? (quote.issuer.logoUrlDark || quote.issuer.logoUrl)
|
||||
: (quote.issuer.logoUrl || quote.issuer.logoUrlDark);
|
||||
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>
|
||||
{quote.issuer.website && (
|
||||
<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;
|
||||
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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user