"use client" /** * Which shape the products home takes. * * Two audiences land on the same route. Someone evaluating Exxat wants the * shelves; someone who owns two products and will never buy a third wants a * door and nothing else. Rather than average the two into a page that serves * neither, `/home` renders one of seven bodies and the person picks. * * Storefront and Focus are both shelves, differing only in how much catalog * they show. Spotlight is the third answer: one live mosaic that says what is * waiting before it says what exists. See `spotlight-home.tsx` for why. * * Launcher is the fourth: an icon-first grid of every app you own (Okta's * dashboard, the Salesforce App Launcher, the Microsoft 365 waffle) plus the * rest of the catalog shown inline rather than behind a disclosure — this is * the one variant that treats cross-sell as a real job of the page instead * of an afterthought. No activity feed; that stays Spotlight's job. * * Campus is the fifth: Spotlight's mosaic with school and program chosen once * for the workspace (beside Your App, not under each row). People, Courses, * and Personnel sit in Your App. More from Exxat is the sticky rail. * * Campus v3 is the ninth body: Campus tile grid with More from Exxat folded * into Your App (taglines on catalog rows). * * Catalog is the sixth: Campus scope chrome with one Apps grid. Active tags * and Open mark entitled doors; Available tags and Learn more mark the rest. * * Spotlight v2 is the seventh body: v1's mosaic with workspace scope in the * greeting band and Directory / More from Exxat interchanged. Pick v1 or v2 * from the Spotlight submenu in the profile menu. * * Set from the profile menu (which navigates to `/home/`), or by * opening that URL directly. Preference is still persisted so `/home` can * redirect to the last layout you used. * * Spotlight v3 is the eighth body: v2's scope greeting with More from Exxat * merged into Your App (Exam Management and Surveys stay inactive rows). * * Spotlight v4 is the tenth body: v3's grid with Modules (sellable add-ons) on * the left and Platform (Prism ecosystem: record hubs and included apps) on the * right. * * Shell-global key on purpose. Home is a shell route, not a product one, so * `productPersistKey` would be inventing a distinction the page does not have * (`exxat-persisted-state.mdc`). */ import * as React from "react" import { getStorageItem, setStorageItem, subscribeToStorageKey, } from "@exxatdesignux/ui/lib/persisted-state" export type HomeVariant = | "storefront" | "storefront-v2" | "focus" | "spotlight" | "spotlight-v2" | "spotlight-v3" | "spotlight-v4" | "launcher" | "campus" | "campus-v3" | "catalog" /** Storefront layouts share one submenu in the profile menu. */ export const STOREFRONT_VARIANTS: { id: "storefront" | "storefront-v2" label: string description: string }[] = [ { id: "storefront", label: "Storefront", description: "Scope picker on each app card", }, { id: "storefront-v2", label: "Storefront v2", description: "Scope in the greeting header", }, ] /** Spotlight layouts share one submenu in the profile menu. */ export const SPOTLIGHT_VARIANTS: { id: "spotlight" | "spotlight-v2" | "spotlight-v3" | "spotlight-v4" label: string description: string }[] = [ { id: "spotlight", label: "Spotlight", description: "Your day on the left, directory on the right", }, { id: "spotlight-v2", label: "Spotlight v2", description: "Scope in the greeting band, catalog on the right", }, { id: "spotlight-v3", label: "Spotlight v3", description: "Scope in the greeting band, catalog folded into Your App", }, { id: "spotlight-v4", label: "Spotlight v4", description: "Modules on the left, Prism platform on the right", }, ] /** Campus layouts share one submenu in the profile menu. */ export const CAMPUS_VARIANTS: { id: "campus" | "campus-v3" label: string description: string }[] = [ { id: "campus", label: "Campus", description: "Your App grid and More from Exxat as a shelf", }, { id: "campus-v3", label: "Campus v3", description: "Your App grid with catalog folded in and taglines", }, ] export const HOME_VARIANTS: { id: HomeVariant; label: string; description: string }[] = [ { id: "storefront", label: "Storefront", description: "Your apps, plus everything else Exxat makes", }, { id: "focus", label: "Focus", description: "Just your apps. Everything else is one click away", }, { id: "spotlight", label: "Spotlight", description: "Type where you are going. Your day on one side, what is new on the other", }, { id: "launcher", label: "Launcher", description: "Every app as an icon grid, plus what else Exxat makes", }, { id: "campus", label: "Campus", description: "One school and program for every app", }, { id: "catalog", label: "Catalog", description: "Every Exxat app in one list, tagged Active or Available", }, ] const STORAGE_KEY = "home-variant" const VERSION = 1 const DEFAULT_VARIANT: HomeVariant = "storefront" /** Canonical URL for a home layout — each approach is its own link. */ export function homeVariantPath(variant: HomeVariant): string { return `/home/${variant}` } export function isHomeVariant(value: unknown): value is HomeVariant { return ( value === "storefront" || value === "storefront-v2" || value === "focus" || value === "spotlight" || value === "spotlight-v2" || value === "spotlight-v3" || value === "spotlight-v4" || value === "launcher" || value === "campus" || value === "campus-v3" || value === "catalog" ) } /** * Variant encoded in a `/home/:segment` path, or `null` when the segment is a * product marketing slug (or missing). */ export function homeVariantFromPathname(pathname: string): HomeVariant | null { const match = pathname.match(/^\/home\/([^/]+)\/?$/) if (!match) return null return isHomeVariant(match[1]) ? match[1] : null } const listeners = new Set<() => void>() let snapshot: HomeVariant | null = null let hydrated = false function subscribe(onChange: () => void) { listeners.add(onChange) // The set covers this document; the storage event covers the other tabs, // which is the half `usePersistedState` would have given us for free. const unsubscribeStorage = subscribeToStorageKey(STORAGE_KEY, () => { hydrated = false onChange() }) return () => { listeners.delete(onChange) unsubscribeStorage() } } function read(): HomeVariant { if (hydrated && snapshot) return snapshot hydrated = true const raw = getStorageItem(STORAGE_KEY) if (raw) { try { const parsed: unknown = JSON.parse(raw) if ( typeof parsed === "object" && parsed !== null && (parsed as { v?: number }).v === VERSION && isHomeVariant((parsed as { d?: unknown }).d) ) { snapshot = (parsed as { d: HomeVariant }).d return snapshot } } catch { // Unparseable payload — fall through to the default. } } snapshot = DEFAULT_VARIANT return snapshot } export function setHomeVariant(next: HomeVariant) { snapshot = next hydrated = true setStorageItem(STORAGE_KEY, JSON.stringify({ v: VERSION, d: next })) for (const listener of listeners) listener() } export function useHomeVariant(): HomeVariant { return React.useSyncExternalStore(subscribe, read, () => DEFAULT_VARIANT) }