@bsuite/theme 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/react/BrandingProvider.d.ts +63 -0
- package/dist/react/BrandingProvider.d.ts.map +1 -0
- package/dist/react/BrandingProvider.js +183 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/useBranding.d.ts +11 -0
- package/dist/react/useBranding.d.ts.map +1 -0
- package/dist/react/useBranding.js +18 -0
- package/package.json +7 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrandingProvider — runtime enterprise white-labelling
|
|
3
|
+
*
|
|
4
|
+
* On mount:
|
|
5
|
+
* 1. Calls supabase.rpc('branding_json_for_tenant') using the user's authed session.
|
|
6
|
+
* 2. Applies each key as a CSS custom property on document.documentElement.
|
|
7
|
+
* 3. Caches the result in localStorage under BRANDING_STORAGE_KEY for FOUC prevention.
|
|
8
|
+
* 4. Subscribes to tenant row changes via Supabase Realtime so branding updates
|
|
9
|
+
* propagate without a page reload (within ~1 second).
|
|
10
|
+
*
|
|
11
|
+
* Environment flags:
|
|
12
|
+
* - VITE_ENABLE_BRANDING_OVERRIDE (default: 'true') — set to 'false' to disable
|
|
13
|
+
* entirely without code changes (emergency kill switch).
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* Wrap BrandingProvider inside ThemeProvider so branding CSS vars override theme defaults:
|
|
17
|
+
*
|
|
18
|
+
* <ThemeProvider>
|
|
19
|
+
* <BrandingProvider supabaseClient={supabase}>
|
|
20
|
+
* {children}
|
|
21
|
+
* </BrandingProvider>
|
|
22
|
+
* </ThemeProvider>
|
|
23
|
+
*/
|
|
24
|
+
import type { ReactNode } from 'react';
|
|
25
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
26
|
+
export declare const BRANDING_STORAGE_KEY = "bsuite_tenant_branding";
|
|
27
|
+
export declare const BRANDING_OVERRIDE_FLAG = "VITE_ENABLE_BRANDING_OVERRIDE";
|
|
28
|
+
/** Shape returned by the branding_json_for_tenant RPC */
|
|
29
|
+
export interface TenantBranding {
|
|
30
|
+
/** Primary action colour as an OKLCH string, e.g. "oklch(0.55 0.22 265)" */
|
|
31
|
+
primary?: string;
|
|
32
|
+
/** Accent / secondary colour as an OKLCH string */
|
|
33
|
+
accent?: string;
|
|
34
|
+
/** Full logo URL (SVG or raster) */
|
|
35
|
+
logo_url?: string;
|
|
36
|
+
/** Mark / icon logo URL */
|
|
37
|
+
mark_url?: string;
|
|
38
|
+
/** Optional CSS font-family stack string */
|
|
39
|
+
font_stack?: string | null;
|
|
40
|
+
}
|
|
41
|
+
export interface BrandingContextValue {
|
|
42
|
+
/** Current resolved branding, or null if no tenant override */
|
|
43
|
+
branding: TenantBranding | null;
|
|
44
|
+
/** True while the initial RPC call is in flight */
|
|
45
|
+
isLoading: boolean;
|
|
46
|
+
/** Any error from the RPC call */
|
|
47
|
+
error: Error | null;
|
|
48
|
+
/** Manually trigger a branding refresh */
|
|
49
|
+
refresh: () => Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
export declare const BrandingContext: import("react").Context<BrandingContextValue | undefined>;
|
|
52
|
+
export interface BrandingProviderProps {
|
|
53
|
+
children: ReactNode;
|
|
54
|
+
/** Authenticated Supabase client from the consuming app */
|
|
55
|
+
supabaseClient: SupabaseClient;
|
|
56
|
+
/**
|
|
57
|
+
* If true, apply persisted branding immediately on mount (before the RPC
|
|
58
|
+
* resolves) to prevent FOUC. Default: true.
|
|
59
|
+
*/
|
|
60
|
+
applyPersistedOnMount?: boolean;
|
|
61
|
+
}
|
|
62
|
+
export declare function BrandingProvider({ children, supabaseClient, applyPersistedOnMount, }: BrandingProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
63
|
+
//# sourceMappingURL=BrandingProvider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BrandingProvider.d.ts","sourceRoot":"","sources":["../../src/react/BrandingProvider.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAUH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAE3D,eAAO,MAAM,oBAAoB,2BAA2B,CAAA;AAC5D,eAAO,MAAM,sBAAsB,kCAAkC,CAAA;AAErE,yDAAyD;AACzD,MAAM,WAAW,cAAc;IAC7B,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,oCAAoC;IACpC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,2BAA2B;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4CAA4C;IAC5C,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC3B;AAED,MAAM,WAAW,oBAAoB;IACnC,+DAA+D;IAC/D,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAA;IAC/B,mDAAmD;IACnD,SAAS,EAAE,OAAO,CAAA;IAClB,kCAAkC;IAClC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,0CAA0C;IAC1C,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC7B;AAED,eAAO,MAAM,eAAe,2DAA6D,CAAA;AAqFzF,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,SAAS,CAAA;IACnB,2DAA2D;IAC3D,cAAc,EAAE,cAAc,CAAA;IAC9B;;;OAGG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC;AAED,wBAAgB,gBAAgB,CAAC,EAC/B,QAAQ,EACR,cAAc,EACd,qBAA4B,GAC7B,EAAE,qBAAqB,2CAyFvB"}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* BrandingProvider — runtime enterprise white-labelling
|
|
4
|
+
*
|
|
5
|
+
* On mount:
|
|
6
|
+
* 1. Calls supabase.rpc('branding_json_for_tenant') using the user's authed session.
|
|
7
|
+
* 2. Applies each key as a CSS custom property on document.documentElement.
|
|
8
|
+
* 3. Caches the result in localStorage under BRANDING_STORAGE_KEY for FOUC prevention.
|
|
9
|
+
* 4. Subscribes to tenant row changes via Supabase Realtime so branding updates
|
|
10
|
+
* propagate without a page reload (within ~1 second).
|
|
11
|
+
*
|
|
12
|
+
* Environment flags:
|
|
13
|
+
* - VITE_ENABLE_BRANDING_OVERRIDE (default: 'true') — set to 'false' to disable
|
|
14
|
+
* entirely without code changes (emergency kill switch).
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* Wrap BrandingProvider inside ThemeProvider so branding CSS vars override theme defaults:
|
|
18
|
+
*
|
|
19
|
+
* <ThemeProvider>
|
|
20
|
+
* <BrandingProvider supabaseClient={supabase}>
|
|
21
|
+
* {children}
|
|
22
|
+
* </BrandingProvider>
|
|
23
|
+
* </ThemeProvider>
|
|
24
|
+
*/
|
|
25
|
+
import { createContext, useCallback, useEffect, useMemo, useRef, useState, } from 'react';
|
|
26
|
+
export const BRANDING_STORAGE_KEY = 'bsuite_tenant_branding';
|
|
27
|
+
export const BRANDING_OVERRIDE_FLAG = 'VITE_ENABLE_BRANDING_OVERRIDE';
|
|
28
|
+
export const BrandingContext = createContext(undefined);
|
|
29
|
+
const BRANDING_CSS_MAP = {
|
|
30
|
+
primary: '--primary',
|
|
31
|
+
accent: '--accent',
|
|
32
|
+
logo_url: '--logo-url',
|
|
33
|
+
mark_url: '--mark-url',
|
|
34
|
+
font_stack: '--font-stack',
|
|
35
|
+
};
|
|
36
|
+
function applyBrandingToRoot(branding) {
|
|
37
|
+
if (typeof document === 'undefined')
|
|
38
|
+
return;
|
|
39
|
+
const root = document.documentElement;
|
|
40
|
+
if (!branding) {
|
|
41
|
+
// Remove any previously applied overrides
|
|
42
|
+
Object.values(BRANDING_CSS_MAP).forEach((cssVar) => {
|
|
43
|
+
if (cssVar)
|
|
44
|
+
root.style.removeProperty(cssVar);
|
|
45
|
+
});
|
|
46
|
+
// Also remove aliased vars
|
|
47
|
+
root.style.removeProperty('--accent-primary');
|
|
48
|
+
root.style.removeProperty('--app-primary');
|
|
49
|
+
root.style.removeProperty('--app-accent');
|
|
50
|
+
root.removeAttribute('data-branding-loaded');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (branding.primary) {
|
|
54
|
+
root.style.setProperty('--primary', branding.primary);
|
|
55
|
+
root.style.setProperty('--accent-primary', branding.primary);
|
|
56
|
+
root.style.setProperty('--app-primary', branding.primary);
|
|
57
|
+
root.style.setProperty('--ring', branding.primary);
|
|
58
|
+
}
|
|
59
|
+
if (branding.accent) {
|
|
60
|
+
root.style.setProperty('--accent', branding.accent);
|
|
61
|
+
root.style.setProperty('--accent-secondary', branding.accent);
|
|
62
|
+
root.style.setProperty('--app-accent', branding.accent);
|
|
63
|
+
}
|
|
64
|
+
if (branding.logo_url) {
|
|
65
|
+
root.style.setProperty('--logo-url', `url(${branding.logo_url})`);
|
|
66
|
+
}
|
|
67
|
+
if (branding.mark_url) {
|
|
68
|
+
root.style.setProperty('--mark-url', `url(${branding.mark_url})`);
|
|
69
|
+
}
|
|
70
|
+
if (branding.font_stack) {
|
|
71
|
+
root.style.setProperty('--font-stack', branding.font_stack);
|
|
72
|
+
}
|
|
73
|
+
root.setAttribute('data-branding-loaded', 'true');
|
|
74
|
+
}
|
|
75
|
+
function persistBranding(branding) {
|
|
76
|
+
try {
|
|
77
|
+
if (branding) {
|
|
78
|
+
localStorage.setItem(BRANDING_STORAGE_KEY, JSON.stringify(branding));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
localStorage.removeItem(BRANDING_STORAGE_KEY);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// localStorage not available — silently ignore
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function loadPersistedBranding() {
|
|
89
|
+
try {
|
|
90
|
+
const raw = localStorage.getItem(BRANDING_STORAGE_KEY);
|
|
91
|
+
if (!raw)
|
|
92
|
+
return null;
|
|
93
|
+
return JSON.parse(raw);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function isBrandingEnabled() {
|
|
100
|
+
try {
|
|
101
|
+
// Vite env var — only present in client bundles that set it
|
|
102
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
103
|
+
const flag = import.meta.env;
|
|
104
|
+
const value = flag?.VITE_ENABLE_BRANDING_OVERRIDE ?? flag?.['VITE_ENABLE_BRANDING_OVERRIDE'];
|
|
105
|
+
if (value === 'false' || value === '0')
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Not in a Vite context — default to enabled
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
export function BrandingProvider({ children, supabaseClient, applyPersistedOnMount = true, }) {
|
|
114
|
+
const [branding, setBranding] = useState(null);
|
|
115
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
116
|
+
const [error, setError] = useState(null);
|
|
117
|
+
const channelRef = useRef(null);
|
|
118
|
+
const fetchBranding = useCallback(async () => {
|
|
119
|
+
if (!isBrandingEnabled()) {
|
|
120
|
+
setIsLoading(false);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
setIsLoading(true);
|
|
124
|
+
setError(null);
|
|
125
|
+
try {
|
|
126
|
+
const { data, error: rpcError } = await supabaseClient.rpc('branding_json_for_tenant');
|
|
127
|
+
if (rpcError) {
|
|
128
|
+
throw new Error(rpcError.message);
|
|
129
|
+
}
|
|
130
|
+
const resolved = data ?? null;
|
|
131
|
+
setBranding(resolved);
|
|
132
|
+
applyBrandingToRoot(resolved);
|
|
133
|
+
persistBranding(resolved);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
137
|
+
// On error keep any persisted branding applied (already done at mount)
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
setIsLoading(false);
|
|
141
|
+
}
|
|
142
|
+
}, [supabaseClient]);
|
|
143
|
+
// Apply persisted branding immediately to prevent FOUC
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (applyPersistedOnMount && isBrandingEnabled()) {
|
|
146
|
+
const persisted = loadPersistedBranding();
|
|
147
|
+
if (persisted) {
|
|
148
|
+
applyBrandingToRoot(persisted);
|
|
149
|
+
setBranding(persisted);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}, [applyPersistedOnMount]);
|
|
153
|
+
// Fetch fresh branding on mount
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
void fetchBranding();
|
|
156
|
+
}, [fetchBranding]);
|
|
157
|
+
// Subscribe to Realtime tenant row changes so branding propagates without reload
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (!isBrandingEnabled())
|
|
160
|
+
return;
|
|
161
|
+
const channel = supabaseClient
|
|
162
|
+
.channel('tenant-branding-updates')
|
|
163
|
+
.on('postgres_changes', {
|
|
164
|
+
event: 'UPDATE',
|
|
165
|
+
schema: 'public',
|
|
166
|
+
table: 'tenants',
|
|
167
|
+
// filter is applied server-side via RLS — only own tenant rows are received
|
|
168
|
+
}, (_payload) => {
|
|
169
|
+
// Re-fetch to get the full branding_json_for_tenant RPC result
|
|
170
|
+
void fetchBranding();
|
|
171
|
+
})
|
|
172
|
+
.subscribe();
|
|
173
|
+
channelRef.current = channel;
|
|
174
|
+
return () => {
|
|
175
|
+
if (channelRef.current) {
|
|
176
|
+
void supabaseClient.removeChannel(channelRef.current);
|
|
177
|
+
channelRef.current = null;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}, [supabaseClient, fetchBranding]);
|
|
181
|
+
const value = useMemo(() => ({ branding, isLoading, error, refresh: fetchBranding }), [branding, isLoading, error, fetchBranding]);
|
|
182
|
+
return _jsx(BrandingContext.Provider, { value: value, children: children });
|
|
183
|
+
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -3,4 +3,6 @@ export type { ThemeProviderProps } from './ThemeProvider';
|
|
|
3
3
|
export { useTheme } from './useTheme';
|
|
4
4
|
export type { ThemeContextValue, ThemeMode, ResolvedTheme } from '../index';
|
|
5
5
|
export { THEME_STORAGE_KEY } from '../index';
|
|
6
|
+
export { BrandingProvider, BrandingContext, BRANDING_STORAGE_KEY, BRANDING_OVERRIDE_FLAG } from './BrandingProvider';
|
|
7
|
+
export type { TenantBranding, BrandingContextValue } from './BrandingProvider';
|
|
6
8
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAC/C,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AACzD,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AACrC,YAAY,EAAE,iBAAiB,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAC3E,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAC/C,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AACzD,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AACrC,YAAY,EAAE,iBAAiB,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAC3E,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAA;AAC5C,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,oBAAoB,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAA;AACpH,YAAY,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAA"}
|
package/dist/react/index.js
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access the current tenant branding values and loading state.
|
|
3
|
+
*
|
|
4
|
+
* Must be used inside a <BrandingProvider>.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const { branding, isLoading } = useBranding()
|
|
8
|
+
* const logoUrl = branding?.logo_url ?? '/logos/default-logo.svg'
|
|
9
|
+
*/
|
|
10
|
+
export declare function useBranding(): import("./BrandingProvider").BrandingContextValue;
|
|
11
|
+
//# sourceMappingURL=useBranding.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useBranding.d.ts","sourceRoot":"","sources":["../../src/react/useBranding.ts"],"names":[],"mappings":"AAGA;;;;;;;;GAQG;AACH,wBAAgB,WAAW,sDAM1B"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
import { BrandingContext } from './BrandingProvider';
|
|
3
|
+
/**
|
|
4
|
+
* Access the current tenant branding values and loading state.
|
|
5
|
+
*
|
|
6
|
+
* Must be used inside a <BrandingProvider>.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const { branding, isLoading } = useBranding()
|
|
10
|
+
* const logoUrl = branding?.logo_url ?? '/logos/default-logo.svg'
|
|
11
|
+
*/
|
|
12
|
+
export function useBranding() {
|
|
13
|
+
const ctx = useContext(BrandingContext);
|
|
14
|
+
if (!ctx) {
|
|
15
|
+
throw new Error('useBranding must be used within a <BrandingProvider>');
|
|
16
|
+
}
|
|
17
|
+
return ctx;
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bsuite/theme",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"publishConfig": {
|
|
@@ -46,18 +46,24 @@
|
|
|
46
46
|
"prepublishOnly": "pnpm build"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
+
"@supabase/supabase-js": ">=2.0.0",
|
|
49
50
|
"react": ">=18.0.0",
|
|
50
51
|
"react-dom": ">=18.0.0",
|
|
51
52
|
"tailwindcss": ">=3.4.0"
|
|
52
53
|
},
|
|
53
54
|
"peerDependenciesMeta": {
|
|
55
|
+
"@supabase/supabase-js": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
54
58
|
"tailwindcss": {
|
|
55
59
|
"optional": true
|
|
56
60
|
}
|
|
57
61
|
},
|
|
58
62
|
"devDependencies": {
|
|
63
|
+
"@supabase/supabase-js": "^2.49.4",
|
|
59
64
|
"@testing-library/jest-dom": "^6.9.1",
|
|
60
65
|
"@testing-library/react": "^16.3.2",
|
|
66
|
+
"@types/node": "^20.0.0",
|
|
61
67
|
"@types/react": "^18.3.28",
|
|
62
68
|
"@vitejs/plugin-react": "^5.2.0",
|
|
63
69
|
"jsdom": "^28.1.0",
|