Files
member-console/internal/embeds/templates/operator.html
T
cgalo5758 3283df19b1 Move account menu into mobile rail drawer
Below lg the top bar now holds the brand and one toggler; the account
items render in the drawer as a second, labelled list from a shared
partial, so both surfaces cannot drift. Desktop unchanged.
2026-09-09 22:57:26 -05:00

401 lines
24 KiB
HTML

{{- /* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial */ -}}
{{- /* SPDX-FileCopyrightText: 2025-2026 Christian Galo */ -}}
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Deployment identity: the configured deployment-name (default
"Member Console") renders identically here and on the member
mastheads, so the console tells one story about who it is. The
default is itself brand-neutral, which keeps a stranger's first
screenshot of this general-purpose OSS project deployment-neutral
too. -->
<!-- The title names the page (server.pageTitle reads the body's own
page header), then the deployment: "New product - Member Console". -->
<title>{{ pageTitle . }} - {{ deploymentName }}</title>
<meta name="description" content="Operator console for membership, entitlement and billing administration">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="ia-position" content="{{ .IAPosition }}">
<meta name="htmx-config" content='{"includeIndicatorCSS": false, "noSwap": [204, 304, 403, "5xx"], "defaultSettleDelay": 0}'>
<link href="/static/bootstrap.css" rel="stylesheet">
<script defer src="/static/bootstrap.bundle.js"></script>
<script defer src="/static/shell.js"></script>
<script defer src="/static/htmx.min.js"></script>
<script defer src="/static/error-handler.js"></script>
<script defer src="/static/success-toast.js"></script>
<script defer src="/static/grant-valid-until-tz.js"></script>
<script defer src="/static/confirm-action-modal.js"></script>
<script defer src="/static/sortable.min.js"></script>
<script defer src="/static/sortable-reorder.js"></script>
<script defer src="/static/help-init.js"></script>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="manifest" href="/static/site.webmanifest">
<link href="/static/app.css" rel="stylesheet">
</head>
<body class="d-flex flex-column vh-100"
hx-boost:inherited="true">
{{ template "shell_topbar.html" .Shell }}
<div class="d-flex flex-column flex-lg-row flex-grow-1">
<!-- Operator IA sidebar — one flat list of eight top-level task
entries, nothing else (maintainer 2026-08-23: no children, no
groups; the sidebar names the sections, the sections navigate
themselves; People joined as the eighth entry with the
directory, ux-operator-scale). Order: Overview, People,
Organizations, Grants, Billing, Products, Domains,
Integrations. Second-level surfaces are reached in-page:
person detail from the People directory's rows, Org Types from
the Organizations page header, the four billing views from the
Billing wrapper's tab row, Entitlement Sets and Plan Ladders
from the Products page header, and provider surfaces from the
Integrations home's provider cards. Their IA-position
declarations still carry the Catalog conceptual group
(docs/operator-ia.md). A second-level page marks its section's
entry active. Every entry points at a real
/operator/<capability> route under hx-boost. The eight are
followed by the surface switch, "Member panel", the only
separated non-section entry the rail may carry (unify-shell).
The rail is the operator surface's only navigation: a static
15rem rail at lg and up, a Bootstrap offcanvas drawer below,
opened from the top bar's toggler (partials/shell_topbar.html)
and closed by shell.js when an entry is chosen. Below lg that
drawer is the shell's one menu, so a second, labelled list
follows this one at the drawer's foot, from the shared
partials/shell_rail_account.html (mobile-shell-menu D1, D8);
at lg and up it is hidden and the bar carries the account menu
itself. The entry list's padding lives on the offcanvas body,
so both lists sit at one inset. The top bar carries the
brand and one control, and no section links or surface label —
the trail's root crumb states the surface instead (design D18)
(docs/design-system.md, "Application shell"). -->
<aside id="app-rail" class="app-sidebar operator-ia-sidebar offcanvas-lg offcanvas-start bg-light border-end flex-shrink-0" tabindex="-1" aria-labelledby="app-rail-label">
<div class="offcanvas-header">
<span class="offcanvas-title" id="app-rail-label">{{ deploymentName }}</span>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#app-rail" aria-label="Close menu"></button>
</div>
<div class="offcanvas-body p-3">
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "" }}active{{ end }}" href="/operator">Overview</a></li>
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "persons" }}active{{ end }}" href="/operator/persons">People</a></li>
<li class="nav-item"><a class="nav-link {{ if or (eq .ActiveCapability "organizations") (eq .ActiveCapability "org-types") }}active{{ end }}" href="/operator/organizations">Organizations</a></li>
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "grants" }}active{{ end }}" href="/operator/grants">Grants</a></li>
<li class="nav-item"><a class="nav-link {{ if or (eq .ActiveCapability "billing-accounts") (eq .ActiveCapability "billing-subscriptions") (eq .ActiveCapability "billing-invoices") (eq .ActiveCapability "billing-payments") }}active{{ end }}" href="/operator/billing/accounts">Billing</a></li>
<li class="nav-item"><a class="nav-link {{ if or (eq .ActiveCapability "products") (eq .ActiveCapability "entitlement-sets") (eq .ActiveCapability "plan-ladders") }}active{{ end }}" href="/operator/products">Products</a></li>
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "domains" }}active{{ end }}" href="/operator/domains">Domains</a></li>
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "integrations" }}active{{ end }}" href="/operator/integrations">Integrations</a></li>
<!-- The surface switch: the rail's one separated non-section
entry, never active (chrome-conventions "The surface
switch is a visible, mirrored rail entry"; the member
rail mirrors it with "Operator panel"; design D25). -->
<li class="nav-item app-rail-switch"><a class="nav-link" href="/">Member panel</a></li>
</ul>
{{ template "shell_rail_account.html" .Shell }}
</div>
</aside>
<!-- Main content — single swap target for hx-boost navigations. The
toast container and confirm modal are siblings (outside this region)
so they survive boosts per docs/design-system.md §3. The inner
container-xxl caps content width at ~1320px so wide screens don't
stretch text to the viewport edge. -->
<main id="operator-main" class="flex-fill p-4">
<div class="container-xxl px-0">
<!-- #operator-body is the HTMX swap target for every mutation
form in the operator panel. Forms hx-post to /partials/...
endpoints that respond with the bare page partial; HTMX
swaps that fragment into #operator-body, preserving the
#operator-main + container-xxl wrappers. See
docs/operator-ux-conventions.md §2 (HTMX request shape). -->
<div id="operator-body">
{{ if .BodyTemplate }}
<!-- MPA page handler set a body partial; dispatch via the
renderBody template func (html/template's built-in template
action requires a string literal, not a dynamic name). -->
{{ renderBody .BodyTemplate .BodyData }}
{{ else }}
<!-- ============================================================
Curated landing surface, the operator's entry point.
Answers "what is this deployment doing right now?"
attention-first: the setup banner (while it applies), then
search, then the counts an operator checks daily, then
system status, then the event timeline (maintainer
2026-08-23: numbers outrank the system cards).
Attention-shaped content (an unconfigured integration, a
stuck delivery queue) is never the last region on the page.
Heading contract: the page writes no heading of its own.
The title comes from pageHeader, each region's title from
sectionHeader (overview-consistency D1), so the outline is
one H1 and one H2 per region and the lint rule
raw-section-title holds it that way.
Per operator-composite-expansion D4, "Recent activity" is
one unified timeline (grants + transitions + invoices +
payments interleaved by timestamp) rather than two
domain-partitioned sections. Override the default 20-row
limit with ?limit=N (clamped to [1,100]). Per task 7.4,
each entry links to its own subject's operator surface
where one exists.
============================================================ -->
{{ template "pageHeader" .LandingHeader }}
<!-- Setup banner (design D12/D28). The blue Bootstrap info alert;
the maintainer's pick over a plain card on 2026-09-03,
"I back the blue", keeping round 4's copy, which names no
step count, matching the wording of the Getting started
idiom. Renders above the lookup while the dismissal has not
been recorded and at least one step is still incomplete;
once every step derives complete the banner withdraws on
its own, dismissed or not (SetupState.ShowBanner). The
close button posts to the dismiss route and swaps the
banner out with an empty response; dismissal is
deployment-wide state (core.instance_settings), not a
cookie, so no operator sees it again on any browser while
the setting stands. CSRF rides the body's
hx-headers:inherited, the same path every other operator
HTMX control on this page uses. -->
{{ if .Setup.ShowBanner }}
<div id="setup-banner" class="alert alert-info d-flex align-items-center justify-content-between gap-3" role="status">
<span><strong>Getting started.</strong> Finish setting up {{ deploymentName }}. <a href="/operator/setup" class="alert-link">Continue setup</a></span>
<button type="button" class="btn-close" aria-label="Dismiss the getting-started banner" hx-post="/partials/operator/setup/dismiss" hx-target="#setup-banner" hx-swap="outerHTML"></button>
</div>
{{ end }}
<!-- Region 1, Lookup. Single typed input resolves to person
detail (email-exact or name-substring), org composite
(name substring, persons resolve first, one match), a
disambiguation list (multi-person or multi-org), or a
no-match notice. A declared search form (form-library, spec
operator-panel-navigation "The landing surface's lookup is
a search form"): a native GET with a true no-JS fallback,
so without JavaScript the lookup navigates and resolves the
same way. Unique matches redirect (HX-Redirect for the
scripted path, an ordinary 303 for the native one);
multi/no-match swap the result partial into #lookup-result.
The region carries no heading at all: the field's own
hidden label, its placeholder and the Look up button
already carry the purpose, and the section names itself to
screen readers with aria-label (overview-consistency D1;
the form part renders the role="search" landmark). -->
<section aria-label="Lookup">
{{/* The placeholder says what to type, not the record
classes it can find (design D4): people by name or
email, organizations by name, an invoice by its exact
number, and any keyed record (org, product, set,
ladder) by its exact key. The no-match sentence (below,
in operator_lookup_result.html) is what names the
classes, generated from the resolver registry so it
can never disagree with the code. */}}
{{ template "form" .LookupForm }}
<div id="lookup-result">
{{ template "operator_lookup_result.html" . }}
</div>
</section>
<!-- Region 2, At a glance. Three daily-read tiles (People, Team
organizations, Monthly recurring; design D3), each one a
readout part in a linked card, so this row doubles as the
primary navigation for operators who arrived here without a
specific destination in mind. The card is the whole link,
marked by the chevron glyph at its trailing edge and the
tertiary background on hover (app.css) (page-anatomy "A
linked card is marked by a glyph and the row's hover";
overview-consistency D3, round 2); the label is the
readout's own label, never a link, and nothing else inside
the card is a link. A tile with no browse surface (no
.Href) carries no chevron. A tile whose count could not be
loaded renders the em dash marker, never a zero; the part
decides that, not this template. -->
<section aria-label="At a glance">
{{ template "sectionHeader" .GlanceHeader }}
{{ if .Overview.Stats }}
<div class="row row-cols-1 row-cols-sm-2 row-cols-xl-3 g-3 mb-3">
{{ range .Overview.Stats }}
<div class="col">
{{ if .Href }}
<a class="card h-100 text-decoration-none text-body" href="{{ .Href }}">
<div class="card-body d-flex align-items-center justify-content-between gap-3">{{ template "readout" .Readout }}{{ template "chevronIcon" }}</div>
</a>
{{ else }}
<div class="card h-100">
<div class="card-body">{{ template "readout" .Readout }}</div>
</div>
{{ end }}
</div>
{{ end }}
</div>
{{ else }}
<div class="card mb-3">
<div class="card-body">{{ template "emptyState" .CountsUnavailable }}</div>
</div>
{{ end }}
</section>
<!-- Region 3, System status (design D3). Every registered
provider in registry order, each row its name link and
single Status (the SAME configurationReadiness signal and
registry lifecycle the Integrations list reads, reused
through IntegrationRow, never duplicated). Stripe's row
alone adds its mode and outbox summary; never a dead-letter
table or operation identifiers, which live on the Stripe
provider page (design D24). The card carries no title of
its own: a card title names an instance, and "Integrations"
is a category (overview-consistency D2), so the providers
sit in the card body as a flush list, the idiom the setup
checklist's steps use. The section header's one action,
"Setup", always links to /operator/setup (design D3, D12);
navigation, so outline-secondary (design D19). Renders
after the metric tiles (maintainer 2026-08-23: the counts
are what an operator checks first) but ahead of the
activity feed, so attention-shaped content is still never
the last region on the page. -->
<section aria-label="System">
{{ template "sectionHeader" .SystemHeader }}
<div class="card mb-3">
<div class="card-body">
{{ if .Overview.Integrations.Rows }}
<ul class="list-group list-group-flush">
{{ range .Overview.Integrations.Rows }}
<li class="list-group-item px-0 py-2">
<div class="d-flex flex-wrap align-items-center gap-2">
{{ if .LinkPath }}<a href="{{ .LinkPath }}">{{ .DisplayName }}</a>{{ else }}{{ .DisplayName }}{{ end }}
{{ template "statusBadge" .StatusBadge }}
{{ with .StripeFacts }}
<span class="text-body-secondary small">{{ .ModeLabel }}</span>
<span class="small {{ if .NeedsAttention }}text-danger{{ else }}text-body-secondary{{ end }}">
{{ if .Available }}{{ .Queued }} queued, {{ .Attention }} need attention{{ else }}Queue health is unavailable.{{ end }}
</span>
{{ end }}
</div>
</li>
{{ end }}
</ul>
{{ else }}
<p class="text-body-secondary small mb-0">No integrations registered.</p>
{{ end }}
</div>
</div>
</section>
<!-- Region 4, Recent activity. A record table like every other
(overview-consistency D5): a plain header row and body-size
cells, the Summary the row's primary cell (th scope="row")
and the table's first column, as every record table's
primary cell is; When is the last column. Each entry's
Organization column always names and links the org it
belongs to when one is known; the Summary additionally
links to the entry's own subject surface (task 7.4 /
chrome-conventions D8, "feed entries link to their
subjects"): the org detail page for a grant or transition,
the billing view for an invoice or payment. An entry with
no addressable surface (.Href empty) renders its summary as
plain text, never a dead link.
The feed pages through the list scaffold's pager, the same
shape as the composite's Tier changes (overview-consistency
D5, round 2): loadRecentActivity returns a merged window of
the deployment's most recent activity and this page's
handler windows it, replacing the retired ?limit
parameter. -->
<section aria-label="Recent activity">
{{ template "sectionHeader" .ActivityHeader }}
{{/* #activity-panel: scoped swap target for this list's
pager, so paging never scrolls the page
(ActivityNav.Target). */}}
<div id="activity-panel">
<div class="card mb-3">
<div class="card-body">
{{ if eq (len .Activity) 0 }}
{{ template "emptyState" .NoActivity }}
{{ else }}
<div class="table-responsive">
<table class="table table-hover table-sm table-record">
<thead>
<tr>
<th scope="col">Summary</th>
<th scope="col">Event</th>
<th scope="col">Organization</th>
<th scope="col">Actor</th>
<th scope="col">When</th>
</tr>
</thead>
<tbody>
{{ range .Activity }}
<tr>
<th scope="row">{{ if .Href }}<a href="{{ .Href }}">{{ .Summary }}</a>{{ else }}{{ .Summary }}{{ end }}</th>
<td>
{{ template "statusBadge" .KindBadge }}
</td>
<td>
{{ if .OrgID }}<a href="/operator/organizations/{{ .OrgID }}">{{ if .OrgName }}{{ .OrgName }}{{ else }}<code class="text-nowrap">{{ .OrgID }}</code>{{ end }}</a>{{ end }}
</td>
<td>
{{ if .PersonID }}<a href="/operator/persons/{{ .PersonID }}">{{ if .PersonName }}{{ .PersonName }}{{ else }}<code class="text-nowrap">{{ .PersonID }}</code>{{ end }}</a>
{{ else }}<span class="text-body-secondary">&mdash;</span>{{ end }}
</td>
<td class="text-nowrap">{{ .Timestamp }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ template "listPager" .ActivityNav }}
{{ end }}
</div>
</div>
</div>
</section>
{{ end }}
</div>
</div>
</main>
</div>
<!-- Toast Container — sibling of <main> so toasts survive hx-boost
navigations and partial swaps (see docs/design-system.md §3). -->
<div class="toast-container">
<div id="errorToast" class="toast align-items-center text-bg-danger border-0" role="alert" aria-live="assertive"
aria-atomic="true">
<div class="d-flex">
<div class="toast-body" id="errorToastBody">
An error occurred. Try again.
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
aria-label="Close"></button>
</div>
</div>
{{/* design D20, round 2 ("a success toast, not a banner"): a
create-then-land page (?flash=created) pre-renders its
confirmation into this shared toast, marked to auto-show on
load; success-toast.js's init reads the marker (CSP-safe, no
inline script), shows the toast, then removes the marker so it
never re-fires on a later swap. */}}
<div id="successToast" class="toast align-items-center text-bg-success border-0" role="status"
aria-live="polite" aria-atomic="true"{{ if .FlashSuccess }} data-show-on-load="true"{{ end }}>
<div class="d-flex">
<div class="toast-body" id="successToastBody">
{{ if .FlashSuccess }}{{ .FlashSuccess }}{{ else }}Done.{{ end }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
aria-label="Close"></button>
</div>
</div>
</div>
<!-- Shared confirmation modal (partials/shell_confirm_modal.html), a
sibling of <main> so it survives boosted navigations. -->
{{ template "shell_confirm_modal.html" . }}
</body>
</html>