"use client" // The organization's brand settings: a name, two colours and two logos, with a preview of both themes // that updates as a colour is typed. Saving is two kinds of call -- `branding.brand.set` for the text // fields and `branding.logo.set` for each file chosen -- both administrators' acts, both audited by // the kernel. A member who is not an administrator sees the same page, read-only. // // A save is therefore up to five calls, and a failure in the middle leaves the earlier ones applied: // the calls are one transaction each, never one transaction together. An atomic // `branding.brand.update` (fields, clears and uploads in one procedure) is the known improvement and // needs a contract addition; until then a failed save is retried as a whole, which is safe because // every call is idempotent for the same input. import { isHexColor } from "@conusai-kit/brand" import { type FormEvent, type ReactElement, useId, useState } from "react" import type { BrandView, LogoVariant } from "../contract/index.js" import { logoUrl, tenantLogo } from "../contract/logo.js" import { BrandPreview, type PreviewLabels } from "./brand-preview.js" import { useTransport } from "./transport.js" export interface BrandFormLabels { readonly displayName: string readonly primary: string readonly accent: string readonly colourHint: string readonly logoLight: string readonly logoDark: string readonly logoHint: string readonly currentLogo: string /** The heading of the list that says which logo each theme shows. */ readonly logoShows: string readonly logoOwn: string /** Contains `{other}`. */ readonly logoOther: string readonly logoApp: string /** Contains `{theme}`. */ readonly clearLogo: string readonly themeLight: string readonly themeDark: string readonly save: string readonly saved: string readonly readOnly: string /** The app's `app.failed` message, with `{detail}` still in it. */ readonly failed: string readonly preview: PreviewLabels } const emptyToNull = (value: string): string | null => (value.trim().length === 0 ? null : value.trim()) function base64Of(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onerror = () => reject(reader.error ?? new Error("the file could not be read")) reader.onload = () => { const url = String(reader.result) resolve(url.slice(url.indexOf(",") + 1)) } reader.readAsDataURL(file) }) } export function BrandForm({ org, brand, canEdit, defaultPrimary, labels, }: { org: string brand: BrandView canEdit: boolean defaultPrimary: string labels: BrandFormLabels }): ReactElement { const { call, describe } = useTransport() const id = useId() const [primary, setPrimary] = useState(brand.primary ?? "") const [accent, setAccent] = useState(brand.accent ?? "") const [state, setState] = useState<{ kind: "idle" | "busy" | "saved" } | { kind: "failed"; detail: string }>({ kind: "idle", }) const [cleared, setCleared] = useState([]) // The logos as they will be once saved: a cleared one is gone, so the fallback it leaves is what the list says. const shown = { logoLight: cleared.includes("light") ? null : brand.logoLight, logoDark: cleared.includes("dark") ? null : brand.logoDark, } const valid = (value: string): boolean => value.trim() === "" || isHexColor(value.trim()) const previewPrimary = isHexColor(primary.trim()) ? primary.trim() : brand.primary const previewAccent = isHexColor(accent.trim()) ? accent.trim() : brand.accent const save = async (event: FormEvent): Promise => { event.preventDefault() const form = new FormData(event.currentTarget) setState({ kind: "busy" }) try { await call("branding.brand.set", { org, displayName: emptyToNull(String(form.get("displayName") ?? "")), primary: emptyToNull(primary), accent: emptyToNull(accent), }) for (const variant of cleared) await call("branding.logo.clear", { org, variant }) for (const variant of ["light", "dark"] as const) { const file = form.get(`logo-${variant}`) if (file instanceof File && file.size > 0) { await call("branding.logo.set", { org, variant, contentType: file.type, body: await base64Of(file) }) } } setState({ kind: "saved" }) window.location.reload() } catch (error) { setState({ kind: "failed", detail: describe(error) }) } } const colourField = (name: string, label: string, value: string, set: (value: string) => void) => (
set(event.target.value)} disabled={!canEdit} maxLength={7} spellCheck={false} autoComplete="off" aria-invalid={!valid(value)} aria-describedby={`${id}-colour-hint`} />
) return (
{canEdit ? null :

{labels.readOnly}

}
{colourField("primary", labels.primary, primary, setPrimary)} {colourField("accent", labels.accent, accent, setAccent)}

{labels.colourHint}

{(["light", "dark"] as const).map((variant) => { const current = variant === "light" ? brand.logoLight : brand.logoDark const theme = variant === "light" ? labels.themeLight : labels.themeDark return (
{current && !cleared.includes(variant) ? ( <> {labels.currentLogo} {canEdit ? ( ) : null} ) : null}
) })}

{labels.logoHint}

{labels.logoShows}

    {(["light", "dark"] as const).map((theme) => { const choice = tenantLogo(shown, theme) const state = choice === null ? labels.logoApp : choice.variant === theme ? labels.logoOwn : labels.logoOther.replace( "{other}", choice.variant === "light" ? labels.themeLight : labels.themeDark, ) return (
  • {theme === "light" ? labels.preview.light : labels.preview.dark}: {state}
  • ) })}
{canEdit ? ( ) : null}

{state.kind === "saved" ? labels.saved : ""}

{state.kind === "failed" ? (

{labels.failed.replace("{detail}", state.detail)}

) : null} ) }