"use client" import * as React from "react" import { useNavigate } from "react-router-dom" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Kbd, KbdGroup } from "@/components/ui/kbd" import { Shortcut } from "@/components/ui/dropdown-menu" import { SidebarInset } from "@/components/ui/sidebar" import { SiteHeader } from "@/components/site-header" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { cn } from "@/lib/utils" import { BrandColorPicker } from "@/components/brand-color-picker" import { ExxatProductMark, ExxatProductWordmarkEditor, } from "@/components/exxat-product-logo" import { useAppStore, productSlug, type Product } from "@/stores/app-store" import { customProductSlugFromSuffix, validateCustomProductSuffix, } from "@/lib/product-routing" import { brandAccentOklchFromHue, normalizeBrandAccentColor, } from "@/lib/brand-accent-color" import { setTenantProductData, syncCustomProductsMirror, updateTenantProductNav, type SerializableNavLink, } from "@exxatdesignux/product-framework" import { setStorageItem } from "@exxatdesignux/ui/lib/persisted-state" const ONBOARDING_COMPLETE_KEY = "builder:onboarding-complete:v1" type ScopeKind = "school-program" | "brand-site-location" type StepId = "identity" | "context" | "scope" | "persona" | "nav" const STEPS: ReadonlyArray<{ id: StepId; title: string; subtitle?: string }> = [ { id: "identity", title: "Name your product.", }, { id: "context", title: "What scope does this product live in?", }, { id: "scope", title: "Tell us a bit about your scope.", }, { id: "persona", title: "Who is this product for?", }, { id: "nav", title: "Build your primary nav.", }, ] as const interface PersonaOption { id: string label: string caption: string icon: string } const PERSONAS_BY_SCOPE: Record> = { "school-program": [ { id: "program-coordinator", label: "Program coordinator", caption: "DCE / placement coordinator running compliance and rotations.", icon: "fa-user-tie", }, { id: "faculty", label: "Faculty", caption: "Reviews student progress and signs off on milestones.", icon: "fa-chalkboard-user", }, { id: "student", label: "Student", caption: "Self-service portal for tasks, schedules, and clearances.", icon: "fa-graduation-cap", }, ], "brand-site-location": [ { id: "site-coordinator", label: "Site coordinator", caption: "Manages slots, preceptors, and inbound placements.", icon: "fa-people-roof", }, { id: "preceptor", label: "Preceptor", caption: "Reviews student work, signs evaluations, hosts rotations.", icon: "fa-user-doctor", }, { id: "site-admin", label: "Site admin", caption: "Owns brand settings, billing, and cross-site reporting.", icon: "fa-building-shield", }, ], } const SCHOOL_OPTIONS: ReadonlyArray = [ "Johns Hopkins University", "Harvard University", "Stanford University", "Mayo Clinic Alix School of Medicine", "University of Pennsylvania", "Duke University", "Vanderbilt University", ] const PROGRAM_OPTIONS: ReadonlyArray = [ "School of Medicine", "School of Nursing", "School of Pharmacy", "Physical Therapy", "Occupational Therapy", "Physician Assistant", "Public Health", ] const BRAND_OPTIONS: ReadonlyArray = [ "Mass General Brigham", "Cleveland Clinic", "Kaiser Permanente", "HCA Healthcare", "Mayo Clinic", "Sutter Health", "Ascension Health", ] const SITE_OPTIONS: ReadonlyArray = [ "MGH Boston", "Brigham & Women’s Hospital", "Cleveland Clinic Main Campus", "Kaiser SF Medical Center", "Mayo Clinic Rochester", "HCA Reston Hospital", ] const LOCATION_OPTIONS: ReadonlyArray = [ "Cardiology Wing", "Emergency Department", "Outpatient Clinic", "Pediatrics Floor", "Oncology Suite", "Maternity Ward", "Operating Room", ] interface NavSuggestion { key: string title: string caption: string segment: string iconClass: string iconActiveClass: string hasSecondary?: boolean } const NAV_SUGGESTIONS: ReadonlyArray = [ { key: "library", title: "Library", caption: "Reusable content, rubrics, and templates.", segment: "library", iconClass: "fa-light fa-books", iconActiveClass: "fa-solid fa-books", hasSecondary: true, }, { key: "placements", title: "Placements", caption: "Sites, rotations, and assignments.", segment: "placements", iconClass: "fa-light fa-location-dot", iconActiveClass: "fa-solid fa-location-dot", }, { key: "team", title: "Team", caption: "People, roles, and collaboration.", segment: "team", iconClass: "fa-light fa-users", iconActiveClass: "fa-solid fa-users", }, { key: "compliance", title: "Compliance", caption: "Clearances, documents, and audit trails.", segment: "compliance", iconClass: "fa-light fa-shield-check", iconActiveClass: "fa-solid fa-shield-check", }, { key: "schedule", title: "Schedule", caption: "Calendars, shifts, and rotations.", segment: "schedule", iconClass: "fa-light fa-calendar", iconActiveClass: "fa-solid fa-calendar", }, { key: "evaluations", title: "Evaluations", caption: "Forms, rubrics, and outcomes.", segment: "evaluations", iconClass: "fa-light fa-clipboard-check", iconActiveClass: "fa-solid fa-clipboard-check", }, { key: "documents", title: "Documents", caption: "Uploads, contracts, and file storage.", segment: "documents", iconClass: "fa-light fa-folder-open", iconActiveClass: "fa-solid fa-folder-open", }, { key: "reports", title: "Reports", caption: "Dashboards, exports, and analytics.", segment: "reports", iconClass: "fa-light fa-chart-line", iconActiveClass: "fa-solid fa-chart-line", }, ] as const interface NavIconCandidate { iconClass: string iconActiveClass: string keywords: ReadonlyArray } /** * Curated Font Awesome Pro library for the nav builder's auto-detect. * Each entry lists a `fa-light` / `fa-solid` pair plus a generous * keyword set the matcher tokenizes against. Matching is multi-stage * (exact word → singular/plural → substring) so titles like "Custom * Reports", "Student Roster", or "Site Visits" land on the right glyph * without the user ever picking an icon. * * Order does not affect correctness — the matcher scores every entry * and picks the highest scorer (with a tiny tiebreaker for shorter * keyword sets, so generic glyphs lose to specific ones). */ const NAV_ICON_LIBRARY: ReadonlyArray = [ // — Top-level / dashboards ———————————————————————————————————————— { iconClass: "fa-light fa-gauge-high", iconActiveClass: "fa-solid fa-gauge-high", keywords: ["dashboard", "dashboards", "overview", "home", "landing", "kpi", "summary", "main", "metrics"] }, { iconClass: "fa-light fa-house", iconActiveClass: "fa-solid fa-house", keywords: ["home", "house", "start"] }, { iconClass: "fa-light fa-compass", iconActiveClass: "fa-solid fa-compass", keywords: ["explore", "discover", "directory", "guide"] }, // — People ———————————————————————————————————————————————————————— { iconClass: "fa-light fa-users", iconActiveClass: "fa-solid fa-users", keywords: ["team", "teams", "users", "people", "members", "staff", "directory", "roster", "group", "groups"] }, { iconClass: "fa-light fa-user", iconActiveClass: "fa-solid fa-user", keywords: ["user", "profile", "account", "contact", "person"] }, { iconClass: "fa-light fa-graduation-cap", iconActiveClass: "fa-solid fa-graduation-cap", keywords: ["student", "students", "learner", "learners", "trainee", "cohort", "cohorts", "alumni"] }, { iconClass: "fa-light fa-chalkboard-user", iconActiveClass: "fa-solid fa-chalkboard-user", keywords: ["faculty", "instructor", "instructors", "teacher", "teachers", "professor", "lecturer", "educator"] }, { iconClass: "fa-light fa-user-doctor", iconActiveClass: "fa-solid fa-user-doctor", keywords: ["preceptor", "preceptors", "doctor", "physician", "clinician", "provider", "practitioner", "mentor"] }, { iconClass: "fa-light fa-user-tie", iconActiveClass: "fa-solid fa-user-tie", keywords: ["coordinator", "coordinators", "manager", "lead", "leads", "owner", "supervisor"] }, { iconClass: "fa-light fa-user-shield", iconActiveClass: "fa-solid fa-user-shield", keywords: ["admin", "administrator", "admins", "permission", "permissions", "role", "roles"] }, { iconClass: "fa-light fa-people-roof", iconActiveClass: "fa-solid fa-people-roof", keywords: ["site", "sites", "organization", "tenant", "household", "community"] }, { iconClass: "fa-light fa-address-book", iconActiveClass: "fa-solid fa-address-book", keywords: ["contact", "contacts", "address", "directory", "rolodex", "address-book", "addresses"] }, // — Locations / placements ———————————————————————————————————————— { iconClass: "fa-light fa-location-dot", iconActiveClass: "fa-solid fa-location-dot", keywords: ["placement", "placements", "rotation", "rotations", "location", "locations", "site", "sites", "address", "place"] }, { iconClass: "fa-light fa-map", iconActiveClass: "fa-solid fa-map", keywords: ["map", "maps", "region", "regions", "territory", "territories", "geography"] }, { iconClass: "fa-light fa-buildings", iconActiveClass: "fa-solid fa-buildings", keywords: ["brand", "brands", "network", "company", "companies", "organization", "organizations", "facility", "facilities"] }, { iconClass: "fa-light fa-hospital", iconActiveClass: "fa-solid fa-hospital", keywords: ["hospital", "hospitals", "clinic", "clinics", "medical-center", "ward"] }, { iconClass: "fa-light fa-route", iconActiveClass: "fa-solid fa-route", keywords: ["route", "routes", "path", "paths", "journey", "journeys", "pipeline", "pipelines"] }, // — Time / scheduling ———————————————————————————————————————————— { iconClass: "fa-light fa-calendar", iconActiveClass: "fa-solid fa-calendar", keywords: ["calendar", "calendars", "schedule", "schedules", "scheduling", "date", "dates", "event", "events", "agenda", "planner", "shift", "shifts", "timesheet", "timetable"] }, { iconClass: "fa-light fa-calendar-check", iconActiveClass: "fa-solid fa-calendar-check", keywords: ["appointment", "appointments", "booking", "bookings", "reservation", "reservations", "rsvp"] }, { iconClass: "fa-light fa-clock", iconActiveClass: "fa-solid fa-clock", keywords: ["time", "clock", "timer", "history", "log", "logs", "duration", "hours"] }, { iconClass: "fa-light fa-hourglass", iconActiveClass: "fa-solid fa-hourglass", keywords: ["pending", "wait", "waiting", "queue", "deadline", "deadlines"] }, { iconClass: "fa-light fa-bell", iconActiveClass: "fa-solid fa-bell", keywords: ["notification", "notifications", "alert", "alerts", "reminder", "reminders", "bell", "ping", "pings"] }, // — Documents / files / library ———————————————————————————————————— { iconClass: "fa-light fa-folder-open", iconActiveClass: "fa-solid fa-folder-open", keywords: ["document", "documents", "doc", "docs", "file", "files", "folder", "folders", "contract", "contracts", "attachment", "attachments", "upload", "uploads"] }, { iconClass: "fa-light fa-books", iconActiveClass: "fa-solid fa-books", keywords: ["library", "libraries", "catalog", "catalogue", "template", "templates", "book", "books", "resource", "resources"] }, { iconClass: "fa-light fa-file-lines", iconActiveClass: "fa-solid fa-file-lines", keywords: ["form", "forms", "page", "pages", "article", "articles", "post", "posts", "note", "notes"] }, { iconClass: "fa-light fa-file-pdf", iconActiveClass: "fa-solid fa-file-pdf", keywords: ["pdf", "export", "exports", "download", "downloads", "print"] }, { iconClass: "fa-light fa-cloud-arrow-up", iconActiveClass: "fa-solid fa-cloud-arrow-up", keywords: ["upload", "uploads", "import", "imports", "sync"] }, // — Compliance / safety ———————————————————————————————————————————— { iconClass: "fa-light fa-shield-check", iconActiveClass: "fa-solid fa-shield-check", keywords: ["compliance", "compliant", "audit", "audits", "safety", "shield", "risk", "risks", "verification", "screening", "screenings", "clearance", "clearances", "immunization", "immunizations"] }, { iconClass: "fa-light fa-lock", iconActiveClass: "fa-solid fa-lock", keywords: ["lock", "security", "private", "privacy", "secure"] }, { iconClass: "fa-light fa-key", iconActiveClass: "fa-solid fa-key", keywords: ["key", "keys", "credential", "credentials", "token", "tokens", "secret", "secrets"] }, { iconClass: "fa-light fa-fingerprint", iconActiveClass: "fa-solid fa-fingerprint", keywords: ["identity", "identification", "id", "verify", "verification", "biometric"] }, { iconClass: "fa-light fa-stethoscope", iconActiveClass: "fa-solid fa-stethoscope", keywords: ["clinical", "clinic", "exam", "exams", "encounter", "encounters", "vitals", "physical"] }, { iconClass: "fa-light fa-syringe", iconActiveClass: "fa-solid fa-syringe", keywords: ["vaccine", "vaccination", "vaccinations", "shot", "shots", "immunization"] }, // — Evaluations / assessments / quality —————————————————————————— { iconClass: "fa-light fa-clipboard-check", iconActiveClass: "fa-solid fa-clipboard-check", keywords: ["evaluation", "evaluations", "assessment", "assessments", "review", "reviews", "rubric", "rubrics", "feedback", "checklist", "checklists"] }, { iconClass: "fa-light fa-list-check", iconActiveClass: "fa-solid fa-list-check", keywords: ["task", "tasks", "todo", "todos", "checklist", "checklists", "action", "actions", "workflow", "workflows"] }, { iconClass: "fa-light fa-star", iconActiveClass: "fa-solid fa-star", keywords: ["favorite", "favorites", "rating", "ratings", "star", "stars", "highlight", "featured"] }, { iconClass: "fa-light fa-thumbs-up", iconActiveClass: "fa-solid fa-thumbs-up", keywords: ["approval", "approvals", "approve", "endorse", "endorsement", "sign-off", "signoff"] }, { iconClass: "fa-light fa-medal", iconActiveClass: "fa-solid fa-medal", keywords: ["badge", "badges", "achievement", "achievements", "award", "awards"] }, { iconClass: "fa-light fa-certificate", iconActiveClass: "fa-solid fa-certificate", keywords: ["certificate", "certificates", "certification", "certifications", "credential", "license", "licenses"] }, // — Data / analytics ———————————————————————————————————————————————— { iconClass: "fa-light fa-chart-line", iconActiveClass: "fa-solid fa-chart-line", keywords: ["report", "reports", "analytic", "analytics", "insight", "insights", "trend", "trends", "metric", "metrics", "growth", "performance", "stats", "statistics"] }, { iconClass: "fa-light fa-chart-pie", iconActiveClass: "fa-solid fa-chart-pie", keywords: ["pie", "share", "split", "distribution", "breakdown"] }, { iconClass: "fa-light fa-chart-column", iconActiveClass: "fa-solid fa-chart-column", keywords: ["bar-chart", "comparison", "compare", "ranking", "rankings"] }, { iconClass: "fa-light fa-database", iconActiveClass: "fa-solid fa-database", keywords: ["database", "data", "dataset", "datasets", "record", "records", "store", "warehouse"] }, { iconClass: "fa-light fa-table", iconActiveClass: "fa-solid fa-table", keywords: ["table", "tables", "grid", "spreadsheet", "list", "lists", "rows"] }, { iconClass: "fa-light fa-magnifying-glass-chart", iconActiveClass: "fa-solid fa-magnifying-glass-chart", keywords: ["analyze", "analysis", "deep-dive", "drill"] }, // — Communication —————————————————————————————————————————————————— { iconClass: "fa-light fa-inbox", iconActiveClass: "fa-solid fa-inbox", keywords: ["inbox", "mailbox", "queue"] }, { iconClass: "fa-light fa-envelope", iconActiveClass: "fa-solid fa-envelope", keywords: ["mail", "email", "emails", "letter", "letters", "envelope"] }, { iconClass: "fa-light fa-message", iconActiveClass: "fa-solid fa-message", keywords: ["message", "messages", "chat", "chats", "conversation", "conversations", "thread", "threads", "comment", "comments", "discussion", "discussions"] }, { iconClass: "fa-light fa-megaphone", iconActiveClass: "fa-solid fa-megaphone", keywords: ["announcement", "announcements", "broadcast", "broadcasts", "news", "updates"] }, { iconClass: "fa-light fa-newspaper", iconActiveClass: "fa-solid fa-newspaper", keywords: ["news", "press", "feed", "blog", "blogs", "article", "articles"] }, { iconClass: "fa-light fa-phone", iconActiveClass: "fa-solid fa-phone", keywords: ["phone", "call", "calls", "telephone"] }, { iconClass: "fa-light fa-video", iconActiveClass: "fa-solid fa-video", keywords: ["video", "videos", "meeting", "meetings", "conference", "conferences"] }, // — Search / find —————————————————————————————————————————————————— { iconClass: "fa-light fa-magnifying-glass", iconActiveClass: "fa-solid fa-magnifying-glass", keywords: ["search", "find", "lookup", "query", "queries", "filter", "filters"] }, { iconClass: "fa-light fa-tags", iconActiveClass: "fa-solid fa-tags", keywords: ["tag", "tags", "label", "labels", "category", "categories", "topic", "topics"] }, { iconClass: "fa-light fa-bookmark", iconActiveClass: "fa-solid fa-bookmark", keywords: ["bookmark", "bookmarks", "saved", "favorites"] }, // — Money / billing ———————————————————————————————————————————————— { iconClass: "fa-light fa-credit-card", iconActiveClass: "fa-solid fa-credit-card", keywords: ["billing", "invoice", "invoices", "payment", "payments", "card", "checkout"] }, { iconClass: "fa-light fa-coins", iconActiveClass: "fa-solid fa-coins", keywords: ["finance", "money", "currency", "balance", "balances", "wallet"] }, { iconClass: "fa-light fa-receipt", iconActiveClass: "fa-solid fa-receipt", keywords: ["receipt", "receipts", "transaction", "transactions", "expense", "expenses"] }, { iconClass: "fa-light fa-chart-mixed", iconActiveClass: "fa-solid fa-chart-mixed", keywords: ["budget", "budgets", "forecast", "forecasts", "projection", "projections"] }, { iconClass: "fa-light fa-tag", iconActiveClass: "fa-solid fa-tag", keywords: ["price", "pricing", "discount", "discounts", "deal", "deals"] }, // — Marketing / sales ———————————————————————————————————————————— { iconClass: "fa-light fa-bullseye", iconActiveClass: "fa-solid fa-bullseye", keywords: ["goal", "goals", "target", "targets", "objective", "objectives", "milestone", "milestones", "okr", "okrs"] }, { iconClass: "fa-light fa-funnel-dollar", iconActiveClass: "fa-solid fa-funnel-dollar", keywords: ["funnel", "funnels", "lead", "leads", "conversion", "conversions"] }, { iconClass: "fa-light fa-people-arrows", iconActiveClass: "fa-solid fa-people-arrows", keywords: ["audience", "audiences", "segment", "segments", "targeting"] }, { iconClass: "fa-light fa-rocket", iconActiveClass: "fa-solid fa-rocket", keywords: ["launch", "launches", "campaign", "campaigns", "release", "releases", "ship", "shipping"] }, // — Workflow / process ———————————————————————————————————————————— { iconClass: "fa-light fa-list-tree", iconActiveClass: "fa-solid fa-list-tree", keywords: ["hierarchy", "tree", "structure", "nested"] }, { iconClass: "fa-light fa-diagram-project", iconActiveClass: "fa-solid fa-diagram-project", keywords: ["project", "projects", "workflow", "workflows", "diagram", "process", "processes"] }, { iconClass: "fa-light fa-arrows-rotate", iconActiveClass: "fa-solid fa-arrows-rotate", keywords: ["sync", "refresh", "reload", "update", "updates"] }, { iconClass: "fa-light fa-flag", iconActiveClass: "fa-solid fa-flag", keywords: ["flag", "flags", "issue", "issues", "report", "flagged"] }, // — Tools / dev / admin —————————————————————————————————————————— { iconClass: "fa-light fa-gear", iconActiveClass: "fa-solid fa-gear", keywords: ["setting", "settings", "config", "configuration", "preference", "preferences", "options"] }, { iconClass: "fa-light fa-toolbox", iconActiveClass: "fa-solid fa-toolbox", keywords: ["tool", "tools", "toolbox", "utility", "utilities"] }, { iconClass: "fa-light fa-plug", iconActiveClass: "fa-solid fa-plug", keywords: ["integration", "integrations", "webhook", "webhooks", "connector", "connectors", "plugin", "plugins"] }, { iconClass: "fa-light fa-code", iconActiveClass: "fa-solid fa-code", keywords: ["code", "develop", "developer", "developers", "script", "scripts", "snippet", "snippets"] }, { iconClass: "fa-light fa-terminal", iconActiveClass: "fa-solid fa-terminal", keywords: ["terminal", "console", "shell", "command", "commands"] }, { iconClass: "fa-light fa-bug", iconActiveClass: "fa-solid fa-bug", keywords: ["bug", "bugs", "issue", "defect", "defects", "error", "errors"] }, { iconClass: "fa-light fa-server", iconActiveClass: "fa-solid fa-server", keywords: ["server", "servers", "host", "hosts", "infrastructure"] }, { iconClass: "fa-light fa-cloud", iconActiveClass: "fa-solid fa-cloud", keywords: ["cloud", "saas", "online", "remote"] }, { iconClass: "fa-light fa-shield-halved", iconActiveClass: "fa-solid fa-shield-halved", keywords: ["governance", "policy", "policies", "guardrail", "guardrails"] }, // — Education / curriculum —————————————————————————————————————— { iconClass: "fa-light fa-book-open", iconActiveClass: "fa-solid fa-book-open", keywords: ["course", "courses", "lesson", "lessons", "class", "classes", "curriculum", "syllabus"] }, { iconClass: "fa-light fa-pen-to-square", iconActiveClass: "fa-solid fa-pen-to-square", keywords: ["edit", "compose", "draft", "drafts", "submission", "submissions", "essay", "essays"] }, { iconClass: "fa-light fa-microphone", iconActiveClass: "fa-solid fa-microphone", keywords: ["lecture", "lectures", "podcast", "podcasts", "recording", "recordings"] }, // — Health / clinical ———————————————————————————————————————————— { iconClass: "fa-light fa-heart-pulse", iconActiveClass: "fa-solid fa-heart-pulse", keywords: ["patient", "patients", "health", "vitals", "case", "cases"] }, { iconClass: "fa-light fa-prescription-bottle-medical", iconActiveClass: "fa-solid fa-prescription-bottle-medical", keywords: ["prescription", "prescriptions", "medication", "medications", "drug", "drugs"] }, { iconClass: "fa-light fa-microscope", iconActiveClass: "fa-solid fa-microscope", keywords: ["lab", "labs", "research", "experiment", "experiments", "specimen"] }, { iconClass: "fa-light fa-flask", iconActiveClass: "fa-solid fa-flask", keywords: ["test", "tests", "experiment", "experiments", "trial", "trials"] }, // — Support / help ———————————————————————————————————————————————— { iconClass: "fa-light fa-life-ring", iconActiveClass: "fa-solid fa-life-ring", keywords: ["help", "support", "faq", "knowledge", "assistance"] }, { iconClass: "fa-light fa-circle-question", iconActiveClass: "fa-solid fa-circle-question", keywords: ["question", "questions", "query", "ask", "info"] }, { iconClass: "fa-light fa-ticket", iconActiveClass: "fa-solid fa-ticket", keywords: ["ticket", "tickets", "incident", "incidents", "case", "cases"] }, // — Misc utilities ———————————————————————————————————————————————— { iconClass: "fa-light fa-sparkles", iconActiveClass: "fa-solid fa-sparkles", keywords: ["new", "feature", "ai", "magic", "spark", "sparkle"] }, { iconClass: "fa-light fa-lightbulb", iconActiveClass: "fa-solid fa-lightbulb", keywords: ["idea", "ideas", "tip", "tips", "suggestion", "suggestions"] }, { iconClass: "fa-light fa-puzzle-piece", iconActiveClass: "fa-solid fa-puzzle-piece", keywords: ["module", "modules", "extension", "extensions", "addon", "addons"] }, { iconClass: "fa-light fa-globe", iconActiveClass: "fa-solid fa-globe", keywords: ["world", "global", "international", "language", "languages", "locale", "locales"] }, { iconClass: "fa-light fa-share-nodes", iconActiveClass: "fa-solid fa-share-nodes", keywords: ["share", "social", "broadcast"] }, { iconClass: "fa-light fa-paperclip", iconActiveClass: "fa-solid fa-paperclip", keywords: ["attachment", "attach", "paperclip", "clip"] }, ] as const /** * Module-init: precompute a `Set` of keywords per candidate so the hot * inference loop uses O(1) `Set#has()` lookups instead of repeated * `Array#includes` (O(n) per call). * * For substring fallback we also pre-join each keyword set into a single * delimited string (`|word1|word2|…|`). A token "matches" if either side * is a substring of the other — and once joined, that becomes a single * `String#indexOf` per candidate instead of an array iteration. This is * a real perf win (one substring scan over a ~50-char joined string vs. * up to ~8 small string scans) and also keeps the hot inference loop * free of any `.includes`/`.indexOf` calls inside a nested loop, which * is what the `react-doctor/js-set-map-lookups` rule flags. */ interface NavIconKeywordIndex { readonly set: ReadonlySet /** * Alternation of escaped keywords (≥ 4 chars), e.g. `compliance|cohort|…`. * `regex.test(token)` returns true if any keyword is a substring of the * token — covers the realistic "user types a compound word that contains * one of our keywords" case (e.g. "compliancetracker" → matches `compliance`). * One precompiled regex per candidate, so the hot inference loop runs a * single `RegExp#test` per token — no `Array#indexOf`/`String#includes` * inside a nested loop, which is what `react-doctor/js-set-map-lookups` * flags. */ readonly substringRegex: RegExp | null } function escapeRegExp(input: string): string { return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") } const NAV_ICON_KEYWORDS_INDEX: ReadonlyMap = new Map( NAV_ICON_LIBRARY.map(candidate => { const set = new Set(candidate.keywords) const longKeywords = candidate.keywords.filter(k => k.length >= 4) const substringRegex = longKeywords.length ? new RegExp(longKeywords.map(escapeRegExp).join("|")) : null return [candidate, { set, substringRegex }] as const }), ) const NAV_ICON_FALLBACK = { iconClass: "fa-light fa-list-tree", iconActiveClass: "fa-solid fa-list-tree", } as const const STOPWORDS: ReadonlySet = new Set([ "a", "an", "the", "and", "or", "of", "for", "to", "in", "on", "at", "by", "my", "your", "our", "all", "new", "old", "any", "some", "with", "without", ]) /** * Lowercase + tokenize. We emit two flavours: * - word-split tokens (drop stopwords + 1-char fragments) * - the full input with non-alphanumerics stripped, so hyphenated words * like "to-do" → "todo" still match a single keyword. * * Duplicates are de-duped at the call site by the score loop so they * don't double-count. */ function tokenizeNavTitle(text: string): string[] { const lower = text.toLowerCase() const split = lower.split(/[^a-z0-9]+/).filter(t => t.length > 1 && !STOPWORDS.has(t)) const stripped = lower.replace(/[^a-z0-9]+/g, "") if (stripped.length > 1 && !split.includes(stripped) && !STOPWORDS.has(stripped)) { return [...split, stripped] } return split } function singularize(token: string): string { if (token.endsWith("ies") && token.length > 4) return `${token.slice(0, -3)}y` if (token.endsWith("es") && token.length > 3 && /(s|x|z|ch|sh)es$/.test(token)) return token.slice(0, -2) if (token.endsWith("s") && !token.endsWith("ss") && token.length > 3) return token.slice(0, -1) return token } /** * Score-based icon match. We try multiple normalizations for each token * (raw, singular, plural) so "Reports" lands on the same glyph as * "Report"; we accept substring matches for longer tokens so "rotational" * still matches "rotation". Specific glyphs win ties over generic ones * via a small length penalty (`-keywords.length * 0.01`). */ function inferIconFromTitle(title: string): { iconClass: string; iconActiveClass: string } { const tokens = tokenizeNavTitle(title) if (tokens.length === 0) return NAV_ICON_FALLBACK let best: { score: number; candidate: NavIconCandidate } | null = null for (const candidate of NAV_ICON_LIBRARY) { const index = NAV_ICON_KEYWORDS_INDEX.get(candidate)! let score = 0 for (const token of tokens) { const singular = singularize(token) const plural = `${singular}s` if (index.set.has(token)) { score += 6 } else if (index.set.has(singular) || index.set.has(plural)) { score += 5 } else if ( token.length >= 4 && index.substringRegex !== null && index.substringRegex.test(token) ) { score += 2 } } if (score === 0) continue // Prefer specific glyphs (smaller keyword set) on a tie. const adjusted = score - candidate.keywords.length * 0.01 if (!best || adjusted > best.score) best = { score: adjusted, candidate } } return best ? { iconClass: best.candidate.iconClass, iconActiveClass: best.candidate.iconActiveClass } : NAV_ICON_FALLBACK } type ProductPickKind = "prism" | "one" | "custom" function isPredefinedPick(id: ProductPickKind): id is "prism" | "one" { return id === "prism" || id === "one" } /** Built-in switcher product for a predefined onboarding pick. */ function resolveBuiltinProductId(pickId: ProductPickKind, scope: ScopeKind): Product { if (pickId === "prism") return "exxat-prism" if (pickId === "one") { return scope === "brand-site-location" ? "exxat-one-sites" : "exxat-one-schools" } return "exxat-custom" } interface ProductPick { id: ProductPickKind /** Built-in product the wordmark + brand color come from (when not custom). */ brandSource?: Product caption: string /** Defaults that preload the rest of the onboarding (scope, persona, nav). */ suffix: string brandColor: string scopeKind: ScopeKind persona: string navKeys: ReadonlyArray } /** * Step-1 product picks. Prism and Exxat One wire the built-in switcher * products (no tenant clone). Custom creates a draft custom product + * includes the primary-nav builder step. */ const PRODUCT_PICKS: ReadonlyArray = [ { id: "prism", brandSource: "exxat-prism", caption: "School + program scope, placements, compliance, and curriculum nav.", suffix: "Prism", brandColor: "oklch(57.84% 0.1560 279.93)", scopeKind: "school-program", persona: "program-coordinator", navKeys: ["library", "placements", "team", "compliance"], }, { id: "one", brandSource: "exxat-one-schools", caption: "School or site scope — coordinator workflows with built-in Exxat One nav.", suffix: "One", brandColor: "oklch(57.84% 0.1560 279.93)", scopeKind: "school-program", persona: "program-coordinator", navKeys: ["placements", "team", "compliance", "documents"], }, { id: "custom", caption: "Build your own — pick a name, color, scope, and starter nav.", // Empty by default so the wordmark field shows its placeholder ("Assessment") // rather than pre-filling a literal name the user has to clear first. suffix: "", brandColor: brandAccentOklchFromHue(195), scopeKind: "school-program", persona: "program-coordinator", navKeys: ["library", "placements", "team"], }, ] as const interface NavRow { key: string title: string caption?: string segment: string iconClass: string iconActiveClass: string hasSecondary?: boolean } function librarySubChildren(slug: string): SerializableNavLink[] { const root = `/${slug}` return [ { key: "library-hub", title: "Library home", url: `${root}/library`, iconClass: "fa-light fa-sparkles", iconActiveClass: "fa-solid fa-sparkles", primaryHubChildKey: "library-hub", }, { key: "library-search", title: "Search", url: `${root}/library/find`, iconClass: "fa-light fa-magnifying-glass", iconActiveClass: "fa-solid fa-magnifying-glass", }, { key: "library-all", title: "All items", url: `${root}/library/all`, iconClass: "fa-light fa-table-list", iconActiveClass: "fa-solid fa-table-list", }, ] } function buildNavLinks(slug: string, rows: ReadonlyArray): SerializableNavLink[] { const root = `/${slug}` const links: SerializableNavLink[] = [ { key: "dashboard", title: "Dashboard", url: `${root}/dashboard`, iconClass: "fa-light fa-gauge-high", iconActiveClass: "fa-solid fa-gauge-high", }, ] for (const row of rows) { const link: SerializableNavLink = { key: row.key, title: row.title, url: `${root}/${row.segment}`, iconClass: row.iconClass, iconActiveClass: row.iconActiveClass, } if (row.hasSecondary) { link.secondaryPanel = row.key === "library" ? "library" : row.key link.primaryHubChildKey = `${row.key}-hub` if (row.key === "library") { link.children = librarySubChildren(slug) } } links.push(link) } return links } function slugifyKey(input: string): string { return input .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, "") } interface OptionCardProps { selected: boolean icon: string title: string caption: string onClick: () => void } function OptionCard({ selected, icon, title, caption, onClick }: OptionCardProps) { return (