1519 lines
62 KiB
Go
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"}
|
|
}
|