"use client" /** * ProductProvider — rehydrates the Zustand app store from localStorage after * mount, then keeps the theme class in sync with the active product. * * Lifts from the apps/web implementation into the published package so every * customer app gets the same shell behaviour. Uses `react-router`'s * `useLocation` for re-render triggers on navigation — `react-router` * is a required peer dependency on @exxatdesignux/ui (the router context * must be a singleton, so it is a peer rather than a bundled dependency). * * `useProduct()` is a thin convenience hook over `useAppStore` so all * existing call-sites continue to work without any changes. */ import * as React from "react" import { useLocation } from "react-router" import { useAppStore, getActiveCustomProductBrand, hasConfiguredCustomProducts, isListedCustomProduct, type CustomProductBrand, type Product, } from "@exxatdesignux/product-framework/app-store" import { brandForProduct, chromeThemeForProduct, } from "@exxatdesignux/product-framework/product-brand" import { brandColorsEquivalent } from "@exxatdesignux/product-framework/brand-color-match" import { normalizeBrandAccentColor } from "@exxatdesignux/product-framework/brand-accent-color" import { resolveProductFromPath } from "@exxatdesignux/product-framework/product-routing" import { listRegisteredProducts } from "@exxatdesignux/product-framework/product-registry" import { ensureShippedCatalogLoaded } from "@exxatdesignux/product-framework/tenant-products/shipped-catalog" import { hydrateTenantProducts } from "@exxatdesignux/product-framework/tenant-products/hydrate" import { ProductAuthoringProvider } from "./product-authoring-context" /** All product theme classes that may appear on `` — aliases included. */ const PRODUCT_THEME_CLASSES = [ "theme-one", "theme-lavender", "theme-prism", "theme-rose", "theme-custom", ] as const function accentOverrideActive(product: Product, override: string | undefined): boolean { if (!override?.trim()) return false const defaultAccent = brandForProduct(product).brandColor.trim() if (!defaultAccent) return true return !brandColorsEquivalent(override, defaultAccent) } function resolveProductThemeClass( product: Product, productBrandColors: ReturnType["productBrandColors"], customBrand?: CustomProductBrand | null, ): "theme-one" | "theme-prism" | "theme-custom" { const hasAccentOverride = accentOverrideActive(product, productBrandColors[product]) const family = hasAccentOverride ? "custom" : chromeThemeForProduct(product, customBrand) return `theme-${family}` } function resolveThemeContext( product: Product, customProducts: CustomProductBrand[], activeCustomIndex: number, customProductBrand: CustomProductBrand | null, pathname: string, ): { product: Product; customBrand: CustomProductBrand | null } { const fromUrl = resolveProductFromPath(pathname, customProducts) if ( fromUrl?.product === "exxat-custom" && fromUrl.customIndex !== undefined ) { const brand = customProducts[fromUrl.customIndex] if (isListedCustomProduct(brand)) { return { product: "exxat-custom", customBrand: brand } } } if (product === "exxat-custom" && isListedCustomProduct(customProductBrand)) { return { product, customBrand: customProductBrand } } if (product === "exxat-custom" && customProducts.length > 0) { const idx = Math.max(0, Math.min(activeCustomIndex, customProducts.length - 1)) const brand = customProducts[idx] if (isListedCustomProduct(brand)) { return { product, customBrand: brand } } } return { product, customBrand: customProductBrand } } function syncProductThemeToDocument( product: Product, customProducts: CustomProductBrand[], activeCustomIndex: number, customProductBrand: CustomProductBrand | null, productBrandColors: ReturnType["productBrandColors"], pathname: string, ) { const html = document.documentElement for (const themeClass of PRODUCT_THEME_CLASSES) { html.classList.remove(themeClass) } const { product: effectiveProduct, customBrand } = resolveThemeContext( product, customProducts, activeCustomIndex, customProductBrand, pathname, ) const themeClass = resolveProductThemeClass( effectiveProduct, productBrandColors, customBrand, ) const effectiveBrandColor = effectiveProduct === "exxat-custom" && customBrand?.brandColor?.trim() ? normalizeBrandAccentColor(customBrand.brandColor) : brandForProduct(effectiveProduct, customBrand, productBrandColors).brandColor html.style.setProperty("--custom-product-brand-color", effectiveBrandColor) html.classList.add(themeClass) } function expectedThemeSnapshot(pathname: string) { const state = useAppStore.getState() const customBrand = getActiveCustomProductBrand(state) const { product: effectiveProduct, customBrand: resolvedBrand } = resolveThemeContext( state.product, state.customProducts, state.activeCustomIndex, customBrand, pathname, ) const expectedTheme = resolveProductThemeClass( effectiveProduct, state.productBrandColors, resolvedBrand, ) const expectedBrandColor = effectiveProduct === "exxat-custom" && resolvedBrand?.brandColor?.trim() ? normalizeBrandAccentColor(resolvedBrand.brandColor) : brandForProduct(effectiveProduct, resolvedBrand, state.productBrandColors).brandColor return { expectedTheme, expectedBrandColor } } function themeDocumentNeedsResync(pathname: string): boolean { const { expectedTheme, expectedBrandColor } = expectedThemeSnapshot(pathname) const html = document.documentElement if (!html.classList.contains(expectedTheme)) return true for (const themeClass of PRODUCT_THEME_CLASSES) { if (themeClass !== expectedTheme && html.classList.contains(themeClass)) return true } if (expectedTheme === "theme-custom") { const current = html.style.getPropertyValue("--custom-product-brand-color").trim() if (!current || !brandColorsEquivalent(current, expectedBrandColor)) return true } return false } /** Re-read the Zustand store and push product chrome onto ``. */ export function syncActiveProductThemeFromStore(pathname?: string) { const state = useAppStore.getState() syncProductThemeToDocument( state.product, state.customProducts, state.activeCustomIndex, getActiveCustomProductBrand(state), state.productBrandColors, pathname ?? (typeof window !== "undefined" ? window.location.pathname : "/"), ) } export type { Product } export function useProduct() { const product = useAppStore(s => s.product) const setProduct = useAppStore(s => s.setProduct) const customProducts = useAppStore(s => s.customProducts) const activeCustomIndex = useAppStore(s => s.activeCustomIndex) const customProductBrand = useAppStore(s => getActiveCustomProductBrand(s)) const addCustomProduct = useAppStore(s => s.addCustomProduct) const updateCustomProduct = useAppStore(s => s.updateCustomProduct) const removeCustomProduct = useAppStore(s => s.removeCustomProduct) const setActiveCustomIndex = useAppStore(s => s.setActiveCustomIndex) const setCustomProducts = useAppStore(s => s.setCustomProducts) const productBrandColors = useAppStore(s => s.productBrandColors) const setProductBrandColor = useAppStore(s => s.setProductBrandColor) const hiddenProducts = useAppStore(s => s.hiddenProducts) const hideProduct = useAppStore(s => s.hideProduct) const showProduct = useAppStore(s => s.showProduct) const startupProduct = useAppStore(s => s.startupProduct) const setStartupProduct = useAppStore(s => s.setStartupProduct) return { product, setProduct, customProducts, activeCustomIndex, customProductBrand, addCustomProduct, updateCustomProduct, removeCustomProduct, setActiveCustomIndex, setCustomProducts, productBrandColors, setProductBrandColor, hiddenProducts, hideProduct, showProduct, startupProduct, setStartupProduct, } } export type ProductProviderProps = { children: React.ReactNode /** * **Builder vs end user** in production deploys. * Wire from auth, e.g. `authoring={session.roles.includes("workspace_builder")}`. * Omit to use dev / env defaults only. */ authoring?: boolean } export function ProductProvider({ children, authoring }: ProductProviderProps) { const product = useAppStore(s => s.product) const customProducts = useAppStore(s => s.customProducts) const activeCustomIndex = useAppStore(s => s.activeCustomIndex) const customProductBrand = useAppStore(s => getActiveCustomProductBrand(s)) const productBrandColors = useAppStore(s => s.productBrandColors) const { pathname } = useLocation() // Rehydrate from localStorage once, then mirror tenant-store catalog into // `customProducts`. Child shells (`usePersistHydrated`, route gates) may // call `rehydrate()` before this effect runs — if hydration already finished, // we must sync immediately or Settings products vanish on refresh. React.useEffect(() => { function syncTenantCatalogIntoAppStore() { const state = useAppStore.getState() const cleaned = state.customProducts.filter(isListedCustomProduct) const { customProducts: catalogMirror } = hydrateTenantProducts({ customProducts: cleaned, codeProducts: [...listRegisteredProducts()], }) const safeMirror = catalogMirror if (safeMirror.length !== state.customProducts.length || safeMirror.some((cp, i) => cp.suffix !== state.customProducts[i]?.suffix)) { state.setCustomProducts(safeMirror) } if (state.activeCustomIndex >= safeMirror.length) { state.setActiveCustomIndex(Math.max(0, safeMirror.length - 1)) } if (state.product === "exxat-custom" && !hasConfiguredCustomProducts(safeMirror)) { state.setProduct("exxat-prism") } syncActiveProductThemeFromStore(window.location.pathname) } function runBootstrap() { void ensureShippedCatalogLoaded().finally(syncTenantCatalogIntoAppStore) } const unsub = useAppStore.persist.onFinishHydration(runBootstrap) if (!useAppStore.persist.hasHydrated()) { void useAppStore.persist.rehydrate() } else { runBootstrap() } return unsub }, []) // Sync theme class to whenever product (or its accent override) changes. // Color scheme toggles `.dark` on `` — keep a MutationObserver so // `theme-prism` / `theme-one` are repaired if a later class mutation strips // or replaces the product theme. React.useLayoutEffect(() => { if (product === "exxat-custom" && !hasConfiguredCustomProducts(customProducts)) { useAppStore.getState().setProduct("exxat-prism") } const apply = () => { syncActiveProductThemeFromStore(pathname) useAppStore.getState().setProductSwitching(false) } apply() const raf = requestAnimationFrame(apply) const observer = new MutationObserver(() => { if (themeDocumentNeedsResync(pathname)) { apply() } }) observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"], }) const unsubStore = useAppStore.subscribe((state, prev) => { if ( state.product !== prev.product || state.activeCustomIndex !== prev.activeCustomIndex || state.customProducts !== prev.customProducts || state.productBrandColors !== prev.productBrandColors ) { apply() } }) return () => { cancelAnimationFrame(raf) observer.disconnect() unsubStore() } }, [ activeCustomIndex, customProductBrand, customProducts, pathname, product, productBrandColors, ]) return ( {children} ) }