"use client"; import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; import { API_URL, api, apiFetch, apiUploadFile, ApiUnreachableError, errorMessage } from "./api-client"; import { useAuth } from "./auth-store"; /** * Per-collective branding on the client — GET /workspace/branding at shell * boot (any active member), PUT + multipart logo upload from Settings * (workspace.members.manage). The constants mirror * apps/api/src/settings/branding.service.ts (migration 036) — the four keys, * the 160-char tagline cap, #RGB/#RRGGBB accent, 4 MB image cap — so the form * refuses locally what the API would reject anyway. */ export interface BrandingPatch { logoAssetKey?: string | null; tagline?: string | null; backgroundKey?: string | null; accent?: string | null; } /** What the API stores — keys and text only, no URLs. */ export interface BrandingRecord { logoAssetKey: string | null; tagline: string | null; backgroundKey: string | null; accent: string | null; } /** GET /workspace/branding — the shape the shell boots from. */ export interface WorkspaceBranding extends BrandingRecord { workspace: { id: string; name: string }; /** API-relative, scoped read path; run it through brandingAssetUrl(). */ logoUrl: string | null; backgroundUrl: string | null; } /** POST /workspace/branding/logo — multipart upload result. */ export interface BrandingUploadResult { key: string; contentType: string; sizeBytes: number; /** The ready-to-send body for PUT /workspace/branding. */ patch: { logoAssetKey: string }; assetUrl: string; } export const MAX_TAGLINE_LENGTH = 160; export const MAX_BRANDING_IMAGE_BYTES = 4 * 1024 * 1024; export const BRANDING_IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const; export type BrandingImageType = (typeof BRANDING_IMAGE_TYPES)[number]; /** Same rule the API enforces on write: #RGB or #RRGGBB. */ const ACCENT_HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; export function isHexAccent(value: string): boolean { return ACCENT_HEX.test(value.trim()); } /** The API returns API-relative asset paths; needs the absolute URL. */ export function brandingAssetUrl(path: string): string { return path.startsWith("http") ? path : `${API_URL}${path.startsWith("/") ? path : `/${path}`}`; } export function fetchWorkspaceBranding(signal?: AbortSignal): Promise { return api.get("/workspace/branding", undefined, signal); } /** PUT /workspace/branding — send every field you manage; null clears it. */ export function saveBranding(patch: BrandingPatch): Promise { return apiFetch("/workspace/branding", { method: "PUT", body: patch }); } export function uploadBrandingLogo( file: File, onProgress: (percent: number) => void = () => undefined, ): Promise { return apiUploadFile("/workspace/branding/logo", file, onProgress); } export type BrandingSource = "loading" | "live" | "unavailable"; export interface BrandingContextValue { workspaceId: string | null; workspaceName: string | null; logoAssetKey: string | null; logoUrl: string | null; tagline: string | null; accent: string | null; source: BrandingSource; error: string | null; /** Re-run the boot read (e.g. after Settings saves a patch). */ reload: () => void; } const BrandingContext = createContext(null); const EMPTY: BrandingContextValue = { workspaceId: null, workspaceName: null, logoAssetKey: null, logoUrl: null, tagline: null, accent: null, source: "loading", error: null, reload: () => undefined, }; /** * One branding read per session/workspace, shared by the shell and Settings. * A failure never throws into the tree — the consumers fall back to the Hub * defaults (stock mark, no tagline), exactly like the API's tolerant decode. */ export function BrandingProvider({ children }: { children: ReactNode }): React.ReactElement { const { status, session } = useAuth(); const workspaceId = session?.workspace.id ?? null; const [record, setRecord] = useState(null); const [source, setSource] = useState("loading"); const [error, setError] = useState(null); const [nonce, setNonce] = useState(0); useEffect(() => { if (status === "loading") return; if (status !== "authenticated" || workspaceId === null) { setRecord(null); setSource("unavailable"); return; } const controller = new AbortController(); fetchWorkspaceBranding(controller.signal) .then((live) => { setRecord(live); setSource("live"); setError(null); }) .catch((err: unknown) => { if (controller.signal.aborted) return; setError(err instanceof ApiUnreachableError ? null : errorMessage(err)); setRecord(null); setSource("unavailable"); }); return () => controller.abort(); }, [status, workspaceId, nonce]); const reload = useCallback(() => setNonce((n) => n + 1), []); const value = useMemo( () => record === null ? { ...EMPTY, source, error, reload } : { workspaceId: record.workspace.id, workspaceName: record.workspace.name, logoAssetKey: record.logoAssetKey, logoUrl: record.logoUrl === null ? null : brandingAssetUrl(record.logoUrl), tagline: record.tagline, accent: record.accent, source, error, reload, }, [record, source, error, reload], ); return createElement(BrandingContext.Provider, { value }, children); } export function useBranding(): BrandingContextValue { const ctx = useContext(BrandingContext); if (ctx === null) throw new Error("useBranding must be used inside "); return ctx; }