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.
401 lines
24 KiB
HTML
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">—</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>
|