Files
member-console/internal/server/anatomy.go
T

1519 lines
62 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"fmt"
"net/url"
"strconv"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
"git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// Page anatomy (docs/design-system.md §6; spec page-anatomy). These are the
// values the shared parts under templates/partials/ui_*.html render from.
// Pages build them in Go, usually as methods on their page data, so a
// template never composes a title, a badge class, or an empty state itself.
// Link is a labelled destination for a header slot.
type Link struct {
Label string
URL string
// Filled renders a section header's link as the filled btn-primary
// instead of outline-secondary. It marks the section's one call to
// action, which today is exactly a list page's "New ..." link to the
// record's create page (design D20 "record creation gets its own page");
// every other header link navigates and stays outline-secondary
// (form-conventions "Button weight follows the button's role"; design
// D19). Page headers ignore it: their action always navigates, so
// ui_page_header.html renders outline-secondary unconditionally.
Filled bool
// Toggle, when set, makes a section header's action a button that opens
// the collapsed panel whose element id it names (Bootstrap collapse, the
// Issue grant idiom; design D20, sub-record creation stays inline),
// instead of a link to URL. Expanded renders the panel open on first
// paint (a re-render carrying that form's field errors). Toggle actions
// render tertiary, with the rotating plus glyph as their whole
// open/closed signal.
Toggle string
Expanded bool
// Post, when set, makes the action an htmx button that posts to it
// instead of a link or a toggle: the Rules section's Add rule, which
// opens an editing row in a form the header sits outside of (design D2,
// D8 of staged-rule-changes). Vals rides as hx-vals, Include as
// hx-include and Target as hx-target; the swap is outerHTML, as every
// posting control of that form uses. Like a Toggle action it renders
// tertiary, with the same opener glyph.
Post string
Vals string
Include string
Target string
}
// OperatorSurfaceRoot and MemberSurfaceRoot are the surfaceRoot template
// function's two real values (design D18 "The location trail on every
// page, rooted at the surface"): every operator template set's FuncMap
// registers the former as "surfaceRoot", every member set's the latter, so
// the pageHeader part can prepend the surface's own root crumb without any
// page constructor naming it.
// The values themselves live in internal/web, which the integration-owned
// operator sets register too, so every surface has one root.
func OperatorSurfaceRoot() Link { return rootLink(web.OperatorSurfaceRoot()) }
func MemberSurfaceRoot() Link { return rootLink(web.MemberSurfaceRoot()) }
func rootLink(r web.SurfaceRoot) Link { return Link{Label: r.Label, URL: r.URL} }
// PageHeader is what the pageHeader part renders: the page title at the one
// size, an optional back affordance above it, an optional one-sentence lead
// below it, and one right-hand slot holding either a muted count or one
// action control.
type PageHeader struct {
Title string
Lead string
// Crumbs are the location trail's parent links, between the surface
// root and the current page (rail entry first, then any real
// intermediate pages). The pageHeader part prepends the surface root
// (the per-template-set surfaceRoot function) ahead of these, unless
// NoTrail, and appends the page title as the unlinked current crumb
// (chrome-conventions "One location trail"; design D18).
Crumbs []Link
Count string
Action *Link
// NoTrail suppresses the location trail entirely. Set on the two
// surface roots (the operator overview, the member dashboard), which
// are what the trail is rooted at, and on the error page, which is
// what came back instead of a location and may be reached without a
// session. Every other page carries the trail, rooted at the surface
// (design D18 "The location trail on every page, rooted at the
// surface").
NoTrail bool
}
// Check enforces the one-slot rule at render time: html/template aborts the
// execution when a method returns a non-nil error, so a page that supplies
// both a count and an action fails to render instead of rendering both.
func (h PageHeader) Check() (string, error) {
if h.Count != "" && h.Action != nil {
return "", fmt.Errorf("page header %q: the right-hand slot holds a count or an action, not both", h.Title)
}
return "", nil
}
// HelpIcon is what the helpIcon part renders: a click-opened popover
// explaining one field or column, titled with its label (design D23 "Help
// is disclosed by click-opened popovers"). Built by the helpIcon template
// function, registered at every FuncMap site, so templates write
// {{ template "helpIcon" (helpIcon "Quantity" "Seats, for per-seat
// products.") }} rather than hand-rolling a `<span title>` gloss.
type HelpIcon struct {
Label string
Text string
}
// helpIcon is the helpIcon template function: it builds the value the
// helpIcon part renders from.
func helpIcon(label, text string) HelpIcon {
return HelpIcon{Label: label, Text: text}
}
// SectionHeader is what the sectionHeader part (and its sectionSummary
// variant for a <details> disclosure) renders: an h2 at the one section
// size with an optional muted count or one action.
type SectionHeader struct {
Title string
Count string
Action *Link
// Help renders the standard help icon with a native tooltip after the
// title, for a section whose meaning needs one sentence.
Help string
// Key renders the entity's read-only declarative address in the muted
// code style in the right slot (docs/identifiers.md, Surfaces), when
// the section fronts a keyed instance and no count or action rides
// there.
Key string
}
// Readout is what the readout part renders: one headline count or amount,
// an uppercase muted label, the console's one uppercase text, over the
// value at the console's one readout size (overview-consistency D3;
// page-anatomy "A readout is a part").
//
// Available distinguishes "counted zero" from "could not count": a failed
// count renders the em dash marker and the caption "Count unavailable",
// never a 0 an operator would read as an answer. Attention marks a value
// that needs an operator (dead-lettered delivery work), rendered in the
// danger colour.
//
// The part renders no anchor and the label is never a link (overview-
// consistency D3, round 2). A readout that navigates sits in a linked card
// (page-anatomy "A linked card is marked by a glyph and the row's hover"),
// which is itself the anchor and carries its own chevron glyph.
type Readout struct {
Label string
Value string
Caption string
Available bool
Attention bool
}
// Badge is what the statusBadge part renders. Tone is one of success,
// secondary, warning, danger, info, light; Title is an optional tooltip.
type Badge struct {
Label string
Tone string
Title string
}
// WithTitle returns the badge with a tooltip.
func (b Badge) WithTitle(title string) Badge {
b.Title = title
return b
}
// badgeMap is the one place a state becomes a label and a tone. Keys are the
// state strings the handlers emit (database enums, registry states, and the
// console's own readiness words). Labels are title case. Add a state here
// before emitting it; TestStatusBadgeMapIsComplete fails on the ones it
// knows about that are missing.
var badgeMap = map[string]Badge{
// Subscription states (billing summary; group 5 completes the set)
"trialing": {Label: "Trialing", Tone: "info"},
"past_due": {Label: "Past due", Tone: "danger"},
"paused": {Label: "Paused", Tone: "secondary"},
// Pool lifecycle beyond active/inactive
"suspended": {Label: "Suspended", Tone: "danger"},
// Tier-change verbs (transition_type; the composite's Tier changes)
"initiate": {Label: "Started", Tone: "light"},
"upgrade": {Label: "Upgraded", Tone: "light"},
"downgrade": {Label: "Downgraded", Tone: "light"},
"transfer": {Label: "Transferred", Tone: "light"},
"extension": {Label: "Extended", Tone: "light"},
"end": {Label: "Ended", Tone: "light"},
// Grant targets (the Grants ledger)
"entitlement_set": {Label: "Set", Tone: "light"},
// The entitlement set History's kinds. The stored values stay the
// model's vocabulary and the badges stay the console's, matching the
// row action an operator pressed (design.md "History section"). There
// is no badge for rule_reactivated: no surface writes one.
"rule_added": {Label: "Added", Tone: "light"},
"rule_modified": {Label: "Edited", Tone: "light"},
"rule_deactivated": {Label: "Removed", Tone: "light"},
// Topology annotations (group 8): a product tiered in more than one ladder
"shared": {Label: "Shared", Tone: "light"},
// The member's own plan/workspace (group 10)
"current": {Label: "Current", Tone: "success"},
// Member domains vocabulary (group 10): hosted-vs-custom, and the
// member-facing pending state (what they wait on is their own DNS)
"hosted": {Label: "Hosted", Tone: "light"},
"awaiting_dns": {Label: "Awaiting DNS", Tone: "warning"},
// Grant reasons (grants.grant_reason; the member Sources list and,
// eventually, the operator Grants ledger). "default" is above under
// the neutral kinds.
"manual": {Label: "Manual", Tone: "light"},
"evaluation": {Label: "Evaluation", Tone: "light"},
"promotional": {Label: "Promotional", Tone: "light"},
"complimentary": {Label: "Complimentary", Tone: "light"},
"sponsored": {Label: "Sponsored", Tone: "light"},
"board_decision": {Label: "Board decision", Tone: "light"},
"legacy": {Label: "Legacy", Tone: "light"},
// Invoice, payment, and billing-account states (group 5)
"open": {Label: "Open", Tone: "warning"},
"void": {Label: "Void", Tone: "secondary"},
"uncollectible": {Label: "Uncollectible", Tone: "secondary"},
"refunded": {Label: "Refunded", Tone: "info"},
"partially_paid": {Label: "Partially paid", Tone: "warning"},
"succeeded": {Label: "Succeeded", Tone: "success"},
"failed": {Label: "Failed", Tone: "danger"},
"closed": {Label: "Closed", Tone: "secondary"},
// Lifecycle
"active": {Label: "Active", Tone: "success"},
"inactive": {Label: "Inactive", Tone: "secondary"},
"pending": {Label: "Pending", Tone: "warning"},
"expired": {Label: "Expired", Tone: "secondary"},
"canceled": {Label: "Canceled", Tone: "secondary"},
"canceling": {Label: "Canceling", Tone: "warning"},
"released": {Label: "Released", Tone: "secondary"},
"revoked": {Label: "Revoked", Tone: "secondary"},
"superseded": {Label: "Superseded", Tone: "secondary"},
"live": {Label: "Live", Tone: "success"},
// The counterpart of "live" for the Stripe environment a row's mapping
// records (stripe-environment-stamp D7). Secondary, the muted tone the
// map already uses for a state that is not the live one: a test-mode
// row is not a problem, it is simply not the environment this key is
// in, and the billing views badge it only while showing both.
"test": {Label: "Test", Tone: "secondary"},
"draft": {Label: "Draft", Tone: "secondary"},
"published": {Label: "Published", Tone: "success"},
"retired": {Label: "Retired", Tone: "secondary"},
// Product purchasability verdict (product-management "The Status
// column is the verdict", design D1); draft and retired reuse the
// Lifecycle states just above rather than duplicating them.
"purchasable": {Label: "Purchasable", Tone: "success"},
"ready_to_grant": {Label: "Ready to grant", Tone: "info"},
// Readiness
"configured": {Label: "Configured", Tone: "success"},
"not_configured": {Label: "Not configured", Tone: "warning"},
"verified": {Label: "Verified", Tone: "success"},
"unverified": {Label: "Unverified", Tone: "warning"},
"servable": {Label: "Servable", Tone: "success"},
"unservable": {Label: "Unservable", Tone: "secondary"},
"seen": {Label: "Seen", Tone: "info"},
// Kinds (neutral)
domains.KindOperatorRoot: {Label: "Operator root", Tone: "light"},
domains.KindMember: {Label: "Member", Tone: "light"},
domains.KindExternal: {Label: "External", Tone: "light"},
"personal": {Label: "Personal", Tone: "light"},
"team": {Label: "Team", Tone: "light"},
"system": {Label: "System tenant", Tone: "light"},
"recurring": {Label: "Recurring", Tone: "light"},
"one_time": {Label: "One-time", Tone: "light"},
"grant_issued": {Label: "Grant", Tone: "light"},
"transition": {Label: "Transition", Tone: "light"},
"invoice_created": {Label: "Invoice", Tone: "light"},
"payment_received": {Label: "Payment", Tone: "light"},
"product": {Label: "Product", Tone: "light"},
"environment": {Label: "Environment", Tone: "light"},
"override": {Label: "Override", Tone: "light"},
"default": {Label: "Default", Tone: "light"},
"subdomain": {Label: "Subdomain", Tone: "light"},
"custom": {Label: "Custom", Tone: "light"},
"archived": {Label: "Archived", Tone: "secondary"},
"read_only": {Label: "Read-only", Tone: "warning"},
"incomplete": {Label: "Incomplete", Tone: "warning"},
"incomplete_expired": {Label: "Incomplete expired", Tone: "secondary"},
"granted": {Label: "Granted", Tone: "success"},
"included": {Label: "Included", Tone: "success"},
"pending_restart": {Label: "Pending restart", Tone: "warning"},
"done": {Label: "Done", Tone: "success"},
"met": {Label: "Met", Tone: "success"},
"missing": {Label: "Missing", Tone: "warning"},
"not_set": {Label: "Not set", Tone: "secondary"},
"check_failed": {Label: "Check failed", Tone: "danger"},
// DNS verification checks
"not_checked": {Label: "Not checked yet", Tone: "secondary"},
"not_found": {Label: "Not found yet", Tone: "warning"},
"found": {Label: "Found", Tone: "success"},
"found_mismatch": {Label: "Found, but doesn't match", Tone: "warning"},
// Payments and projection
"paid": {Label: "Paid", Tone: "success"},
"unpaid": {Label: "Unpaid", Tone: "warning"},
"overdue": {Label: "Overdue", Tone: "danger"},
"synced": {Label: "Synced", Tone: "success"},
"not_mapped": {Label: "Not mapped", Tone: "secondary"},
"sync_pending": {Label: "Sync pending", Tone: "warning"},
// A mapping whose Stripe id the environment check could not fetch under
// the current key (stripe-environment-stamp D2). The object still
// exists in the environment that made it, so this is not a failure of
// the sync; it is an id this deployment can no longer reach.
"stale": {Label: "Stale", Tone: "warning"},
"sync_failed": {Label: "Sync failed", Tone: "danger"},
}
// StatusBadge maps a state to its badge. An unknown state renders as a
// secondary badge with the raw state as its label, so it is visible rather
// than styled; the map's completeness test names it.
func StatusBadge(state string) Badge {
if b, ok := badgeMap[state]; ok {
return b
}
return Badge{Label: state, Tone: "secondary"}
}
// knownStates are the state strings the handlers emit through StatusBadge,
// gathered from the packages that define them so the completeness test
// follows their constants.
var knownStates = []string{
domains.StatusPending, domains.StatusActive, domains.StatusExpired, domains.StatusCanceled, domains.StatusReleased,
domains.KindOperatorRoot, domains.KindMember, domains.KindExternal,
string(integration.StateActive),
"not_configured", "servable", "unservable", "seen",
// anatomy-sweep, design D3
"archived", "read_only", "incomplete", "granted", "included", "pending_restart",
"done", "met", "missing", "not_set", "check_failed",
"not_checked", "not_found", "found", "found_mismatch",
"personal", "team", "system", "recurring", "one_time",
"grant_issued", "transition", "invoice_created", "payment_received", "product",
"environment", "override", "default", "subdomain", "custom",
"live", "test", "superseded", "inactive", "synced", "not_mapped", "sync_pending", "sync_failed", "stale",
"purchasable", "ready_to_grant",
"paid", "unpaid", "overdue", "verified", "unverified", "draft", "published", "retired",
"expired", "canceled", "released", "revoked",
"trialing", "past_due", "paused", "suspended", "incomplete_expired", "initiate", "upgrade", "downgrade", "transfer", "extension", "end", "entitlement_set",
"rule_added", "rule_modified", "rule_deactivated",
"open", "void", "uncollectible", "refunded", "partially_paid", "succeeded", "failed", "closed",
"canceling", "shared", "current", "hosted", "awaiting_dns",
"manual", "evaluation", "promotional", "complimentary", "sponsored", "board_decision", "legacy",
}
// countLabel renders "1 live claim" / "3 live claims" for header slots.
func countLabel(n int, singular, plural string) string {
if n == 1 {
return fmt.Sprintf("%d %s", n, singular)
}
return fmt.Sprintf("%d %s", n, plural)
}
// Group 1 of the sweep (Overview, Setup, not-found, error): the pages'
// values for the parts.
// LandingHeader is the operator landing surface's page header. It is one of
// exactly two surface roots (with IndexPageData.Header) that carry no
// location trail (design D18).
func (OperatorPageData) LandingHeader() PageHeader {
return PageHeader{Title: "Operator overview", Lead: "Membership, delivery, catalog and integration state for this deployment.", NoTrail: true}
}
// GlanceHeader, SystemHeader and ActivityHeader title the landing
// surface's three regions. They are sections like any other
// (overview-consistency D1): the page writes no heading of its own, and
// the System header's one action always offers Setup, navigation and so
// outline-secondary (design D19).
func (OperatorPageData) GlanceHeader() SectionHeader {
return SectionHeader{Title: "At a glance"}
}
func (OperatorPageData) SystemHeader() SectionHeader {
return SectionHeader{Title: "System", Action: &Link{Label: "Setup", URL: "/operator/setup"}}
}
func (OperatorPageData) ActivityHeader() SectionHeader {
return SectionHeader{Title: "Recent activity"}
}
// CountsUnavailable is the at-a-glance region's empty state.
func (OperatorPageData) CountsUnavailable() EmptyStateParams {
return EmptyStateParams{Headline: "Counts are unavailable."}
}
// NoActivity is the activity feed's empty state.
func (OperatorPageData) NoActivity() EmptyStateParams {
return EmptyStateParams{Headline: "No activity recorded yet.", Note: "Grants, plan transitions, invoices and payments appear here as they happen."}
}
// Readout is one at-a-glance tile's value for the readout part. Href marks
// the tile's own card as the link and the chevron glyph beside it
// (overview-consistency D3, round 2); the readout itself carries no URL.
func (s OverviewStat) Readout() Readout {
return Readout{Label: s.Label, Value: s.Value, Caption: s.Caption, Available: s.Available}
}
// KindBadge renders the activity feed's event kind.
func (e ActivityEvent) KindBadge() Badge { return StatusBadge(e.EventType) }
// Header is the setup checklist page's header; the intro's second sentence
// renders below it as a paragraph.
func (SetupState) Header() PageHeader {
return PageHeader{Title: "Setup checklist", Lead: "Steps to take this deployment from installed to member-ready."}
}
// StateBadge renders a step's completion.
func (s SetupStep) StateBadge() Badge {
if s.Complete {
return StatusBadge("done")
}
return StatusBadge("incomplete")
}
// Header is the operator not-found page's header, with the way back.
func (OperatorNotFoundData) Header() PageHeader {
return PageHeader{Title: "Page not found"}
}
// ErrorPageAction is the error page's one way out: where the button goes
// and what it says. Every error page has exactly one.
type ErrorPageAction struct {
Label string
Href string
}
// ErrorPageData feeds error.html (render.go), the one page without a
// shell; it still titles itself through the part.
type ErrorPageData struct {
Status int
StatusText string
Message string
// Title replaces "<status> <status text>" as the heading, for a
// failure whose status line says nothing useful to the person reading
// it (a sign-in that expired is a 400).
Title string
// Action is the button. RenderErrorPage and RenderSignInError each
// fill it; a zero value would render a button with no label.
Action ErrorPageAction
}
// Header is the error page's header: the status as the title unless the page
// named its own, and the message as the lead.
//
// No trail. The trail is rooted at the surface, and this page is reachable
// without a session, so its root would be a link the visitor may not be able
// to follow. The page is not at a location either -- it is what came back
// instead of one -- and the page's own action already carries the way out.
func (d ErrorPageData) Header() PageHeader {
title := d.Title
if title == "" {
title = fmt.Sprintf("%d %s", d.Status, d.StatusText)
}
return PageHeader{Title: title, Lead: d.Message, NoTrail: true}
}
// plain is the page as one line, for the HTMX and render-failure paths that
// answer with text instead.
func (d ErrorPageData) plain() string {
if d.Message != "" {
return d.Message
}
return d.Title
}
// Group 2 of the sweep (People): the pages' values for the parts.
// Header is the People directory's header; the true total rides in the
// slot (operator-list-scale's count(*) OVER()).
func (d PersonsData) Header() PageHeader {
h := PageHeader{Title: "People"}
if d.Nav.Total > 0 {
h.Count = countLabel(int(d.Nav.Total), "person", "people")
}
return h
}
// Empty is the directory's true-empty state: how a person row comes to
// exist, so the operator knows nothing is broken.
func (PersonsData) Empty() EmptyStateParams {
return EmptyStateParams{Headline: "No one has signed in yet.", Note: "Person records are created automatically the first time someone logs in."}
}
// UnverifiedBadge marks the exception: an address the identity provider
// has not verified. A verified address renders nothing (design D8:
// boolean checks mark only the exception).
func (p PersonListViewModel) UnverifiedBadge() Badge {
return StatusBadge("unverified")
}
// StatusBadge renders the person's lifecycle status.
func (p PersonListViewModel) StatusBadge() Badge {
return StatusBadge(p.Status)
}
// Header is the person page's header: the person's name as the title, the
// email as the lead, the trail to the directory above them. The name and
// email are not marked here; each carries the identity-provider glyph on
// its own Details-section label instead (operator-people-directory "The
// identity provider's ownership of a person's name is marked on the
// label"; acceptance-fixes round 5, maintainer 2026-09-03: "remove that
// epic in the middle of the card and instead add the glyph back but add
// them to the field labels themselves for the ones that are applicable").
func (d PersonDetailData) Header() PageHeader {
return PageHeader{Title: d.Person.DisplayName, Lead: d.Person.Email, Crumbs: []Link{{Label: "People", URL: "/operator/persons"}}}
}
// MembershipsHeader titles the org-memberships section with its count.
func (d PersonDetailData) MembershipsHeader() SectionHeader {
return SectionHeader{Title: "Org memberships", Count: countLabel(len(d.Memberships), "organization", "organizations")}
}
// NoMemberships is the memberships section's empty state.
func (PersonDetailData) NoMemberships() EmptyStateParams {
return EmptyStateParams{Headline: "Not a member of any organization."}
}
// Group 3 of the sweep (Organizations, organization types): the pages'
// values for the parts.
// Header is the directory's header. The one right-hand slot carries the
// way to Organization types, which is reached from here and not from the
// rail (maintainer, 2026-08-23); the row count is the pager's.
func (OrganizationsData) Header() PageHeader {
return PageHeader{Title: "Organizations", Action: &Link{Label: "Organization types", URL: "/operator/org-types"}}
}
// Empty is the directory's true-empty state: how an organization comes to
// exist, so the operator knows nothing is broken.
func (OrganizationsData) Empty() EmptyStateParams {
return EmptyStateParams{Headline: "No organizations yet.", Note: "A personal organization is created for each person the first time they sign in."}
}
// TypeBadge renders the organization's type as a neutral kind. The
// reserved System tenant's badge carries the marker's explanation
// (ux-honest-surfaces UX-16); a deployment-defined type outside the map
// renders its key in the same light tone.
func (o OrganizationViewModel) TypeBadge() Badge {
if o.IsSystemOrg {
return StatusBadge(systemtenant.OrgType).WithTitle("Reserved system tenant, not a member organization")
}
if b, ok := badgeMap[o.OrgType]; ok {
return b
}
return Badge{Label: o.OrgType, Tone: "light"}
}
// StatusBadge renders the organization's lifecycle status.
func (o OrganizationViewModel) StatusBadge() Badge {
return StatusBadge(o.Status)
}
// Header titles the organization types page. What a type decides is the
// Default plan tooltip's job (maintainer, 2026-08-30: no lead).
func (OrgTypesData) Header() PageHeader {
return PageHeader{Title: "Organization types"}
}
// Empty is the page's true-empty state. The migrations create the
// personal type, so an empty list means they have not run.
func (OrgTypesData) Empty() EmptyStateParams {
return EmptyStateParams{Headline: "No organization types exist.", Note: "The database migrations create the personal type; an empty list means they have not run."}
}
// Header titles one type's section outside its card (overview-consistency
// D9): the display name as the title, the type key in the header's Key
// slot, which is where a keyed instance's read-only address belongs. The
// card below it no longer repeats the key.
func (o OrgTypeViewModel) Header() SectionHeader {
return SectionHeader{Title: o.DisplayName, Key: o.OrgType}
}
// ActiveBadge renders the type's lifecycle state.
func (o OrgTypeViewModel) ActiveBadge() Badge {
if o.IsActive {
return StatusBadge("active")
}
return StatusBadge("inactive")
}
// The organization composite (sweep task 3.2): the page's values for the
// parts.
// Header titles the composite by the organization's own name below the
// Organizations trail (plan-enrollment-administration "titled by the
// organization"); owner and key live in the details card, not the header.
func (d OrgEnrollmentData) Header() PageHeader {
return PageHeader{Title: d.OrgName, Crumbs: []Link{{Label: "Organizations", URL: "/operator/organizations"}}}
}
// BillingHeader titles the Billing section; the navigation action renders
// only when there is an account to navigate for, and it lands on this
// organization's row rather than the whole deployment's accounts (design
// D5, round 5).
func (d OrgEnrollmentData) BillingHeader() SectionHeader {
h := SectionHeader{Title: "Billing"}
if d.BillingSummary.HasAccount {
h.Action = &Link{Label: "Full billing", URL: orgBillingListURL("/operator/billing/accounts", d.OrgName, "")}
}
return h
}
// orgBillingListURL scopes one of the billing lists to a single
// organization. The three lists search by organization name already
// (operator-list-scale UX-4: accounts "by organization or account name",
// subscriptions "by organization or billing account", invoices "by
// organization or invoice number"), and no account-detail route exists, so
// the search parameter is the scoping the lists offer today; a first-class
// account facet on all three is the durable fix (an issue, not this change).
func orgBillingListURL(path, orgName, status string) string {
q := url.Values{}
q.Set("q", orgName)
if status != "" {
q.Set("status", status)
}
return path + "?" + q.Encode()
}
// OpenInvoicesURL is where the outstanding balance points: this
// organization's open invoices, the rows that add up to the amount.
func (d OrgEnrollmentData) OpenInvoicesURL() string {
return orgBillingListURL("/operator/billing/invoices", d.OrgName, "open")
}
// SubscriptionsURL is where the subscription count points: this
// organization's subscriptions, where each one names its product.
func (d OrgEnrollmentData) SubscriptionsURL() string {
return orgBillingListURL("/operator/billing/subscriptions", d.OrgName, "")
}
// PoolsHeader carries the one explanation of what a pool is (design D5); no
// other paragraph on the composite repeats it.
func (OrgEnrollmentData) PoolsHeader() SectionHeader {
return SectionHeader{Title: "Pools", Help: "A pool holds what an organization's plan and grants deliver; its sites and services draw from it."}
}
func (OrgEnrollmentData) PlanGrantsHeader() SectionHeader {
return SectionHeader{Title: "Plan and grants"}
}
// MembersHeader carries the true total; the table itself is capped.
func (d OrgEnrollmentData) MembersHeader() SectionHeader {
h := SectionHeader{Title: "Members"}
if d.MembersTotal > 0 {
h.Count = countLabel(d.MembersTotal, "member", "members")
}
return h
}
// EntitlementChangesHeader titles the composite's trail of rule commits. No
// help icon: newest first is visible from the When column and the heading
// carries the rest (design.md "The deletion test").
func (OrgEnrollmentData) EntitlementChangesHeader() SectionHeader {
return SectionHeader{Title: "Entitlement changes"}
}
func (OrgEnrollmentData) NoEntitlementChanges() EmptyStateParams {
return EmptyStateParams{Headline: "No entitlement changes recorded yet."}
}
func (OrgEnrollmentData) TierChangesHeader() SectionHeader {
return SectionHeader{Title: "Tier changes", Help: "Every tier change on this organization's plan ladders, newest first. Tier names reflect the ladder's current shape."}
}
// NoBillingAccount, NoPools, NoMembers and NoTierChanges are the
// sections' true-empty states; NoGrants branches on the ledger tab.
func (OrgEnrollmentData) NoBillingAccount() EmptyStateParams {
return EmptyStateParams{Headline: "No billing account exists for this organization yet."}
}
func (OrgEnrollmentData) NoPools() EmptyStateParams {
return EmptyStateParams{Headline: "No pools for this organization."}
}
func (d OrgEnrollmentData) NoGrants() EmptyStateParams {
if d.GrantsNav.Facet == "history" {
return EmptyStateParams{Headline: "No grants issued yet."}
}
return EmptyStateParams{Headline: "No grants are delivering right now.", Note: "The History tab lists every grant ever issued."}
}
func (OrgEnrollmentData) NoMembers() EmptyStateParams {
return EmptyStateParams{Headline: "No members."}
}
func (OrgEnrollmentData) NoTierChanges() EmptyStateParams {
return EmptyStateParams{Headline: "No tier changes recorded."}
}
// The billing summary's badges. SubscriptionsBadge carries the worst state
// across the account's subscriptions; BalanceBadge marks money owed (the
// "unpaid" state the invoices list uses for the same money, so the two
// surfaces name it the same way); InvoiceBadge renders the state the
// invoices list derives, Overdue included.
func (b BillingSummaryViewModel) SubscriptionsBadge() Badge {
badge := StatusBadge(b.SubscriptionState)
if b.SubscriptionState == "canceling" {
return badge.WithTitle("Cancels at the period end and will not renew.")
}
return badge
}
func (BillingSummaryViewModel) BalanceBadge() Badge {
return StatusBadge("unpaid")
}
func (b BillingSummaryViewModel) InvoiceBadge() Badge {
return StatusBadge(b.InvoiceState)
}
// SubscriptionCountLabel names the count the card links: "1 subscription",
// "2 subscriptions".
func (b BillingSummaryViewModel) SubscriptionCountLabel() string {
return countLabel(b.SubscriptionCount, "subscription", "subscriptions")
}
// AccountAbnormal reports whether the billing account's own status is worth
// a row. Active is the normal state every healthy account carries, so it
// stays silent (design D5, round 5).
func (b BillingSummaryViewModel) AccountAbnormal() bool {
return b.HasAccount && b.AccountStatus != "" && b.AccountStatus != "active"
}
func (b BillingSummaryViewModel) AccountBadge() Badge {
return StatusBadge(b.AccountStatus)
}
// TypeBadge renders the pool's kind; a deployment-defined type outside
// the map renders its key in the same light tone.
func (p PoolEnrollmentViewModel) TypeBadge() Badge {
if b, ok := badgeMap[p.PoolType]; ok {
return b
}
return Badge{Label: p.PoolType, Tone: "light"}
}
// StateBadge renders the pool's lifecycle status; the red not-delivering
// sentence beside it carries the consequence.
func (p PoolEnrollmentViewModel) StateBadge() Badge {
return StatusBadge(p.Status)
}
// DeliveryBadge renders the grant ledger's delivery classification.
func (g GrantViewModel) DeliveryBadge() Badge {
return StatusBadge(g.DeliveryState)
}
// ChangeBadge renders the tier change's verb as a kind, with the one
// tooltip Transferred needs. ChangeKind, when set, names a verb that is
// not the type's own (an extension's transfer renders Extended).
func (t TransitionHistoryViewModel) ChangeBadge() Badge {
kind := t.TransitionType
if t.ChangeKind != "" {
kind = t.ChangeKind
}
b := StatusBadge(kind)
if t.ChangeTooltip != "" {
return b.WithTitle(t.ChangeTooltip)
}
return b
}
// Group 4 of the sweep (Grants): the ledger page's values for the parts.
// Header carries the true total; the page is a rail entry, so no trail.
func (d GrantsData) Header() PageHeader {
h := PageHeader{Title: "Grants"}
if d.Nav.Total > 0 {
h.Count = countLabel(int(d.Nav.Total), "grant", "grants")
}
return h
}
// Empty is the ledger's true-empty state: blocked while no product has
// been published (a grant delivers a published product), otherwise
// pointing at the per-organization issue flow.
func (d GrantsData) Empty() EmptyStateParams {
if len(d.Products) == 0 {
return EmptyStateParams{Blocked: true, BlockerCopy: "A grant delivers a published product to an organization, but no product has been published yet.", PrerequisiteURL: "/operator/products", PrerequisiteLabel: "Go to Products"}
}
return EmptyStateParams{Headline: "No grants issued yet.", Note: "Grants are issued from each organization's detail page, not from here."}
}
// TargetBadge renders what the grant delivers: a product, or a bare
// entitlement set.
func (g GrantViewModel) TargetBadge() Badge {
if g.ProductName != "" {
return StatusBadge("product")
}
return StatusBadge("entitlement_set")
}
// Group 5 of the sweep (Billing wrapper, four views, invoice detail):
// the pages' values for the parts.
// WrapperHeader supplies the billing wrapper's header for the invoice
// detail: the instance's number as the title below the Billing /
// Invoices trail.
func (d OperatorInvoiceDetailData) WrapperHeader() PageHeader {
title := "Invoice"
if d.InvoiceNumber != "" {
title = "Invoice " + d.InvoiceNumber
}
return PageHeader{Title: title, Crumbs: []Link{{Label: "Billing", URL: "/operator/billing/accounts"}, {Label: "Invoices", URL: "/operator/billing/invoices"}}}
}
func (d OperatorInvoiceDetailData) StatusBadge() Badge { return StatusBadge(d.StatusState) }
func (d OperatorInvoiceDetailData) SyncBadge() Badge { return StatusBadge(d.StripeSyncStatus) }
// LineItemsHeader and NoLineItems title the detail's one section.
func (OperatorInvoiceDetailData) LineItemsHeader() SectionHeader {
return SectionHeader{Title: "Line items"}
}
func (OperatorInvoiceDetailData) NoLineItems() EmptyStateParams {
return EmptyStateParams{Headline: "No line items recorded for this invoice."}
}
func (v InvoiceViewModel) StatusBadge() Badge { return StatusBadge(v.StatusState) }
func (v InvoiceViewModel) SyncBadge() Badge { return StatusBadge(v.StripeSyncStatus) }
func (v SubscriptionViewModel) StatusBadge() Badge { return StatusBadge(v.StatusState) }
func (v SubscriptionViewModel) SyncBadge() Badge { return StatusBadge(v.StripeSyncStatus) }
func (v PaymentViewModel) StatusBadge() Badge { return StatusBadge(v.StatusState) }
func (v PaymentViewModel) SyncBadge() Badge { return StatusBadge(v.StripeSyncStatus) }
func (v BillingAccountViewModel) StatusBadge() Badge { return StatusBadge(v.Status) }
func (v BillingAccountViewModel) SyncBadge() Badge { return StatusBadge(v.StripeSyncStatus) }
// EnvBadge marks a row recorded in the Stripe environment the API key is
// not in, rendered only while a billing view is showing both
// (stripe-environment-stamp D7). EnvState is "" on every other row, and
// the templates render nothing for it.
func (v InvoiceViewModel) EnvBadge() Badge { return StatusBadge(v.EnvState) }
func (v SubscriptionViewModel) EnvBadge() Badge { return StatusBadge(v.EnvState) }
func (v PaymentViewModel) EnvBadge() Badge { return StatusBadge(v.EnvState) }
func (v BillingAccountViewModel) EnvBadge() Badge { return StatusBadge(v.EnvState) }
// billingEmpty is the four views' shared empty state: blocked while
// Stripe is not configured; otherwise the plain absence, with a note
// naming the mechanism that would fill it (ACC-1: the affirmative-denial
// shape empty-state-guidance retires — a quiet webhook-projected surface
// must say why it's quiet, not just that it is).
func billingEmpty(stripeConfigured bool, headline, note string) EmptyStateParams {
if !stripeConfigured {
return EmptyStateParams{Blocked: true, BlockerCopy: "Billing data is projected from Stripe, which is not configured for this deployment.", PrerequisiteURL: "/operator/integrations/stripe/settings", PrerequisiteLabel: "Configure Stripe"}
}
return EmptyStateParams{Headline: headline, Note: note}
}
func (d BillingAccountsData) Empty() EmptyStateParams {
return billingEmpty(d.StripeConfigured, "No billing accounts yet.", "Billing accounts appear here as Stripe issues invoices or subscriptions for an organization.")
}
func (d SubscriptionsData) Empty() EmptyStateParams {
return billingEmpty(d.StripeConfigured, "No subscriptions yet.", "Subscriptions appear here as Stripe reports them.")
}
func (d InvoicesData) Empty() EmptyStateParams {
return billingEmpty(d.StripeConfigured, "No invoices yet.", "Invoices appear here as Stripe issues them.")
}
func (d PaymentsData) Empty() EmptyStateParams {
return billingEmpty(d.StripeConfigured, "No payments yet.", "Payments appear here as Stripe processes them.")
}
// Group 6 of the sweep (Products list, detail, edit, prices, readiness):
// the pages' values for the parts.
// Header carries the count; the sibling catalog surfaces ride the
// toolbar row below (two nav actions cannot share the one slot).
func (d OperatorProductsData) Header() PageHeader {
h := PageHeader{Title: "Products"}
if d.Nav.Total > 0 {
h.Count = countLabel(int(d.Nav.Total), "product", "products")
}
return h
}
func (OperatorProductsData) CatalogOverviewHeader() SectionHeader {
return SectionHeader{Title: "Catalog overview"}
}
// TotalReadout, PublishedReadout, DraftReadout, RetiredReadout,
// PublishedTiersReadout and PublishedOffLadderReadout are the Catalog
// overview's six counts for the readout part, the same idiom and the same
// size as the landing surface's tiles and the Stripe delivery queue
// (overview-consistency D3, round 2: the strip rendered its value at fs-4
// above a muted label, "I noticed that that is fs-4 instead of fs-3").
// Every count is computed in Go from data already loaded for the list, so
// none can fail to load or need an operator's attention.
func (s ProductCatalogSummary) TotalReadout() Readout {
return Readout{Label: "Total products", Value: strconv.Itoa(s.Total), Available: true}
}
func (s ProductCatalogSummary) PublishedReadout() Readout {
return Readout{Label: "Published", Value: strconv.Itoa(s.Published), Available: true}
}
func (s ProductCatalogSummary) DraftReadout() Readout {
return Readout{Label: "Draft", Value: strconv.Itoa(s.Draft), Available: true}
}
func (s ProductCatalogSummary) RetiredReadout() Readout {
return Readout{Label: "Retired", Value: strconv.Itoa(s.Retired), Available: true}
}
func (s ProductCatalogSummary) PublishedTiersReadout() Readout {
return Readout{Label: "Published, ladder tiers", Value: strconv.Itoa(s.PublishedTiers), Available: true}
}
func (s ProductCatalogSummary) PublishedOffLadderReadout() Readout {
return Readout{Label: "Published, off-ladder", Value: strconv.Itoa(s.PublishedOffLadder), Available: true}
}
// ListHeader titles the list section itself; its action is the list page's
// one filled call to action, a link to the create page (design D20, round
// 4: "record creation gets its own page"), replacing the create panel the
// header used to open.
func (d OperatorProductsData) ListHeader() SectionHeader {
return SectionHeader{Title: "Products", Action: &Link{Label: "New product", URL: "/operator/products/new", Filled: true}}
}
// CategoryBadge renders the presentation-only display category as a
// neutral kind; a deployment-defined category outside the map renders
// raw in the same light tone.
func (p OperatorProductViewModel) CategoryBadge() Badge {
if b, ok := badgeMap[p.DisplayCategory]; ok {
return b
}
return Badge{Label: p.DisplayCategory, Tone: "light"}
}
// StatusBadge renders the products list's Status cell (product-management
// "The Status column is the verdict", design D1): the readiness verdict for
// a published product (purchasable, ready_to_grant, incomplete), or the
// lifecycle word itself for draft/retired, which pre-empts the verdict.
// VerdictState is filled at load time by productListStatus.
func (p OperatorProductViewModel) StatusBadge() Badge {
return StatusBadge(p.VerdictState)
}
// Header titles the composite by the product's own name below the
// Products trail.
func (d ProductDetailData) Header() PageHeader {
return PageHeader{Title: d.Edit.Product.Name, Crumbs: []Link{{Label: "Products", URL: "/operator/products"}}}
}
func (d OperatorProductEditData) DetailsHeader() SectionHeader {
return SectionHeader{Title: "Details", Key: d.Product.Key}
}
func (d OperatorProductEditData) LaddersHeader() SectionHeader {
h := SectionHeader{Title: "Plan ladders"}
if len(d.LadderMemberships) > 0 {
h.Count = countLabel(len(d.LadderMemberships), "ladder", "ladders")
}
return h
}
func (OperatorProductEditData) ReadinessHeader() SectionHeader {
return SectionHeader{Title: "Purchasability"}
}
// PricesHeader carries the count and the "Add price" opener of the price
// panel above the table. Sub-record creation stays inline behind a tertiary
// opener (design D20, round 4), so this is a Toggle, not a link to a page
// of its own; "Add", not "Create", because the verb names what it does to
// the record on screen, matching Add tier and Add rule. Expanded when a
// create submission re-renders the page with field errors.
func (d ProductPricesData) PricesHeader() SectionHeader {
h := SectionHeader{Title: "Prices", Action: &Link{Label: "Add price", Toggle: "createPricePanel", Expanded: d.PriceForm.HasErrors()}}
if len(d.Prices) > 0 {
h.Count = countLabel(len(d.Prices), "price", "prices")
}
return h
}
func (ProductPricesData) Empty() EmptyStateParams {
return EmptyStateParams{Headline: "No prices yet."}
}
func (p PriceViewModel) DefaultBadge() Badge { return StatusBadge("default") }
func (p PriceViewModel) TypeBadge() Badge {
if p.IsRecurring {
return StatusBadge("recurring")
}
return StatusBadge("one_time")
}
func (p PriceViewModel) StateBadge() Badge {
if p.IsActive {
return StatusBadge("active")
}
return StatusBadge("inactive")
}
func (p PriceViewModel) SyncBadge() Badge { return StatusBadge(p.StripeSyncStatus) }
// StateBadge maps the readiness row's check state through the badge map;
// the annotation states (shown, hidden, n/a) render as text in the
// template per design D3.
func (r ProductReadinessRow) StateBadge() Badge {
switch r.State {
case "pending":
return StatusBadge("sync_pending")
case "failed":
return StatusBadge("sync_failed")
case "unmet":
return StatusBadge("missing")
}
return StatusBadge(r.State)
}
// CancelingBadge is the subscription's exception marker (design D8): an
// active subscription flagged to cancel at the period end.
func (SubscriptionViewModel) CancelingBadge() Badge {
return StatusBadge("canceling").WithTitle("Cancels at the period end and will not renew.")
}
// Group 7 of the sweep (Entitlement sets list, detail, edit, rules): the
// pages' values for the parts.
// Header carries the always-on explanation as the lead (ux-first-run 3.4:
// it survives past the empty state) below the Products trail.
func (d EntitlementSetsData) Header() PageHeader {
h := PageHeader{Title: "Entitlement sets", Lead: "An entitlement set bundles resource rules, each a limit or a switch for one resource, and defines what a product provides.", Crumbs: []Link{{Label: "Products", URL: "/operator/products"}}}
if len(d.EntitlementSets) > 0 {
h.Count = countLabel(len(d.EntitlementSets), "set", "sets")
}
return h
}
// ListHeader titles the list section itself; its action is the list page's
// one filled call to action, a link to the create page (design D20, round
// 4: "record creation gets its own page").
func (d EntitlementSetsData) ListHeader() SectionHeader {
return SectionHeader{Title: "Entitlement sets", Action: &Link{Label: "New entitlement set", URL: "/operator/entitlement-sets/new", Filled: true}}
}
func (d EntitlementSetDetailData) Header() PageHeader {
return PageHeader{Title: d.Edit.EntitlementSet.Name, Crumbs: []Link{{Label: "Products", URL: "/operator/products"}, {Label: "Entitlement sets", URL: "/operator/entitlement-sets"}}}
}
func (d EntitlementSetEditData) DetailsHeader() SectionHeader {
return SectionHeader{Title: "Details", Key: d.EntitlementSet.Key}
}
// RulesHeader carries the count and the "Add rule" action, which appends an
// editing row to the table (design D8 of staged-rule-changes): the opener
// sits outside the form and posts the form by id, so the row it opens
// arrives with every open editor and the whole staged batch intact.
func (d EntitlementSetRulesData) RulesHeader() SectionHeader {
h := SectionHeader{Title: "Rules", Action: &Link{
Label: "Add rule",
Post: d.RuleActionURL(),
Vals: `{"act":"add"}`,
Include: entitlementSetRulesFormSelector(),
Target: "#entitlement-set-rules",
}}
if len(d.Rules) > 0 {
h.Count = countLabel(len(d.Rules), "rule", "rules")
}
return h
}
func (EntitlementSetRulesData) NoRules() EmptyStateParams {
return EmptyStateParams{Headline: "No rules defined for this set."}
}
// HistoryHeader titles the set's ledger of committed rule changes. No help
// icon: newest first is visible from the When column and the heading
// carries the rest (design.md "The deletion test").
func (EntitlementSetHistoryData) HistoryHeader() SectionHeader {
return SectionHeader{Title: "History"}
}
// NoHistory is the ledger's true-empty state. The note answers why a mature
// set's History is empty, which a reader cannot infer from an empty table.
func (EntitlementSetHistoryData) NoHistory() EmptyStateParams {
return EmptyStateParams{
Headline: "No rule changes recorded yet.",
Note: "Rules added before this ledger existed carry no entry.",
}
}
func (r RuleViewModel) StateBadge() Badge {
if r.IsActive {
return StatusBadge("active")
}
return StatusBadge("inactive")
}
// StateBadge renders the set's is_active flag as a badge (entitlement-set-
// management "The set's flag is called Active"): the list column and the
// flag itself are both named "Active", never "In pickers".
func (v EntitlementSetViewModel) StateBadge() Badge {
if v.IsActive {
return StatusBadge("active")
}
return StatusBadge("inactive")
}
// ---- Sweep group 8: Plan ladders ----
func (d PlanLaddersData) Header() PageHeader {
h := PageHeader{
Title: "Plan ladders",
Lead: "Ordered tiers of plan products; an organization holds one active position per ladder, and rank 0 is the base tier new organizations start on.",
Crumbs: []Link{{Label: "Products", URL: "/operator/products"}},
}
if len(d.Ladders) > 0 {
h.Count = countLabel(len(d.Ladders), "ladder", "ladders")
}
return h
}
// TopologyHeader titles the map-first topology section. No action (design
// D8 / plan-ladder-management: the structural-validation page it once
// pointed at was retired in acceptance-fixes round 2 — the one check it ran
// is impossible under the database's exclusion constraint).
func (PlanLaddersData) TopologyHeader() SectionHeader {
return SectionHeader{Title: "Plan topology"}
}
func (PlanLaddersData) Empty() EmptyStateParams {
return EmptyStateParams{
Headline: "No plan ladders yet.",
Note: "A plan ladder can be created before any product exists; its tiers get attached to a product once one is ready.",
}
}
func (PlanTopologyData) OffLadderHeader() SectionHeader {
return SectionHeader{Title: "Off-ladder products", Help: "Products that confer without occupying a plan position. Legitimate catalog shape: the member catalog's non-plan section is exactly these."}
}
func (PlanTopologyData) SharedHeader() SectionHeader {
return SectionHeader{Title: "Shared products", Help: "Products that are a tier in more than one ladder."}
}
func (PlanTopologyData) OrgTypesHeader() SectionHeader {
return SectionHeader{Title: "What new organizations get"}
}
func (c TopologyCell) SharedBadge() Badge {
return StatusBadge("shared").WithTitle("Tier in more than one ladder.")
}
func (c TopologyCell) LifecycleBadge() Badge {
return StatusBadge(c.LifecycleStatus)
}
func (a TopologyAddonViewModel) CategoryBadge() Badge {
if b, ok := badgeMap[a.Category]; ok {
return b
}
return Badge{Label: a.Category, Tone: "light"}
}
func (a TopologyAddonViewModel) LifecycleBadge() Badge {
return StatusBadge(a.LifecycleStatus)
}
func (d PlanLadderDetailData) Header() PageHeader {
return PageHeader{Title: d.Edit.Ladder.Name, Crumbs: []Link{{Label: "Products", URL: "/operator/products"}, {Label: "Plan ladders", URL: "/operator/plan-ladders"}}}
}
func (d PlanLadderEditData) DetailsHeader() SectionHeader {
return SectionHeader{Title: "Details", Key: d.Ladder.Key}
}
// AddTierHeader opens the collapsed "Add tier" panel (design D20 "Create
// flows: a closed panel, then land on the record"); a re-render carrying
// the add-tier form's field errors reopens it.
// TiersHeader titles the tiers section and carries its one action, the
// opener of the collapsed Add tier panel (design D20): the panel sits under
// this header, so no separate "Add tier" section header exists. The tier
// count therefore rides in the title's text through the empty state and the
// table, never in the right-hand slot, which the action occupies.
func (d PlanLadderTiersData) TiersHeader() SectionHeader {
return SectionHeader{
Title: "Tiers",
Action: &Link{Label: "Add tier", Toggle: "tierAddPanel", Expanded: d.TierForm.HasErrors()},
}
}
func (PlanLadderTiersData) NoTiers() EmptyStateParams {
return EmptyStateParams{Headline: "No tiers in this ladder."}
}
// RankBadge is the tier row's rank chip: an ordinal in the neutral light
// style, semantically closer to an identifier than a state but rendered
// through the one badge part so tone and contrast stay centralized.
func (t PlanLadderTierViewModel) RankBadge() Badge {
return Badge{Label: fmt.Sprintf("%d", t.Rank), Tone: "light"}
}
// PendingRankBadge marks every rank chip while a dropped-but-uncommitted
// reorder is on screen: the numbers shown are the pending ones.
func (t PlanLadderTierViewModel) PendingRankBadge() Badge {
return Badge{Label: fmt.Sprintf("%d", t.Rank), Tone: "warning", Title: "Pending rank; nothing is saved until you commit."}
}
// DefaultBadges annotates the rank-0 row with one chip per org type whose
// configured default plan is this ladder (empty when it is no type's
// default, so rank 0 is only the ladder's base tier).
func (d PlanLadderTiersData) DefaultBadges() []Badge {
badges := make([]Badge, 0, len(d.DefaultForOrgTypeNames))
for _, name := range d.DefaultForOrgTypeNames {
badges = append(badges, Badge{
Label: name + " default",
Tone: "light",
Title: "New " + name + " organizations start on this tier",
})
}
return badges
}
// ---- Sweep group 9: Integrations ----
func (d IntegrationSettingsData) Header() PageHeader {
h := PageHeader{
Title: d.DisplayName + " settings",
Lead: "Each setting uses its override if one is stored, otherwise the environment value, otherwise its default.",
Crumbs: []Link{{Label: "Integrations", URL: "/operator/integrations"}},
}
// A provider with a page of its own sits between Integrations and its
// settings in the trail ("Operator / Integrations / Stripe / Stripe
// settings"; design D24), which is also the way back to that page, so
// the header carries no "Admin page" action.
if d.SurfacePath != "" {
h.Crumbs = append(h.Crumbs, Link{Label: d.DisplayName, URL: d.SurfacePath})
}
return h
}
// SourceBadge names the layer a key's effective value came from. All three
// sources are neutral facts (light tone, design D3); a secret key always
// resolves through the environment and never stores an override, which the
// tooltip carries.
func (r SettingRow) SourceBadge() Badge {
if r.Secret {
return StatusBadge("environment").WithTitle("Secret keys are managed via the environment and never stored as overrides.")
}
return StatusBadge(r.Source)
}
func (r SettingRow) PendingBadge() Badge {
return StatusBadge("pending_restart").WithTitle("A stored change differs from the running value; it applies on the next restart.")
}
func (r SettingRow) NotSetBadge() Badge {
return StatusBadge("not_set")
}
// Header is the Stripe provider page's header (design D24): the trail
// reads "Operator / Integrations / Stripe", and the header's action links
// forward to the settings page (non-primary, so the pageHeader part
// renders it outline-secondary — navigation, not an opener). The lead
// comes from the provider manifest's declared Description when it carries
// one, otherwise a generic sentence naming the page's job.
func (d StripeIntegrationData) Header() PageHeader {
lead := "Stripe's configuration status, mode, and queued delivery work."
if d.Description != "" {
lead = d.Description
}
return PageHeader{
Title: "Stripe",
Lead: lead,
Crumbs: []Link{{Label: "Integrations", URL: "/operator/integrations"}},
Action: &Link{Label: "Stripe settings", URL: "/operator/integrations/stripe/settings"},
}
}
func (StripeIntegrationData) DetailsHeader() SectionHeader {
return SectionHeader{Title: "Details"}
}
// StatusBadge renders Stripe's configuration state, from the same
// required-key resolution the settings page and the Integrations table
// use (configurationReadiness), so the three surfaces can never disagree.
func (d StripeIntegrationData) StatusBadge() Badge {
if d.Configured {
return StatusBadge("configured")
}
b := StatusBadge("not_configured")
if len(d.Missing) > 0 {
b = b.WithTitle("Missing: " + strings.Join(d.Missing, ", "))
}
return b
}
// PendingReadout, RetryingReadout and DeadLetterReadout are the delivery
// queue's three counts for the readout part, the same idiom and the same
// size as the landing surface's tiles (overview-consistency D3). Only
// dead-lettered work takes the attention tone: pending and retrying are
// normal draining states. The strip renders inside the Available branch,
// so each count states itself as available.
func (q DeliveryQueue) PendingReadout() Readout {
return Readout{Label: "Pending", Value: strconv.FormatInt(q.Pending, 10), Available: true}
}
func (q DeliveryQueue) RetryingReadout() Readout {
return Readout{Label: "Retrying", Value: strconv.FormatInt(q.Retrying, 10), Available: true}
}
func (q DeliveryQueue) DeadLetterReadout() Readout {
return Readout{Label: "Dead-letter", Value: strconv.FormatInt(q.DeadLetter, 10), Available: true, Attention: q.NeedsAttention()}
}
// ---- Sweep group 10: the member surface ----
// Member pages are titled by pageHeader. The dashboard is the member
// surface's root (with OperatorPageData.LandingHeader, one of exactly two
// pages that carry no trail); Products and Billing are rail-entry pages and
// carry the trail rooted at the surface (design D18).
func (IndexPageData) Header() PageHeader {
// Workspaces are deliberately absent from the lead: the feature is not
// visible yet (maintainer, 2026-08-31).
return PageHeader{Title: "Dashboard", Lead: "Your account and services.", NoTrail: true}
}
func (IndexPageData) AccountHeader() SectionHeader {
return SectionHeader{Title: "Account"}
}
func (IndexPageData) WorkspacesHeader() SectionHeader {
return SectionHeader{Title: "Workspaces"}
}
// Empty is the dashboard's one empty state (member-dashboard ADDED
// requirement "The dashboard has one empty state"; design D25), rendered
// in place of the cards section when IsEmpty (server.go) reports neither a
// declared card nor an org entitlement.
func (IndexPageData) Empty() EmptyStateParams {
return EmptyStateParams{
Headline: "Nothing to show yet.",
Note: "Your services and entitlements appear here once your organization holds a plan or a grant.",
}
}
func (c DashboardCardView) Header() SectionHeader {
return SectionHeader{Title: c.Title}
}
func (ProductsPageData) Header() PageHeader {
return PageHeader{Title: "Products", Lead: "Your entitlements, and the plans and products available to your organization."}
}
func (ProductsPageData) EntitlementsHeader() SectionHeader {
return SectionHeader{Title: "Your entitlements"}
}
func (ProductsPageData) PlansHeader() SectionHeader {
return SectionHeader{Title: "Plans"}
}
func (BillingPageData) Header() PageHeader {
return PageHeader{Title: "Billing", Lead: "Your invoice and payment history."}
}
// Member invoices region (swapped into #billing-content, so the region
// titles itself and the title survives the list/detail swaps as part of
// each partial).
func (d MemberInvoicesData) Header() SectionHeader {
h := SectionHeader{Title: "Invoices"}
if len(d.Invoices) > 0 {
h.Count = countLabel(len(d.Invoices), "invoice", "invoices")
}
return h
}
func (MemberInvoicesData) Empty() EmptyStateParams {
return EmptyStateParams{
Headline: "Nothing to show here yet.",
Note: "Invoices appear here as they are issued.",
}
}
func (v InvoiceListItemViewModel) StatusBadge() Badge {
return StatusBadge(v.StatusState)
}
func (d MemberInvoiceDetailData) StatusBadge() Badge {
return StatusBadge(d.StatusState)
}
// Header titles the detail view. The detail replaces the Invoices section
// inside #billing-content, so its title is that section's title
// (overview-consistency D9). An invoice with no number yet is titled plain
// "Invoice", with the help icon carrying when the number arrives.
func (d MemberInvoiceDetailData) Header() SectionHeader {
if d.InvoiceNumber == "" {
return SectionHeader{Title: "Invoice", Help: "Assigned when the invoice is issued."}
}
return SectionHeader{Title: "Invoice " + d.InvoiceNumber}
}
func (MemberInvoiceDetailData) LineItemsHeader() SectionHeader {
return SectionHeader{Title: "Line items"}
}
func (d MemberInvoiceDetailData) PaymentsHeader() SectionHeader {
h := SectionHeader{Title: "Payments"}
if len(d.Payments) > 0 {
h.Count = countLabel(len(d.Payments), "payment", "payments")
}
return h
}
func (p MemberPaymentViewModel) StatusBadge() Badge {
return StatusBadge(p.Status)
}
// Member products regions.
func (EntitlementsData) Empty() EmptyStateParams {
return EmptyStateParams{Headline: "No active entitlements."}
}
func (p PoolTierViewModel) PoolBadge() Badge {
return Badge{Label: p.PoolName, Tone: "light"}
}
func (BooleanEntitlementViewModel) IncludedBadge() Badge {
return StatusBadge("included")
}
func (s EntitlementSourceViewModel) ReasonBadge() Badge {
return StatusBadge(s.Reason)
}
func (PlansData) Empty() EmptyStateParams {
return EmptyStateParams{Headline: "No plans available."}
}
// CurrentBadge marks the member's own rung. The card copy says "Current
// plan" (the workspace list's chip says "Current"; both are the same
// success tone, and the longer label is the member-facing phrase the
// catalog established).
func (t TierViewModel) CurrentBadge() Badge {
return Badge{Label: "Current plan", Tone: "success"}
}
// CancelBadge marks a ladder whose subscription cancels at period end; the
// member-facing chip carries the date itself (unlike the operator's
// Canceling badge, the date is the fact a member needs).
func (l LadderViewModel) CancelBadge() Badge {
return Badge{Label: "Cancels on " + l.CancelsOn, Tone: "warning", Title: "The plan does not renew; access continues until this date."}
}
func (d AddonsData) Header() SectionHeader {
if d.HasPlans {
return SectionHeader{Title: "More products"}
}
return SectionHeader{Title: "Products"}
}
func (a AddonViewModel) CategoryBadge() Badge {
if b, ok := badgeMap[a.DisplayCategory]; ok {
return b
}
return Badge{Label: a.DisplayCategory, Tone: "light"}
}
// Workspaces region (titled by the dashboard page; the partial renders
// content only, so the title survives the list/create-form swaps).
func (WorkspaceListData) Empty() EmptyStateParams {
return EmptyStateParams{Headline: "No workspaces yet."}
}
func (w WorkspaceViewModel) StatusBadge() Badge {
return StatusBadge(w.Status)
}
func (WorkspaceViewModel) CurrentBadge() Badge {
return StatusBadge("current")
}
// Member domains region (sweep group 10).
func (MemberDomainsData) Empty() EmptyStateParams {
return EmptyStateParams{Headline: "No domains yet."}
}
// StateBadge maps a DNS probe state onto the shared badge map's
// verification states.
func (r MemberDomainRecordView) StateBadge() Badge {
switch r.State {
case "match":
return StatusBadge("found")
case "mismatch":
return StatusBadge("found_mismatch")
case "missing":
return StatusBadge("not_found")
case "error":
return StatusBadge("check_failed").WithTitle("We couldn't complete the DNS lookup; we'll keep trying.")
default:
return StatusBadge("not_checked")
}
}
// RecordKindBadge is the record-type chip (TXT / CNAME): an identifier in
// the neutral light style.
func (r MemberDomainRecordView) RecordKindBadge() Badge {
return Badge{Label: r.Kind, Tone: "light"}
}