Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/portal/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import { SigninPage } from './pages/Signin.js';
import { WorkspacesPage } from './pages/Workspaces.js';
import { AddBrandPage } from './pages/AddBrand.js';
import { PartnerJoinPage } from './pages/PartnerJoin.js';
import { BrandDocument } from './lib/BrandDocument.js';
import { PlatformMagicLandingPage } from './pages/auth/PlatformMagicLanding.js';
import { CreatorSignupPage } from './pages/creator/CreatorSignup.js';
import { CreatorSigninPage } from './pages/creator/CreatorSignin.js';
Expand Down Expand Up @@ -132,6 +133,7 @@ export function App() {

return (
<BrowserRouter>
<BrandDocument />
<Routes>
{isMultiTenant && hostTenantSlug ? (
<>
Expand Down
3 changes: 2 additions & 1 deletion apps/portal/src/__tests__/white-label-branding.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ function makeClient(whiteLabel: boolean): QueryClient {
});
// Seed the caches useBrand()/usePublicBrand() read so no fetch happens.
qc.setQueryData(['program-settings'], { ...BRAND, whiteLabel });
qc.setQueryData(['public-branding'], { ...BRAND, whiteLabel, tenantSlug: 'acme' });
// usePublicBrand keys by the /t/<slug>/ URL prefix; jsdom runs at '/'.
qc.setQueryData(['public-branding', null], { ...BRAND, whiteLabel, tenantSlug: 'acme' });
return qc;
}

Expand Down
59 changes: 59 additions & 0 deletions apps/portal/src/lib/BrandDocument.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { usePublicBrand, DEFAULT_BRAND } from './useBrand.js';

/**
* Keeps the HTML document itself on-brand: tab title, favicon, and og:title.
*
* index.html ships hardcoded "OpenPartner" + the platform favicon — the one
* pair of brand marks that survives every in-app branding change, cache
* clear, and incognito window (first real-world report: a white-label
* admin who "changed the name and logo but it still appears" — in the tab).
*
* White-label: title is the brand name alone and the favicon is the brand
* logo (or a neutral monogram — never the platform mark). Branded but
* non-white-label tenants get "Brand · OpenPartner". Platform surfaces
* keep the shipped defaults.
*/
export function BrandDocument() {
// Subscribe to navigation so usePublicBrand re-reads the /t/<slug>/ URL
// prefix when the user moves between platform and tenant surfaces.
useLocation();
const { programName, logoUrl, whiteLabel, brandColor, isLoading } = usePublicBrand();

useEffect(() => {
if (isLoading) return;
const branded = programName && programName !== DEFAULT_BRAND;
if (!branded && !whiteLabel) return; // platform surface — leave defaults

const title = whiteLabel ? programName : `${programName} · ${DEFAULT_BRAND}`;
document.title = title;
document.querySelector('meta[property="og:title"]')?.setAttribute('content', title);

const icon = logoUrl ?? (whiteLabel ? monogramIcon(programName, brandColor) : null);
if (icon) {
for (const el of Array.from(document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]'))) {
el.remove();
}
const link = document.createElement('link');
link.rel = 'icon';
link.href = icon;
document.head.appendChild(link);
}
}, [programName, logoUrl, whiteLabel, brandColor, isLoading]);

return null;
}

/** Tiny SVG monogram data-URI — the white-label no-logo fallback, so the
* tab never shows the platform mark. */
function monogramIcon(name: string, brandColor: string | null): string {
const initial = (name.trim()[0] ?? '?').toUpperCase();
const bg = brandColor && /^#[0-9a-fA-F]{3,8}$/.test(brandColor) ? brandColor : '#1f2937';
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">` +
`<rect width="32" height="32" rx="7" fill="${bg}"/>` +
`<text x="16" y="22" text-anchor="middle" font-family="system-ui,sans-serif" font-size="18" font-weight="700" fill="#ffffff">${initial}</text>` +
`</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
10 changes: 7 additions & 3 deletions apps/portal/src/lib/useBrand.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../api.js';
import { api, currentTenantSlug } from '../api.js';

/**
* Shape of `GET /config/program`. Mirrors the API's `ProgramSettings`
Expand Down Expand Up @@ -81,9 +81,12 @@ export interface PublicBranding {
* pages (no tenant in the URL / platform host) the API returns nulls and
* this falls back to the OpenPartner default.
*/
export function usePublicBrand(): Brand {
export function usePublicBrand(): Brand & { brandColor: string | null } {
// api() scopes by the /t/<slug>/ URL prefix at call time — key the cache
// by it too, or an SPA navigation from a platform page (nulls) into a
// tenant page would keep serving the platform's empty branding.
const { data, isLoading } = useQuery({
queryKey: ['public-branding'],
queryKey: ['public-branding', currentTenantSlug()],
queryFn: () => api<PublicBranding>('/branding'),
staleTime: 60_000,
});
Expand All @@ -92,6 +95,7 @@ export function usePublicBrand(): Brand {
logoUrl: data?.logoUrl ?? null,
supportEmail: data?.supportEmail || null,
whiteLabel: data?.whiteLabel ?? false,
brandColor: data?.brandColor ?? null,
isLoading,
};
}
Loading