Redesign portal as a simpler account tool

This commit is contained in:
rcourtman
2026-03-29 20:30:40 +01:00
parent 298b23626b
commit a5162d96cb
9 changed files with 1638 additions and 607 deletions
@@ -103,21 +103,26 @@ Pulse's internal product model.
Primary areas:
1. `Overview`
A short triage surface that answers only three questions: what needs
attention, what is ready, and what the next obvious action is.
2. `Workspaces`
1. `Workspaces`
Hosted workspace list, health summary, open-workspace handoff, lifecycle
actions, and create-workspace entry points.
3. `Access`
2. `Access`
Account roster, invites, role changes, and access removal.
4. `Billing`
3. `Billing`
Hosted billing state and Stripe billing handoff when relevant. Self-hosted
license retrieval, refunds, privacy/data actions, and related commercial
utilities appear here only when the account has those needs.
5. `Support`
Secondary utilities:
1. `Support`
Escalation path only. It must not compete with primary account tasks in the
main shell.
2. Precise workspace counts
Signed-in workspace counts stay inline at the top of `Workspaces`. They
report account count, workspace count, ready count, review count, and
suspended count directly from runtime truth instead of as a separate
`Summary` or `Overview` tab.
Conditional content:
@@ -135,8 +140,9 @@ of utilities.
Core rules:
1. The first screen must be triage, not a dashboard essay.
2. The overview should answer only `Needs attention`, `Ready`, and `Next
1. The first screen must be the first live task, not a dashboard essay or a
summary-first landing layer.
2. The summary should answer only `Needs attention`, `Ready`, and `Next
action`.
3. Top-level navigation must be organized by user jobs, not by Pulse's
internal hosted, license, billing, or support implementation boundaries.
@@ -166,10 +172,12 @@ Core rules:
the create-workspace form opens below the fold, the shell must reveal it.
14. Access management must be a visible roster and invite surface, not a table
or control hidden behind unrelated account copy.
15. Support must remain an escalation path, not a peer destination competing
with the primary jobs a user came to do.
16. The signed-in `Overview` must be one shell-level triage surface across the
current account set, not a repeated per-account dashboard.
15. Support must remain an escalation utility, not a peer destination
competing with the primary jobs a user came to do.
16. The signed-in shell may not expose a separate `Summary` or `Overview` tab
ahead of the real task surfaces. Workspace counts and state belong inline
at the top of `Workspaces`, not as a repeated per-account dashboard or a
competing first-class destination.
17. Top-level navigation must stay honest to account shape. Hosted-only tasks
that are irrelevant to the current account should be removed from the
primary task row instead of rendered as fake live tabs, and any shared
@@ -233,35 +241,35 @@ Core rules:
and owner/admin handoff paths; `Support` must not imply they can perform
hosted lifecycle, access-mutation, or hosted-billing changes themselves
before escalation.
33. `Overview` must keep billing cues honest to account shape and permission.
Hosted-only accounts may not mention self-hosted billing utilities by
default, and hosted view-only roles must say when owner/admin authority is
still required to open hosted billing.
33. Inline workspace counts and shell copy must keep billing cues honest to
account shape and permission. Hosted-only accounts may not mention self-
hosted billing utilities by default, and hosted view-only roles must say
when owner/admin authority is still required to open hosted billing.
34. User-facing role labels must stay on product vocabulary. The portal may
describe account role as `Owner`, `Admin`, `Tech`, or `Read-only`, but it
must not leak internal identifiers such as `read_only` or legacy aliases
such as `member`.
35. `Overview` must also keep `Next action` permission-honest for hosted
view-only accounts. When no workspace is ready, the primary route must
stay on reviewable `Workspaces` or `Access` surfaces before any blocked
hosted billing or owner/admin-only mutation path.
35. The signed-in shell must keep the first available action permission-
honest for hosted view-only accounts. When no workspace is ready, the
primary route must stay on reviewable `Workspaces` or `Access` surfaces
before any blocked hosted billing or owner/admin-only mutation path.
36. Task surfaces must keep failure copy on owned user jobs. The portal may
not leak raw transport strings such as `Network error.` into `Access`,
`Workspaces`, or `Billing`; each failure must stay on the task-specific
action the user was trying to complete.
37. `Overview` must keep `Ready` honest when no hosted workspace exists yet.
Hosted accounts with zero workspaces may not tell the user to review
current workspace state; they must say that nothing is ready yet and that
the first hosted workspace still needs owner/admin creation before routine
work can start.
38. `Overview` must keep `Needs attention` honest when only suspended
workspaces remain. The shell may not imply active work is ready merely
because a suspended workspace exists; suspended-only states must say that
no active workspace is ready for routine use right now.
39. `Overview` must stay fact-first. It may not synthesize urgency or health
verdicts such as `Nothing urgent` or `Healthy now`; it must report
concrete counts, explicit workspace state, and the next action directly
from the owned runtime truth.
37. Inline workspace counts and workspace-state copy must keep `Ready` honest
when no hosted workspace exists yet. Hosted accounts with zero workspaces
may not tell the user to review current workspace state; they must say
that nothing is ready yet and that the first hosted workspace still needs
owner/admin creation before routine work can start.
38. Inline workspace counts and workspace-state copy must keep suspended-only
states honest. The shell may not imply active work is ready merely because
a suspended workspace exists; suspended-only states must say that no
active workspace is ready for routine use right now.
39. Inline workspace counts and shell status copy must stay fact-first. They
may not synthesize urgency or health verdicts such as `Nothing urgent` or
`Healthy now`; they must report concrete counts and explicit workspace
state directly from the owned runtime truth.
40. Portal task and status copy must stay literal. Customer-facing copy may
not rely on commentary such as `obvious`, `actual work`, `trustworthy`,
or `settled` when the runtime already knows the concrete state, action,
@@ -279,11 +287,11 @@ Core rules:
presentation, and hierarchy driven by spacing, typography, and dividers
instead of dashboard chrome, decorative dark rails, nested card stacks,
or dense pill collections competing with the active job.
42. The signed-in shell must also open on the first live task, not the
summary layer. Hosted accounts should land in `Workspaces`; self-hosted-
only accounts should land in `Billing`. `Overview` remains available as a
precise triage surface, but it must not be the default first impression
for authenticated users.
42. The signed-in shell must also open on the first live task, not a summary
layer. Hosted accounts should land in `Workspaces`; self-hosted-only
accounts should land in `Billing`. The shell must not expose a separate
`Overview` or `Summary` tab as the first impression for authenticated
users.
43. The signed-in shell must orient the user with one quiet account-context
header and one flat top task row. It must not add a second summary box,
sidebar shell, or badge-heavy frame that competes with the active task.
@@ -312,12 +320,12 @@ The signed-in shell should be treated as four first-class states:
Each signed-in state should render:
1. a concise overview band that answers `Needs attention`, `Ready`, and `Next
action`
The overview band is shell-level triage, not one repeated card per account.
The overview band remains available from navigation, but authenticated
users should open on the first live task for the current account shape
rather than landing here by default.
1. a concise workspace counts strip at the top of `Workspaces`
It reports account count, workspace count, ready count, review count, and
suspended count directly from runtime truth. It is not a separate
`Summary` or `Overview` tab, and authenticated users should open on the
first live task for the current account shape rather than landing on a
separate triage page.
2. one quiet account-context header with account kind, role, current account
title, and one short orienting sentence
3. an explicit `Workspaces` area for open, create, and lifecycle actions
@@ -455,8 +463,9 @@ Accepted as sufficient for RC and GA:
The `customer-account-portal` lane should deliver:
1. one named `Pulse Account` shell and task-first IA centered on `Overview`,
`Workspaces`, `Access`, `Billing`, and `Support`
1. one named `Pulse Account` shell and task-first IA centered on
`Workspaces`, `Access`, `Billing`, and `Support`, with precise workspace
counts inline at the top of `Workspaces`
2. shared identity and navigation across hosted account actions and
self-hosted commercial actions
3. canonical ownership boundaries for billing, licenses, hosted tenants,
@@ -326,13 +326,15 @@ self-hosted escalation paths by default, and self-hosted-only accounts do not
front-load an empty hosted-billing block before the real self-hosted jobs.
That same typed bootstrap/runtime contract must also derive the default signed-
in shell section from account shape: hosted accounts open on `Workspaces`,
self-hosted-only accounts open on `Billing`, and `Overview` remains available
as triage instead of acting as the default authenticated landing layer.
self-hosted-only accounts open on `Billing`, and the signed-in shell keeps
precise workspace counts inline on `Workspaces` instead of exposing a separate
`Summary` tab as a primary or default destination.
The same account-shape runtime contract must also keep the shell navigation
honest: self-hosted-only accounts must drop hosted-only `Workspaces`,
`Access`, and `Overview` tabs from the primary task row rather than implying
live hosted work, and any shared fallback surface that still resolves there
must render an explicit unavailable state. `Support` follows the same
honest: the task row is `Workspaces`, `Access`, `Billing`, and `Support`.
Self-hosted-only accounts must drop hosted-only `Workspaces` and `Access`
surfaces rather than implying live hosted work, and any shared fallback
surface that still resolves there must render an explicit unavailable state. `Support`
follows the same
account-shape runtime contract: self-hosted-only accounts expose only the
billing escalation path and billing-specific handoff packet, and hosted
workspace/access escalation controls must not render when no hosted account
@@ -350,10 +352,10 @@ The same permission contract must also drive hosted `Support`: when
`can_manage` is false, the support shell may route the user back to
`Workspaces`, `Access`, or `Billing` only as review and owner/admin handoff
paths, not as live hosted mutation paths the current role can execute.
The same typed bootstrap/runtime contract must also keep `Overview` billing
copy honest to account shape: hosted-only accounts may not mention self-hosted
billing utilities by default, and hosted view-only roles must say when hosted
billing still needs owner/admin authority.
The same typed bootstrap/runtime contract must also keep inline workspace
counts and shell copy honest to account shape: hosted-only accounts may not
mention self-hosted billing utilities by default, and hosted view-only roles
must say when hosted billing still needs owner/admin authority.
The same permission contract must also drive the compact account-context
summary: the strip may not describe full hosted access-control or billing
ownership when the current role can only review workspaces or roster state.
@@ -361,7 +363,7 @@ That same typed runtime contract must also normalize account-role labels before
render: customer-facing copy may say `Owner`, `Admin`, `Tech`, or `Read-only`,
but it must not surface raw runtime identifiers such as `read_only` or legacy
aliases such as `member`.
That same runtime contract must also keep `Overview` next-action routing
That same runtime contract must also keep the first available action
permission-honest for hosted view-only accounts: when no ready workspace
exists, the primary route must stay on reviewable `Workspaces` or `Access`
surfaces before any blocked hosted billing or owner/admin-only mutation path.
@@ -369,14 +371,14 @@ That same shared request/runtime boundary must also preserve task-specific
failure copy on transport errors: portal job surfaces may not leak raw strings
such as `Network error.`, and must instead surface the owned fallback for the
exact action that failed.
That same typed overview contract must also keep `Ready` honest when no hosted
That same typed summary contract must also keep `Ready` honest when no hosted
workspace exists yet: hosted accounts with zero workspaces may not route the
user into current workspace review, and must instead render that nothing is
ready until the first hosted workspace exists.
That same typed overview contract must also keep `Needs attention` honest when
That same typed summary contract must also keep `Needs attention` honest when
only suspended workspaces remain: hosted workspace history alone may not make
the shell imply that active work is ready.
That same typed overview contract must also stay fact-first: overview copy may
That same typed summary contract must also stay fact-first: summary copy may
not synthesize urgency or health verdicts such as `Nothing urgent` or
`Healthy now`, and must instead render concrete counts, explicit workspace
state, and next-action routing from the owned runtime payload.
@@ -869,12 +869,12 @@ not yet form one coherent Pulse account product. The canonical future shape is
authenticated Pulse account shell that unifies Cloud tenants, self-hosted
licenses, billing, recovery, and MSP admin surfaces without creating a
standalone Relay portal. That shell now also owns two product rules
explicitly: `Overview` is one shell-level triage surface across the current
account set rather than a repeated per-account dashboard, and the top-level
task row must stay honest to account shape by removing irrelevant hosted-only
tasks instead of pretending they are live. Any shared fallback that still
lands on a non-live task must render an explicit unavailable state instead of
blank space. The same shell also owns action-first task
explicitly: signed-in workspace counts stay inline at the top of
`Workspaces` instead of as a separate `Overview` or `Summary` tab, and the
top-level task row must stay honest to account shape by removing irrelevant
hosted-only tasks instead of pretending they are live. Any shared fallback
that still lands on a non-live task must render an explicit unavailable state
instead of blank space. The same shell also owns action-first task
surfaces for `Access`, `Billing`, and `Support`: access mutations must be
permission-honest and roster-led, billing must reduce to one obvious job at a
time with hosted billing first when relevant, support must stay a failed-path
@@ -917,10 +917,10 @@ list-first task presentation instead of dark ornamental rails, sidebars, or
nested explanatory cards. The signed-in shell should orient the user with one
quiet account-context header and one flat top task row, not a second summary
deck competing with the task itself.
That same owned shell must also open on the first live job instead of the
That same owned shell must also open on the first live job instead of a
summary layer: hosted accounts should land in `Workspaces`, and self-hosted-
only accounts should land in `Billing`. `Overview` remains part of the shell,
but it is triage, not the default authenticated landing surface.
only accounts should land in `Billing`. The signed-in shell must not expose a
separate `Overview` or `Summary` tab ahead of the real task surfaces.
That same owned shell also owns the signed-out visual posture: the unauthenticated
`/portal` page must read like the same product boundary, not a leftover
marketing block plus a generic login card. The auth surface should present one
@@ -936,10 +936,10 @@ sent back to `Workspaces`, `Access`, or `Billing` only as review and
owner/admin handoff paths, not as though the user can perform hosted
lifecycle, access-mutation, or hosted-billing changes directly before
escalation.
`Overview` follows the same account-shape and permission rule: hosted-only
accounts must not mention self-hosted billing utilities by default, and hosted
view-only roles must say when hosted billing still needs owner/admin
authority.
Inline workspace counts and shell copy follow the same account-shape and
permission rule: hosted-only accounts must not mention self-hosted billing
utilities by default, and hosted view-only roles must say when hosted billing
still needs owner/admin authority.
The same rule applies to the compact account-context strip: it must describe
the current user's effective hosted tasks, not restate full access-control and
billing capability when those actions are blocked behind owner/admin roles.
@@ -947,7 +947,7 @@ That same owned shell must also keep role labels on product vocabulary:
customer-facing copy may say `Owner`, `Admin`, `Tech`, or `Read-only`, but it
must not leak internal identifiers such as `read_only` or legacy aliases such
as `member`.
That same owned `Overview` surface must also keep `Next action`
That same owned signed-in shell must also keep the first available action
permission-honest for hosted view-only users: when no workspace is ready, the
primary route must stay on reviewable `Workspaces` or `Access` surfaces before
any blocked hosted billing or owner/admin-only mutation path.
@@ -955,17 +955,13 @@ That same owned task surface must also keep failure copy on the user job
instead of leaking raw transport wording: `Access`, `Workspaces`, and
`Billing` failures must render the task-specific action that could not
complete, not generic copy such as `Network error.`.
That same owned `Overview` surface must also keep `Ready` honest when no
hosted workspace exists yet: hosted accounts with zero workspaces may not tell
the user to review current workspace state, and must instead say that nothing
is ready yet until the first hosted workspace exists.
That same owned `Overview` surface must also keep `Needs attention` honest
when only suspended workspaces remain: a suspended-only account may not imply
that active work is ready simply because hosted workspace history exists.
That same owned `Overview` surface must also stay fact-first: it may not
invent urgency or health verdicts such as `Nothing urgent` or `Healthy now`,
and must instead report concrete counts, explicit workspace state, and the
next action directly from runtime-backed account truth.
That same owned inline workspace counts and workspace-state copy must also
keep `Ready` honest when no hosted workspace exists yet: hosted accounts with
zero workspaces may not tell the user to review current workspace state, and
must instead say that nothing is ready yet until the first hosted workspace
exists. The same counts and copy must keep suspended-only states honest, and
must stay fact-first rather than inventing urgency or health verdicts such as
`Nothing urgent` or `Healthy now`.
That same portal shell/runtime boundary must also keep task and status copy
literal across the account surface: customer-facing wording may not lean on
commentary such as `obvious`, `actual work`, `trustworthy`, or `settled` when
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "9f9d5468a58d29a9e38839b7c671e9b48a2cbedb21919ebe32e2a44256a030aa",
"source_hash": "105a2cf01f7b31e356d71c2f826b18be16165e4c9c61fba4504283bf965662ff",
"build_inputs": [
"package.json",
"tsconfig.json",
+537 -60
View File
@@ -4101,13 +4101,16 @@ header .logout-btn,
}
}
.portal-shell-main {
width: min(1080px, calc(100vw - 48px));
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
padding: 0 0 32px;
gap: 18px;
padding: 10px 0 40px;
}
.portal-shell-main > .portal-content-panel {
display: none !important;
width: 100%;
}
.portal-shell[data-shell-section=workspaces] .portal-content-panel-workspaces,
.portal-shell[data-shell-section=access] .portal-content-panel-access,
@@ -4118,7 +4121,7 @@ header .logout-btn,
}
.portal-account-context {
display: block;
padding: 0 0 12px;
padding: 0 0 14px;
border: none;
border-bottom: 1px solid var(--line);
border-radius: 0;
@@ -4135,14 +4138,14 @@ header .logout-btn,
color: var(--line-strong);
}
.portal-account-context-row-title h2 {
font-size: 30px;
line-height: 1.04;
letter-spacing: -0.04em;
font-size: 32px;
line-height: 1.02;
letter-spacing: -0.045em;
}
.portal-account-context-copy p {
max-width: 820px;
font-size: 15px;
line-height: 1.55;
max-width: 760px;
font-size: 14px;
line-height: 1.5;
}
.portal-account-context-summary {
display: none !important;
@@ -4150,6 +4153,10 @@ header .logout-btn,
.portal-shell-nav {
position: static;
top: auto;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
border: none;
border-bottom: 1px solid var(--line);
background: transparent;
@@ -4160,8 +4167,8 @@ header .logout-btn,
.portal-shell-nav-group {
display: flex;
flex-direction: row;
gap: 12px;
padding: 0 0 4px;
gap: 10px;
padding: 0 0 6px;
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
@@ -4169,6 +4176,14 @@ header .logout-btn,
.portal-shell-nav-group::-webkit-scrollbar {
display: none;
}
.portal-shell-nav-group-primary {
flex: 1 1 auto;
min-width: 0;
}
.portal-shell-nav-group-utility {
flex: 0 0 auto;
justify-content: flex-end;
}
.portal-shell-nav-link {
min-width: 0;
padding: 0 8px 10px;
@@ -4188,6 +4203,12 @@ header .logout-btn,
background: transparent;
color: var(--ink);
}
.portal-shell-nav-link-utility {
color: var(--ink-soft);
}
.portal-shell-nav-link-utility.active {
border-bottom-color: var(--line-strong);
}
.portal-shell-nav-row {
justify-content: flex-start;
gap: 0;
@@ -4196,6 +4217,10 @@ header .logout-btn,
font-size: 14px;
font-weight: 600;
}
.portal-shell-nav-link-utility .portal-shell-nav-label {
font-size: 13px;
font-weight: 500;
}
.portal-shell-nav-badge {
display: none !important;
}
@@ -4211,29 +4236,29 @@ header .logout-btn,
box-shadow: none;
}
.account-surface + .account-surface {
margin-top: 24px;
margin-top: 28px;
}
.account-stage-header,
.billing-header {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
margin-bottom: 18px;
gap: 10px;
margin-bottom: 14px;
}
.account-stage-header h3,
.billing-header h2,
.portal-support-panel h2 {
font-size: 24px;
line-height: 1.12;
letter-spacing: -0.03em;
font-size: 26px;
line-height: 1.08;
letter-spacing: -0.035em;
}
.account-stage-header p,
.billing-header p,
.billing-note,
.portal-support-panel > p {
.portal-support-panel p {
max-width: 760px;
font-size: 14px;
line-height: 1.55;
line-height: 1.5;
}
.section-context-strip {
gap: 0;
@@ -4245,14 +4270,25 @@ header .logout-btn,
.section-context-chip + .section-context-chip::before {
margin-right: 8px;
}
.overview-task-grid {
grid-template-columns: 1fr;
gap: 12px;
.overview-task-grid-compact {
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 14px;
background: #fff;
}
.overview-task-grid-compact .overview-task-card {
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
background: transparent;
padding: 16px 18px;
}
.overview-task-grid-compact .overview-task-card:first-child {
border-top: none;
}
.overview-task-card,
.billing-task-card,
.portal-support-route-card,
.portal-support-runbook,
.workspace-list-wrap,
.workspace-management-panel,
.access-roster,
@@ -4260,117 +4296,558 @@ header .logout-btn,
.access-invite-panel,
.access-job-note-panel,
.access-policy-panel,
.billing-surface-block,
.billing-shell-main,
.billing-shell-detail,
.billing-panel {
.billing-panel,
.portal-support-simple-card {
border: 1px solid var(--line);
border-radius: 12px;
border-radius: 14px;
background: #fff;
}
.overview-task-card,
.billing-task-card,
.portal-support-route-card,
.portal-support-runbook,
.billing-shell-main,
.billing-shell-detail,
.billing-panel {
padding: 18px;
}
.workspace-operations-shell,
.access-shell,
.billing-shell,
.portal-support-layout {
grid-template-columns: 1fr;
.billing-shell {
grid-template-columns: minmax(0, 1fr);
gap: 14px;
}
.workspace-list-wrap,
.access-roster,
.billing-surface-block,
.portal-support-simple-card {
overflow: hidden;
}
.workspace-list-toolbar,
.access-panel-heading {
padding: 16px 18px 10px;
padding: 14px 18px 8px;
}
.workspace-list-head {
grid-template-columns: minmax(0, 1.6fr) 120px 120px 160px;
grid-template-columns: minmax(0, 1.7fr) 116px 116px 168px;
gap: 12px;
padding: 8px 18px;
padding: 9px 18px;
}
.workspace-row {
grid-template-columns: minmax(0, 1.6fr) 120px 120px 160px;
grid-template-columns: minmax(0, 1.7fr) 116px 116px 168px;
gap: 12px;
padding: 14px 18px;
}
.workspace-actions {
flex-wrap: wrap;
justify-content: flex-start !important;
}
.access-management-header {
margin-bottom: 12px;
}
.access-management-stats {
display: block;
margin-bottom: 12px;
margin-bottom: 10px;
}
.access-stat-summary {
padding: 0 0 8px;
font-size: 12px;
padding: 0 0 10px;
font-size: 13px;
font-weight: 500;
color: var(--ink-muted);
}
.access-stat-summary-error {
color: var(--danger);
}
.access-shell-main,
.access-roster-column,
.access-roster {
width: 100%;
}
.access-task-strip {
gap: 6px;
margin-top: 10px;
gap: 8px;
margin-top: 12px;
}
.access-task-button {
border-radius: 10px;
}
.access-task-button.active {
.access-task-button.active,
.access-task-button.is-active {
border-color: var(--accent);
background: #edf3fe;
color: var(--accent);
}
.access-roster-list {
padding: 0 18px 8px;
padding: 0 18px 10px;
}
.access-roster-head {
padding: 8px 18px;
padding: 9px 18px;
}
.access-member-row {
padding: 12px 0;
}
.billing-overview-grid {
grid-template-columns: 1fr;
gap: 12px;
.billing-header {
grid-template-columns: minmax(0, 1fr);
align-items: start;
}
.billing-note {
padding: 0;
border: none;
}
.billing-surface-block {
padding: 0;
}
.billing-surface-block + .billing-surface-block {
margin-top: 14px;
}
.billing-surface-block-empty {
padding: 18px;
}
.billing-surface-header {
padding: 18px 18px 10px;
}
.billing-action-list-surface {
display: flex;
flex-direction: column;
}
.billing-action-row-surface {
padding: 16px 18px;
}
.billing-action-row {
padding: 14px 0;
}
.portal-support-route-grid {
gap: 12px;
.portal-support-simple {
max-width: 880px;
}
.portal-support-simple-card {
padding: 0;
}
.portal-support-simple-list {
display: flex;
flex-direction: column;
}
.portal-support-simple-row {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 16px;
padding: 14px 18px;
border-top: 1px solid var(--line);
}
.portal-support-simple-row:first-child {
border-top: none;
}
.portal-support-simple-row strong {
font-size: 13px;
color: var(--ink);
}
.portal-support-simple-row span {
font-size: 14px;
line-height: 1.5;
color: var(--ink-soft);
}
.portal-support-simple-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 16px 18px;
border-top: 1px solid var(--line);
background: #fafbfc;
}
@media (max-width: 960px) {
.portal-shell-main {
width: min(100%, calc(100vw - 32px));
gap: 16px;
}
.portal-shell-nav {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.portal-shell-nav-group {
gap: 8px;
}
.portal-shell-nav-group-utility {
justify-content: flex-start;
}
.portal-account-context {
padding-bottom: 10px;
padding-bottom: 12px;
}
.portal-account-context-row-title h2,
.account-stage-header h3,
.billing-header h2,
.portal-support-panel h2 {
font-size: 22px;
font-size: 24px;
}
.portal-support-simple-row {
grid-template-columns: 1fr;
gap: 6px;
}
}
@media (max-width: 640px) {
.portal-shell-main {
width: min(100%, calc(100vw - 20px));
}
.portal-shell-nav-link {
padding: 0 6px 8px;
}
.portal-shell-nav-link-utility {
padding-bottom: 6px;
}
.portal-account-context-row-title h2 {
font-size: 24px;
font-size: 26px;
}
.access-management-stats {
grid-template-columns: 1fr;
}
}
.portal-shell-main {
max-width: 1120px;
margin: 0 auto;
width: min(100%, calc(100vw - 48px));
padding: 0 0 56px;
gap: 20px;
}
.portal-account-context {
padding: 28px 0 8px;
border-bottom: none;
background: transparent;
}
.portal-account-context-copy {
gap: 4px;
}
.portal-account-context-meta {
font-size: 12px;
}
.portal-account-context-row-title h2 {
font-size: 32px;
line-height: 1.04;
letter-spacing: -0.04em;
}
.portal-account-context-copy p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: var(--ink-muted);
}
.portal-shell-nav {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
}
.portal-shell-nav-group {
padding-bottom: 0;
gap: 20px;
}
.portal-shell-nav-group-primary {
flex: 0 0 auto;
}
.portal-shell-nav-group-utility {
flex: 0 0 auto;
margin-left: 0;
}
.portal-shell-nav-link {
padding: 0 0 12px;
width: auto;
min-width: max-content;
flex: 0 0 auto;
display: inline-flex;
white-space: nowrap;
}
.portal-shell-nav-link-utility {
color: var(--ink-muted);
}
.portal-page-header {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
margin-bottom: 18px;
}
.portal-page-header h2 {
font-size: 28px;
line-height: 1.06;
letter-spacing: -0.04em;
}
.portal-page-header p {
max-width: 640px;
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--ink-muted);
}
.portal-page-facts,
.portal-account-block-facts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0;
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: var(--ink-muted);
}
.portal-fact-separator {
margin: 0 10px;
color: var(--line-strong, var(--line));
}
.portal-summary-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
width: 100%;
min-width: 0;
}
.portal-summary-item {
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: #fff;
}
.portal-summary-item span {
display: block;
margin-bottom: 4px;
font-size: 12px;
line-height: 1.4;
color: var(--ink-muted);
}
.portal-summary-item strong {
display: block;
font-size: 18px;
line-height: 1.2;
color: var(--ink);
}
.account-surface + .account-surface {
margin-top: 18px;
}
.portal-account-block-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.portal-account-block-copy h3 {
font-size: 20px;
line-height: 1.12;
letter-spacing: -0.03em;
}
.portal-account-block-copy p {
margin: 10px 0 0;
font-size: 14px;
line-height: 1.55;
color: var(--ink-muted);
}
.portal-account-block-actions {
display: flex;
align-items: flex-start;
gap: 12px;
}
.access-section-shell .portal-account-block-header,
.billing-section .portal-page-header,
.portal-support-panel .portal-page-header {
align-items: flex-start;
}
.access-section-shell .portal-account-block-header {
flex-direction: column;
}
.access-section-shell .portal-account-block-actions {
width: 100%;
}
.workspace-list-wrap,
.workspace-management-panel,
.access-roster,
.access-task-panel,
.billing-surface-block,
.billing-shell-main,
.billing-shell-detail,
.billing-panel,
.portal-support-panel {
border-radius: 14px;
}
.workspace-list-toolbar {
padding: 14px 18px 8px;
}
.workspace-list-summary {
font-size: 13px;
line-height: 1.5;
color: var(--ink-muted);
}
.workspace-list-head {
grid-template-columns: minmax(0, 1.8fr) 110px 120px 180px;
gap: 12px;
padding: 8px 18px;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-muted);
}
.workspace-row {
grid-template-columns: minmax(0, 1.8fr) 110px 120px 180px;
gap: 12px;
padding: 16px 18px;
}
.workspace-row-note {
font-size: 13px;
}
.workspace-actions {
justify-content: flex-end;
gap: 8px;
}
.workspace-operations-shell,
.access-shell,
.billing-shell {
gap: 12px;
}
.access-management-stats {
margin-bottom: 10px;
}
.access-task-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.access-task-button {
border-radius: 999px;
background: #fff;
}
.access-roster-list {
padding: 0 18px 4px;
}
.billing-surface-block {
padding: 18px;
}
.billing-surface-header {
margin-bottom: 8px;
}
.billing-action-list-surface {
gap: 0;
}
.billing-action-row-surface {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
padding: 16px 0;
border-top: 1px solid var(--line);
}
.billing-action-row-surface:first-child {
padding-top: 0;
border-top: none;
}
.billing-action-row-surface:last-child {
padding-bottom: 0;
}
.billing-action-row-surface .billing-action-copy h3 {
font-size: 18px;
}
.billing-action-row-surface .billing-action-meta {
margin-top: 4px;
font-size: 13px;
line-height: 1.5;
color: var(--ink-muted);
}
.portal-support-panel {
border: 1px solid var(--line);
background: #fff;
padding: 18px;
}
.portal-support-simple-card {
padding: 0;
border: none;
background: transparent;
}
.portal-support-simple-row {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 16px;
padding: 14px 0;
border-top: 1px solid var(--line);
}
.portal-support-simple-row:first-child {
padding-top: 0;
border-top: none;
}
.portal-support-simple-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
@media (max-width: 960px) {
.portal-shell-main {
width: min(100%, calc(100vw - 40px));
padding-bottom: 40px;
}
.portal-shell-nav {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.portal-shell-nav-group {
gap: 12px;
}
.portal-shell-nav-group-utility {
margin-left: 0;
}
.portal-account-block-header {
flex-direction: column;
align-items: flex-start;
}
.portal-summary-strip {
width: 100%;
min-width: 0;
}
.billing-action-row-surface {
grid-template-columns: 1fr;
align-items: flex-start;
}
}
@media (max-width: 720px) {
.workspace-list-head {
display: none;
}
.workspace-row {
grid-template-columns: 1fr;
gap: 10px;
}
.workspace-row-status-cell {
justify-content: flex-start;
}
.workspace-actions {
justify-content: flex-start;
width: 100%;
}
.workspace-actions form,
.workspace-actions button {
max-width: none;
}
.portal-summary-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.access-task-strip {
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.access-task-strip::-webkit-scrollbar {
display: none;
}
.portal-support-simple-row {
grid-template-columns: 1fr;
gap: 6px;
}
}
@media (max-width: 640px) {
.portal-shell-main {
width: min(100%, calc(100vw - 24px));
padding-bottom: 32px;
}
.portal-account-context {
padding-top: 20px;
}
.portal-account-context-row-title h2 {
font-size: 28px;
}
.portal-page-header h2 {
font-size: 24px;
}
.portal-summary-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+100 -84
View File
@@ -2015,12 +2015,6 @@
function suspendedWorkspaceSectionChipLabel(count) {
return count === 1 ? "1 workspace suspended" : String(count) + " workspaces suspended";
}
function billingHeaderChipLabels(hostedBillingCount, showSelfHostedCommercial) {
return [
hostedBillingCount > 0 ? "Hosted billing attached" : "No hosted billing attached",
showSelfHostedCommercial ? "Self-hosted billing available" : "Hosted billing only"
];
}
function supportSectionChipLabels(hasHostedAccounts2, hostedViewOnly, supportEmail) {
if (hasHostedAccounts2) {
return [
@@ -2038,45 +2032,6 @@
}
return showSelfHostedCommercial ? "Use Support only after Workspaces, Access, or Billing fails." : "Use Support only after Workspaces, Access, or hosted Billing fails.";
}
function hostedSupportRouteTitle(hostedViewOnly) {
return hostedViewOnly ? "Review or owner/admin handoff failed" : "Workspace or access failed";
}
function hostedSupportRouteDescription(hostedViewOnly) {
return hostedViewOnly ? "Recheck the same workspace or roster first. Escalate only if the required owner or admin change still cannot complete." : "Retry the same workspace or access task first. Escalate only if the same task still fails.";
}
function hostedSupportRouteTaskCopy(hostedViewOnly) {
return hostedViewOnly ? "Use Workspaces for workspace state or Access for roster state before you escalate." : "Use Workspaces for lifecycle work or Access for roster work before you escalate.";
}
function hostedSupportRouteContextLabel(hostedViewOnly) {
return hostedViewOnly ? "Owner/admin handoff" : "Failed step";
}
function hostedSupportRouteContextCopy(hostedViewOnly) {
return hostedViewOnly ? "Include the account, workspace, and the change that still needs owner or admin action." : "Include the account, workspace, and the failed step.";
}
function billingSupportRouteTitle(hostedViewOnly, showSelfHostedCommercial) {
if (hostedViewOnly) {
return showSelfHostedCommercial ? "Billing or owner/admin handoff failed" : "Hosted billing or owner/admin handoff failed";
}
return showSelfHostedCommercial ? "Billing failed" : "Hosted billing failed";
}
function billingSupportRouteDescription(hostedViewOnly, showSelfHostedCommercial) {
if (hostedViewOnly) {
return showSelfHostedCommercial ? "Escalate only if hosted billing still needs owner/admin action or a self-hosted billing task still fails." : "Escalate only if hosted billing still needs owner/admin action or still fails.";
}
return showSelfHostedCommercial ? "Escalate only if hosted billing or a self-hosted billing task still fails." : "Escalate only if hosted billing still fails.";
}
function billingSupportRouteJobCopy(hostedViewOnly, showSelfHostedCommercial) {
if (hostedViewOnly) {
return showSelfHostedCommercial ? "Hosted billing, licenses, refunds, or privacy. Say if hosted billing still needed owner/admin action." : "Hosted billing. Say if it still needed owner/admin action.";
}
return showSelfHostedCommercial ? "Hosted billing, licenses, refunds, or privacy." : "Hosted billing.";
}
function billingSupportRouteAccountCopy(hostedViewOnly, showSelfHostedCommercial) {
if (showSelfHostedCommercial) {
return "Include the hosted account for hosted billing or the billing email for self-hosted work.";
}
return hostedViewOnly ? "Include the hosted account that still needed owner/admin billing action." : "Include the hosted account for the failed billing step.";
}
function supportRunbookPathCopy(hasHostedAccounts2, hostedViewOnly, showSelfHostedCommercial) {
if (!hasHostedAccounts2) return "Billing, licenses, refunds, or privacy.";
if (hostedViewOnly) {
@@ -2161,28 +2116,44 @@
return '<span class="section-context-chip">' + escapeHTML(chip) + "</span>";
}).join("") + "</div>";
}
function renderFactLine(className, facts) {
if (!facts.length) return "";
return '<div class="' + className + '">' + facts.map(function(fact) {
return "<span>" + escapeHTML(fact) + "</span>";
}).join('<span class="portal-fact-separator">\u2022</span>') + "</div>";
}
function renderSummaryStrip(items) {
if (!items.length) return "";
return '<div class="portal-summary-strip">' + items.map(function(item) {
return '<div class="portal-summary-item"><span>' + escapeHTML(item.label) + "</span><strong>" + escapeHTML(item.value) + "</strong></div>";
}).join("") + "</div>";
}
function attentionWorkspaces(workspaces) {
var results = [];
for (var i = 0; i < workspaces.length; i += 1) {
var status = workspaceHealthState(workspaces[i]);
if (status === "unhealthy" || status === "checking") {
results.push(workspaces[i]);
}
}
return results;
}
function accountContextRoleMeta(account) {
return portalRoleLabel(account.role) + " role";
}
function accountContextLeadCopy(account) {
if (account.can_manage) {
return account.has_billing ? "Manage workspaces, access, and billing for this account." : "Manage workspaces and access for this account.";
}
return account.has_billing ? "Open workspaces and review access here. Billing and account changes require an owner or admin." : "Open workspaces and review access here. Account changes require an owner or admin.";
}
function renderAccountContextStrip(account) {
return '<section class="portal-account-context"><div class="portal-account-context-copy"><div class="portal-account-context-meta"><span class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + '</span><span class="portal-account-context-separator">\u2022</span><span class="portal-account-context-access">' + escapeHTML(accountContextRoleMeta(account)) + '</span></div><div class="portal-account-context-row portal-account-context-row-title"><h2>' + escapeHTML(account.name) + "</h2></div><p>" + escapeHTML(accountContextLeadCopy(account)) + "</p></div></section>";
return '<section class="portal-account-context"><div class="portal-account-context-copy"><div class="portal-account-context-meta"><span class="account-eyebrow">Pulse Account</span><span class="portal-account-context-separator">\u2022</span><span class="portal-account-context-access">' + escapeHTML(accountKindLabel(account)) + '</span></div><div class="portal-account-context-row portal-account-context-row-title"><h2>' + escapeHTML(account.name) + "</h2></div><p>" + escapeHTML(accountContextRoleMeta(account)) + "</p></div></section>";
}
function renderPortalContextStrip(accounts, showSelfHostedCommercial) {
if (accounts.length === 1) {
return renderAccountContextStrip(accounts[0]);
}
if (accounts.length > 1) {
return '<section class="portal-account-context"><div class="portal-account-context-copy"><div class="portal-account-context-meta"><span class="account-eyebrow">Pulse Account</span><span class="portal-account-context-separator">\u2022</span><span class="portal-account-context-access">' + escapeHTML(accounts.length === 1 ? "1 hosted account" : String(accounts.length) + " hosted accounts") + '</span></div><div class="portal-account-context-row portal-account-context-row-title"><h2>Accounts</h2></div><p>Open workspaces, review access, and handle billing from one account surface.</p></div></section>';
return '<section class="portal-account-context"><div class="portal-account-context-copy"><div class="portal-account-context-meta"><span class="account-eyebrow">Pulse Account</span><span class="portal-account-context-separator">\u2022</span><span class="portal-account-context-access">' + escapeHTML(accounts.length === 1 ? "1 hosted account" : String(accounts.length) + " hosted accounts") + '</span></div><div class="portal-account-context-row portal-account-context-row-title"><h2>Hosted accounts</h2></div><p>Workspaces, access, and billing by account.</p></div></section>';
}
return '<section class="portal-account-context"><div class="portal-account-context-copy"><div class="portal-account-context-meta"><span class="account-eyebrow">Billing account</span><span class="portal-account-context-separator">\u2022</span><span class="portal-account-context-access">' + escapeHTML(showSelfHostedCommercial ? "Self-hosted commercial" : "Commercial account") + '</span></div><div class="portal-account-context-row portal-account-context-row-title"><h2>Billing</h2></div><p>Use Billing for self-hosted subscriptions, licenses, refunds, and privacy requests.</p></div></section>';
}
function visibleShellSections(bootstrap) {
function primaryShellSections(bootstrap) {
var accounts = Array.isArray(bootstrap.accounts) ? bootstrap.accounts : [];
var hosted = hasHostedAccounts(accounts);
var showSelfHostedCommercial = hasSelfHostedCommercial(bootstrap);
@@ -2200,19 +2171,21 @@
if (hostedBillingCount > 0 || showSelfHostedCommercial) {
sections.push({ section: "billing", title: "Billing" });
}
sections.push({ section: "support", title: "Support" });
if (hosted) {
sections.push({ section: "overview", title: "Overview" });
}
return sections;
}
function shellSectionButton(section, activeSection, title) {
return '<button class="portal-shell-nav-link' + (activeSection === section ? " active" : "") + '" type="button" data-shell-action="activate-section" data-shell-section="' + section + '"><span class="portal-shell-nav-row"><span class="portal-shell-nav-label">' + title + "</span></span></button>";
function utilityShellSections(_bootstrap) {
return [{ section: "support", title: "Support" }];
}
function visibleShellSections(bootstrap) {
return primaryShellSections(bootstrap).concat(utilityShellSections(bootstrap));
}
function shellSectionButton(section, activeSection, title, variant) {
return '<button class="portal-shell-nav-link portal-shell-nav-link-' + variant + (activeSection === section ? " active" : "") + '" type="button" data-shell-action="activate-section" data-shell-section="' + section + '"><span class="portal-shell-nav-row"><span class="portal-shell-nav-label">' + title + "</span></span></button>";
}
function renderShellNavigation(bootstrap, activeSection) {
var sections = visibleShellSections(bootstrap);
return '<nav class="portal-shell-nav" aria-label="Pulse Account sections"><div class="portal-shell-nav-group">' + sections.map(function(entry) {
return shellSectionButton(entry.section, activeSection, entry.title);
return '<nav class="portal-shell-nav" aria-label="Pulse Account sections"><div class="portal-shell-nav-group portal-shell-nav-group-primary">' + sections.map(function(entry) {
return shellSectionButton(entry.section, activeSection, entry.title, entry.section === "support" ? "utility" : "primary");
}).join("") + "</div></nav>";
}
function renderWorkspaceCard(account, workspace, accountAPIBasePath) {
@@ -2388,7 +2361,13 @@
reviewWorkspaceChipLabel(attentionCount),
suspendedWorkspaceChipLabel(suspendedCount)
] : ["No hosted account", "0 hosted workspaces", "Billing available", "Support only on escalation"];
return '<section class="account-content-panel account-content-panel-overview"><div class="account-stage-header account-stage-header-overview overview-stage-header"><div><div class="account-panel-kicker">Overview</div><h3>Current state</h3><p>Hosted workspace counts, current state, and the next step.</p>' + renderSectionContextChips(chips) + '</div></div><div class="overview-task-grid">' + renderOverviewAttentionCard(accounts, entries, showSelfHostedCommercial) + renderOverviewReadyCard(accounts, entries, context.accountAPIBasePath) + renderOverviewNextActionCard(accounts, entries, context.accountAPIBasePath, showSelfHostedCommercial) + "</div></section>";
return '<section class="account-content-panel account-content-panel-overview"><div class="account-stage-header account-stage-header-overview overview-stage-header"><div><div class="account-panel-kicker">Summary</div><h3>Current state</h3><p>Use this summary only when you need a quick account readout.</p>' + renderSectionContextChips(chips) + '</div></div><div class="overview-task-grid overview-task-grid-compact">' + renderOverviewAttentionCard(accounts, entries, showSelfHostedCommercial) + renderOverviewReadyCard(accounts, entries, context.accountAPIBasePath) + renderOverviewNextActionCard(accounts, entries, context.accountAPIBasePath, showSelfHostedCommercial) + "</div></section>";
}
function renderPageHeader(kicker, title, copy, summaryHTML = "") {
return '<div class="portal-page-header"><div><div class="account-panel-kicker">' + escapeHTML(kicker) + "</div><h2>" + escapeHTML(title) + "</h2><p>" + escapeHTML(copy) + "</p></div>" + summaryHTML + "</div>";
}
function renderAccountBlockHeader(account, facts, actionsHTML = "", copy = "") {
return '<div class="portal-account-block-header"><div class="portal-account-block-copy"><div class="account-panel-kicker">' + escapeHTML(accountKindLabel(account)) + "</div><h3>" + escapeHTML(account.name) + "</h3>" + renderFactLine("portal-account-block-facts", [accountContextRoleMeta(account)].concat(facts)) + (copy ? "<p>" + escapeHTML(copy) + "</p>" : "") + "</div>" + (actionsHTML ? '<div class="portal-account-block-actions">' + actionsHTML + "</div>" : "") + "</div>";
}
function renderNoHostedWorkspacesSection() {
return '<section class="account-content-panel account-content-panel-workspaces"><div class="account-stage-header"><div><div class="account-panel-kicker">Workspaces</div><h3>Workspaces</h3><p>No hosted workspace is attached to this account.</p>' + renderSectionContextChips(["None attached", "Billing instead"]) + '</div></div><div class="empty-state empty-state-spaced"><p>There is nothing to open or manage here yet.</p><p class="support-copy">Use Billing for self-hosted subscriptions, licenses, refunds, or privacy requests.</p></div></section>';
@@ -2399,9 +2378,9 @@
function renderAccountWorkspaceSection(account, accountAPIBasePath) {
var workspaces = Array.isArray(account.workspaces) ? account.workspaces : [];
var readyCount = countReadyWorkspaces(workspaces);
var attentionCount = attentionWorkspaces(workspaces).length;
var suspendedCount = countWorkspacesByState(workspaces, "suspended");
var sectionCopy = account.can_manage ? "Open a workspace, review lifecycle state, or create a new one without mixing in access or billing work." : "Open a workspace and review current state here. An owner or admin must create or change hosted workspaces.";
var workspaceListSummary = account.can_manage ? "Open a workspace to work in it. Use the lifecycle view only when you are reviewing state or making account-level changes." : "Open a workspace here. An owner or admin must handle lifecycle or creation changes.";
var workspaceListSummary = account.can_manage ? "Open a workspace to work in it. Use Lifecycle only when you need account-level changes." : "Open a workspace here. An owner or admin must create or change hosted workspaces.";
var workspaceManagement = "";
var addWorkspaceForm = "";
var workspaceHeaderActions = "";
@@ -2417,35 +2396,43 @@
var workspaceHTML = workspaces.length ? '<div class="workspace-list-wrap"><div class="workspace-list-toolbar"><div class="workspace-list-summary">' + escapeHTML(workspaceListSummary) + '</div></div><div class="workspace-list-head"><span>Workspace</span><span>Health</span><span>Lifecycle</span><span>Actions</span></div><div class="workspace-list">' + workspaces.map(function(workspace) {
return renderWorkspaceCard(account, workspace, accountAPIBasePath);
}).join("") + "</div></div>" : '<div class="empty-state"><p>' + escapeHTML(account.can_manage ? "No hosted workspaces yet. Create one to get started." : "No hosted workspaces are attached yet. An owner or admin must create the first one.") + "</p></div>";
return '<section class="account-content-panel account-content-panel-workspaces"><div class="account-stage-header"><div class="account-stage-header-row"><div><div class="account-panel-kicker">Workspaces</div><h3>Workspaces</h3><p>' + escapeHTML(sectionCopy) + "</p>" + renderSectionContextChips([
workspaceTotalChipLabel(workspaces.length),
readyWorkspaceSectionChipLabel(readyCount),
suspendedWorkspaceSectionChipLabel(suspendedCount)
]) + '</div><div class="account-stage-header-actions">' + workspaceHeaderActions + '</div></div></div><div class="workspace-operations-shell workspace-operations-shell-idle" id="workspace-operations-shell-' + escapeAttr(account.id) + '"><div class="workspace-operations-main">' + workspaceHTML + '</div><div class="workspace-operations-detail" id="workspace-operations-detail-' + escapeAttr(account.id) + '" hidden>' + workspaceManagement + "</div></div></section>";
return '<section class="account-content-panel account-content-panel-workspaces">' + renderAccountBlockHeader(
account,
[
workspaceTotalChipLabel(workspaces.length),
readyWorkspaceSectionChipLabel(readyCount),
reviewWorkspaceChipLabel(attentionCount),
suspendedWorkspaceSectionChipLabel(suspendedCount)
],
workspaceHeaderActions
) + '<div class="workspace-operations-shell workspace-operations-shell-idle" id="workspace-operations-shell-' + escapeAttr(account.id) + '"><div class="workspace-operations-detail" id="workspace-operations-detail-' + escapeAttr(account.id) + '" hidden>' + workspaceManagement + '</div><div class="workspace-operations-main">' + workspaceHTML + "</div></div></section>";
}
function renderAccountAccessSection(account) {
var accessHeaderTitle = account.can_manage ? "Manage access" : "Review access";
var accessHeaderCopy = account.can_manage ? "Review the hosted roster, then open one access job at a time." : "Review who already has access to this hosted account. An owner or admin must make changes.";
var accessTaskStrip = account.can_manage ? '<div class="access-task-strip"><button type="button" class="access-task-button" id="access-task-invite-' + escapeAttr(account.id) + '" data-action="set-access-job" data-account-id="' + escapeAttr(account.id) + '" data-access-job="invite">Invite people</button><button type="button" class="access-task-button" id="access-task-change_role-' + escapeAttr(account.id) + '" data-action="set-access-job" data-account-id="' + escapeAttr(account.id) + '" data-access-job="change_role">Change roles</button><button type="button" class="access-task-button" id="access-task-remove-' + escapeAttr(account.id) + '" data-action="set-access-job" data-account-id="' + escapeAttr(account.id) + '" data-access-job="remove">Remove access</button></div>' : renderSectionContextChips(["View roster", "Owner or admin required"]);
var accessRoleGuide = '<div class="access-policy-panel"><div class="access-panel-heading"><h4>' + (account.can_manage ? "Choose the smallest role" : "Role meanings") + "</h4><p>" + (account.can_manage ? "Match each person to the narrowest role that still lets them do the job they own." : "Use these role meanings to understand what each person on this roster can do.") + '</p></div><div class="access-policy-list"><div class="access-policy-row"><strong>Owner</strong><span>Full account, billing, and access control.</span></div><div class="access-policy-row"><strong>Admin</strong><span>Workspace control, billing, and roster management.</span></div><div class="access-policy-row"><strong>Tech</strong><span>Workspace control without billing or roster ownership.</span></div><div class="access-policy-row"><strong>Read-only</strong><span>Review access without control-plane changes.</span></div></div></div>';
var accessInvitePanel = account.can_manage ? '<div class="access-invite-panel"><div class="access-panel-heading"><h4>Invite people</h4><p>Add one person with the minimum role they need on this account.</p></div><div class="access-invite"><div><label for="invite-email-' + escapeAttr(account.id) + '">Email</label><input type="email" id="invite-email-' + escapeAttr(account.id) + '" placeholder="user@example.com" autocomplete="off"></div><div><label for="invite-role-' + escapeAttr(account.id) + '">Role</label><select id="invite-role-' + escapeAttr(account.id) + '"><option value="admin">Admin</option><option value="tech">Tech</option><option value="read_only">Read-only</option></select></div><button type="button" class="btn-primary btn-compact" data-action="invite-member" data-account-id="' + escapeAttr(account.id) + '">Invite</button></div></div>' : "";
var accessChangeRolePanel = '<div class="access-job-note-panel"><div class="access-panel-heading"><h4>Change roles on the roster</h4><p>Use the role column in the roster to change one person at a time. Keep each person on the smallest role they need.</p></div></div>' + accessRoleGuide;
var accessRemovePanel = '<div class="access-job-note-panel"><div class="access-panel-heading"><h4>Remove stale access</h4><p>Use removal only when this person should no longer be on this hosted account. Owners may still be protected when they are the last owner.</p></div><div class="access-remove-points"><div class="access-remove-point"><strong>Pick the exact person</strong><span>Use the roster to remove one account member at a time.</span></div><div class="access-remove-point"><strong>Keep current owners safe</strong><span>The last owner cannot be removed until another owner exists.</span></div></div></div>';
return '<section class="account-content-panel account-content-panel-access"><section class="access-management-panel access-section access-section-shell" id="access-section-' + escapeAttr(account.id) + '" data-actor-role="' + escapeAttr(account.role) + '" data-can-manage="' + escapeAttr(account.can_manage ? "true" : "false") + '"><div class="access-management-header"><div><div class="account-panel-kicker">Access</div><h3>' + accessHeaderTitle + "</h3><p>" + accessHeaderCopy + "</p>" + accessTaskStrip + '</div></div><div class="access-management-stats" id="access-stats-' + escapeAttr(account.id) + '"></div><div class="access-shell access-shell-idle" id="access-shell-' + escapeAttr(account.id) + '"><div class="access-shell-main"><div class="access-roster-column"><div class="access-roster"><div class="access-panel-heading"><h4>People on this account</h4><p>' + (account.can_manage ? "Review the hosted roster here, then open the exact access job you need." : "Review the hosted roster here. An owner or admin must make changes.") + '</p></div><div class="access-roster-list" id="access-list-' + escapeAttr(account.id) + '"><div class="access-list-message">Loading\u2026</div></div></div></div></div>' + (account.can_manage ? '<div class="access-shell-detail" id="access-detail-' + escapeAttr(account.id) + '" hidden><div class="access-task-panel" id="access-task-panel-' + escapeAttr(account.id) + '" hidden><div class="access-task-header"><div><div class="account-panel-kicker">Access task</div><h4 id="access-task-title-' + escapeAttr(account.id) + '">Invite people</h4><p id="access-task-copy-' + escapeAttr(account.id) + '"></p></div><button type="button" class="btn-secondary btn-compact" data-action="clear-access-job" data-account-id="' + escapeAttr(account.id) + '">Close panel</button></div><div class="access-task-body" id="access-task-body-invite-' + escapeAttr(account.id) + '" hidden>' + accessInvitePanel + accessRoleGuide + '</div><div class="access-task-body" id="access-task-body-change_role-' + escapeAttr(account.id) + '" hidden>' + accessChangeRolePanel + '</div><div class="access-task-body" id="access-task-body-remove-' + escapeAttr(account.id) + '" hidden>' + accessRemovePanel + "</div></div></div>" : "") + "</div></section></section>";
return '<section class="account-content-panel account-content-panel-access"><section class="access-management-panel access-section access-section-shell" id="access-section-' + escapeAttr(account.id) + '" data-actor-role="' + escapeAttr(account.role) + '" data-can-manage="' + escapeAttr(account.can_manage ? "true" : "false") + '">' + renderAccountBlockHeader(
account,
[account.can_manage ? "Manage access" : "Review roster"],
accessTaskStrip,
account.can_manage ? "Open one access task at a time, then use the roster below." : "Review who can act on this account. An owner or admin must make changes."
) + '<div class="access-management-stats" id="access-stats-' + escapeAttr(account.id) + '"></div><div class="access-shell access-shell-idle" id="access-shell-' + escapeAttr(account.id) + '">' + (account.can_manage ? '<div class="access-shell-detail" id="access-detail-' + escapeAttr(account.id) + '" hidden><div class="access-task-panel" id="access-task-panel-' + escapeAttr(account.id) + '" hidden><div class="access-task-header"><div><div class="account-panel-kicker">Access task</div><h4 id="access-task-title-' + escapeAttr(account.id) + '">Invite people</h4><p id="access-task-copy-' + escapeAttr(account.id) + '"></p></div><button type="button" class="btn-secondary btn-compact" data-action="clear-access-job" data-account-id="' + escapeAttr(account.id) + '">Close panel</button></div><div class="access-task-body" id="access-task-body-invite-' + escapeAttr(account.id) + '" hidden>' + accessInvitePanel + accessRoleGuide + '</div><div class="access-task-body" id="access-task-body-change_role-' + escapeAttr(account.id) + '" hidden>' + accessChangeRolePanel + '</div><div class="access-task-body" id="access-task-body-remove-' + escapeAttr(account.id) + '" hidden>' + accessRemovePanel + "</div></div></div>" : "") + '<div class="access-shell-main"><div class="access-roster-column"><div class="access-roster"><div class="access-panel-heading"><h4>People on this account</h4><p>' + (account.can_manage ? "Review the hosted roster here, then open the exact access job you need." : "Review the hosted roster here. An owner or admin must make changes.") + '</p></div><div class="access-roster-list" id="access-list-' + escapeAttr(account.id) + '"><div class="access-list-message">Loading\u2026</div></div></div></div></div></div></section></section>';
}
function renderHostedBillingCards(accounts, showSelfHostedCommercial) {
var hostedBillingAccounts = accounts.filter(function(account) {
return account.has_billing;
});
if (!hostedBillingAccounts.length) {
return '<div class="billing-task-card billing-task-card-muted"><div class="account-panel-kicker">Hosted billing</div><h3>No hosted billing attached</h3><p>' + escapeHTML(
showSelfHostedCommercial ? "Use the self-hosted billing tools below only if you are managing a self-hosted purchase." : "Hosted invoices, payment methods, and subscription changes are not attached to this Pulse account right now."
) + "</p></div>";
return '<section class="billing-surface-block billing-surface-block-empty"><div class="billing-surface-header"><div class="account-panel-kicker">Hosted billing</div><h3>No hosted billing attached</h3></div><p>' + escapeHTML(
showSelfHostedCommercial ? "Use the self-hosted billing tools below only when the request belongs to a self-hosted purchase." : "Hosted invoices, payment methods, and subscription changes are not attached to this account."
) + "</p></section>";
}
return hostedBillingAccounts.map(function(account) {
return '<section class="billing-surface-block"><div class="billing-surface-header"><div class="account-panel-kicker">Hosted billing</div><h3>Hosted billing</h3></div><div class="billing-action-list billing-action-list-surface">' + hostedBillingAccounts.map(function(account) {
var actionHTML = account.can_manage ? '<button type="button" class="btn-primary btn-compact" data-action="open-billing" data-account-id="' + escapeAttr(account.id) + '">Open hosted billing</button>' : '<div class="billing-task-note">An owner or admin on this account needs to open hosted billing.</div>';
return '<article class="billing-task-card"><div class="account-panel-kicker">Hosted billing</div><h3>' + escapeHTML(account.name) + '</h3><p>Invoices, payment methods, and hosted subscription changes for this account live here.</p><div class="billing-task-meta">Keep workspace lifecycle work in Workspaces and access changes in Access.</div><div class="billing-task-actions">' + actionHTML + "</div></article>";
}).join("");
return '<article class="billing-action-row billing-action-row-surface"><div class="billing-action-main"><div class="billing-action-copy"><h3>' + escapeHTML(account.name) + '</h3><p>Invoices, payment methods, and hosted subscription changes for this account.</p></div><div class="billing-action-meta">Keep workspace changes in Workspaces and roster changes in Access.</div></div><div class="billing-action-cta">' + actionHTML + "</div></article>";
}).join("") + "</div></section>";
}
function renderBillingTaskPanel(title, copy, panelID, bodyHTML) {
return '<section class="billing-panel" id="' + escapeAttr(panelID) + '" hidden><div class="billing-task-header"><div><div class="account-panel-kicker">Billing task</div><h3>' + escapeHTML(title) + "</h3><p>" + escapeHTML(copy) + '</p></div><button type="button" class="btn-secondary btn-compact" data-account-billing-action="clear-billing-panel">Close panel</button></div><div class="billing-task-body">' + bodyHTML + "</div></section>";
@@ -2465,9 +2452,14 @@
var hostedViewOnly = hasHostedAccounts2 && !canManageHostedTasks;
var supportLead = supportLeadCopy(hasHostedAccounts2, hostedViewOnly, showSelfHostedCommercial);
var supportChips = supportSectionChipLabels(hasHostedAccounts2, hostedViewOnly, supportEmail);
var routeCards = hasHostedAccounts2 ? '<div class="portal-support-route-card"><div class="account-panel-kicker">Workspaces or Access</div><h3>' + hostedSupportRouteTitle(hostedViewOnly) + "</h3><p>" + hostedSupportRouteDescription(hostedViewOnly) + '</p><div class="portal-support-points"><div class="portal-support-point"><strong>Task</strong><span>' + hostedSupportRouteTaskCopy(hostedViewOnly) + '</span></div><div class="portal-support-point"><strong>' + hostedSupportRouteContextLabel(hostedViewOnly) + "</strong><span>" + hostedSupportRouteContextCopy(hostedViewOnly) + '</span></div></div><div class="portal-support-actions"><button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="workspaces">Open Workspaces</button><button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="access">Open Access</button><a class="portal-support-link" href="mailto:' + escapeAttr(supportEmail) + '">' + escapeHTML(supportEmail) + '</a></div></div><div class="portal-support-route-card"><div class="account-panel-kicker">' + (showSelfHostedCommercial ? "Billing" : "Hosted billing") + "</div><h3>" + billingSupportRouteTitle(hostedViewOnly, showSelfHostedCommercial) + "</h3><p>" + billingSupportRouteDescription(hostedViewOnly, showSelfHostedCommercial) + '</p><div class="portal-support-points"><div class="portal-support-point"><strong>Billing job</strong><span>' + billingSupportRouteJobCopy(hostedViewOnly, showSelfHostedCommercial) + '</span></div><div class="portal-support-point"><strong>Account or email</strong><span>' + billingSupportRouteAccountCopy(hostedViewOnly, showSelfHostedCommercial) + '</span></div></div><div class="portal-support-actions"><button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="billing">Open billing</button><a class="portal-support-link" href="mailto:' + escapeAttr(supportEmail) + '">' + escapeHTML(supportEmail) + "</a></div></div>" : '<div class="portal-support-route-card"><div class="account-panel-kicker">Self-hosted billing</div><h3>Self-hosted billing failed</h3><p>Escalate only if a self-hosted billing task still fails.</p><div class="portal-support-points"><div class="portal-support-point"><strong>Billing job</strong><span>Billing, licenses, refunds, or privacy.</span></div><div class="portal-support-point"><strong>Account or email</strong><span>Include the commercial billing email and the failed step.</span></div></div><div class="portal-support-actions"><button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="billing">Open billing</button><a class="portal-support-link" href="mailto:' + escapeAttr(supportEmail) + '">' + escapeHTML(supportEmail) + "</a></div></div>";
var runbookSteps = hasHostedAccounts2 ? '<div class="portal-support-runbook-step"><strong>1. Failed path</strong><span>' + supportRunbookPathCopy(hasHostedAccounts2, hostedViewOnly, showSelfHostedCommercial) + '</span></div><div class="portal-support-runbook-step"><strong>2. Account or email</strong><span>' + supportRunbookAccountCopy(hasHostedAccounts2, showSelfHostedCommercial) + '</span></div><div class="portal-support-runbook-step"><strong>3. Failed action</strong><span>Name the exact button, form, or billing step that failed and what happened next.</span></div>' : '<div class="portal-support-runbook-step"><strong>1. Billing job</strong><span>' + supportRunbookPathCopy(hasHostedAccounts2, hostedViewOnly, showSelfHostedCommercial) + '</span></div><div class="portal-support-runbook-step"><strong>2. Account or email</strong><span>' + supportRunbookAccountCopy(hasHostedAccounts2, showSelfHostedCommercial) + '</span></div><div class="portal-support-runbook-step"><strong>3. Failed action</strong><span>Name the exact button, form, or billing step that failed and what happened next.</span></div>';
return '<section class="portal-support-panel"><div class="account-panel-kicker">Support</div><h2>Support</h2><p>' + escapeHTML(supportLead) + "</p>" + renderSectionContextChips(supportChips) + '<div class="portal-support-layout"><div class="portal-support-route-grid">' + routeCards + '</div><div class="portal-support-runbook"><div class="account-panel-kicker">What to send</div><h3>Send these details</h3><p>Send the failed path, account or email, and failed action.</p><div class="portal-support-runbook-list">' + runbookSteps + "</div></div></div></section>";
var retryCopy = hasHostedAccounts2 ? hostedViewOnly ? "Review Workspaces or Access first. If billing is involved, hand it to an owner or admin before you escalate." : "Retry the same Workspaces, Access, or Billing step before you escalate." : "Retry the same Billing step before you escalate.";
var supportActions = hasHostedAccounts2 ? '<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="workspaces">Workspaces</button><button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="access">Access</button><button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="billing">Billing</button>' : '<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="billing">Billing</button>';
return '<section class="portal-support-panel">' + renderPageHeader(
"Support",
"Support",
supportLead,
renderFactLine("portal-page-facts", supportChips)
) + '<div class="portal-support-simple"><div class="portal-support-simple-card"><div class="portal-support-simple-list"><div class="portal-support-simple-row"><strong>Try first</strong><span>' + escapeHTML(retryCopy) + '</span></div><div class="portal-support-simple-row"><strong>Path</strong><span>' + escapeHTML(supportRunbookPathCopy(hasHostedAccounts2, hostedViewOnly, showSelfHostedCommercial)) + '</span></div><div class="portal-support-simple-row"><strong>Account or email</strong><span>' + escapeHTML(supportRunbookAccountCopy(hasHostedAccounts2, showSelfHostedCommercial)) + '</span></div><div class="portal-support-simple-row"><strong>Failed action</strong><span>Name the exact button, form, or billing step that failed and what happened next.</span></div></div><div class="portal-support-simple-actions">' + supportActions + '<a class="portal-support-link" href="mailto:' + escapeAttr(supportEmail) + '">' + escapeHTML(supportEmail) + "</a></div></div></div></section>";
}
function renderHeaderHTML(context) {
if (context.bootstrap.authenticated) {
@@ -2484,10 +2476,11 @@
var activeSection = shellSections.some(function(entry) {
return entry.section === preferredSection;
}) ? preferredSection : shellSections[0] ? shellSections[0].section : "billing";
var allWorkspaces = collectWorkspaces(accounts);
var hostedBillingCount = accounts.filter(function(account) {
return account.has_billing;
}).length;
var billingNote = hosted ? showSelfHostedCommercial ? "Use hosted billing first when the request belongs to a hosted workspace account. Self-hosted licenses, refunds, and privacy stay separate underneath it." : "Use this billing surface only for hosted billing on your hosted workspace accounts." : "Use this billing surface only for self-hosted subscriptions, licenses, refunds, and privacy requests.";
var billingNote = hosted ? showSelfHostedCommercial ? "Hosted billing by account. Self-hosted purchases stay separate below." : "Hosted billing by account." : "Self-hosted subscriptions, licenses, refunds, and privacy requests.";
var selfHostedBillingEscalationCopy = hosted ? "Escalate with the same hosted billing action or self-hosted path and the exact failed step." : "Escalate with the same self-hosted billing path and the exact failed step.";
var workspacesContent = accounts.length ? accounts.map(function(account) {
return '<section class="account-surface">' + renderAccountWorkspaceSection(account, context.accountAPIBasePath) + "</section>";
@@ -2495,7 +2488,30 @@
var accessContent = accounts.length ? accounts.map(function(account) {
return '<section class="account-surface">' + renderAccountAccessSection(account) + "</section>";
}).join("") : renderNoHostedAccessSection();
return '<div class="portal-shell" data-shell-section="' + activeSection + '"><div class="portal-shell-main">' + renderPortalContextStrip(accounts, showSelfHostedCommercial) + renderShellNavigation(context.bootstrap, activeSection) + (hosted ? '<section class="portal-content-panel portal-content-panel-workspaces">' + workspacesContent + '</section><section class="portal-content-panel portal-content-panel-access">' + accessContent + '</section><section class="portal-content-panel portal-content-panel-overview">' + renderShellOverviewSection(context) + "</section>" : "") + '<section class="portal-content-panel portal-content-panel-billing billing-section" id="billing-section"><div class="billing-header"><div><div class="account-panel-kicker">Billing</div><h2>Billing</h2>' + renderSectionContextChips(billingHeaderChipLabels(hostedBillingCount, showSelfHostedCommercial)) + '</div><div class="billing-note">' + billingNote + "</div></div>" + (hosted ? '<div class="billing-overview-grid">' + renderHostedBillingCards(accounts, showSelfHostedCommercial) + "</div>" : "") + (showSelfHostedCommercial ? '<div class="billing-shell billing-shell-idle"><div class="billing-shell-main"><div class="billing-shell-main-head"><div class="account-panel-kicker">Self-hosted billing</div><h3>Pick the self-hosted job</h3><p>Use self-hosted billing only for self-hosted purchases. Open one path at a time when hosted billing does not apply.</p></div><div class="billing-action-list">' + renderBillingActionRow("open-manage-billing", "Manage subscriptions", "Billing", "Open Stripe for self-hosted plan, invoice, and payment changes.", "manage-billing-panel", "manage-inline-email", ["Plan changes", "Invoices"]) + renderBillingActionRow("open-retrieve-billing", "Retrieve licenses", "Licenses", "Recover the latest active self-hosted license and invoice link.", "retrieve-billing-panel", "retrieve-inline-email", ["Latest active license", "Invoice lookup"]) + renderBillingActionRow("open-refund-billing", "Refund requests", "Refunds", "Request a self-serve refund when the purchase is still eligible.", "refund-billing-panel", "refund-inline-email", ["Eligibility check", "Revocation"]) + renderBillingActionRow("open-data-billing", "Data and privacy", "Privacy", "Request export or deletion for commercial account data.", "data-billing-panel", "data-export-email", ["Export", "Deletion"]) + '</div><div class="billing-inline-support"><div class="account-panel-kicker">Escalation only</div><h4>Use Support only after Billing fails</h4><p>' + selfHostedBillingEscalationCopy + '</p><div class="billing-inline-support-actions"><button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="support">Open support</button><a class="portal-support-link" href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + '</a></div></div></div><div class="billing-shell-detail" id="billing-detail-shell" hidden>' + renderBillingTaskPanel(
return '<div class="portal-shell" data-shell-section="' + activeSection + '"><div class="portal-shell-main">' + renderPortalContextStrip(accounts, showSelfHostedCommercial) + renderShellNavigation(context.bootstrap, activeSection) + (hosted ? '<section class="portal-content-panel portal-content-panel-workspaces">' + renderPageHeader(
"Workspaces",
"Workspaces",
"Open a workspace or manage lifecycle by account.",
renderSummaryStrip([
{ label: "Accounts", value: String(accounts.length) },
{ label: "Workspaces", value: String(allWorkspaces.length) },
{ label: "Ready", value: String(countReadyWorkspaces(allWorkspaces)) },
{ label: "Needs review", value: String(attentionWorkspaces(allWorkspaces).length) },
{ label: "Suspended", value: String(countWorkspacesByState(allWorkspaces, "suspended")) }
])
) + workspacesContent + '</section><section class="portal-content-panel portal-content-panel-access">' + renderPageHeader(
"Access",
"Access",
"Review the roster or open one access task by account."
) + accessContent + '</section><section class="portal-content-panel portal-content-panel-overview">' + renderShellOverviewSection(context) + "</section>" : "") + '<section class="portal-content-panel portal-content-panel-billing billing-section" id="billing-section">' + renderPageHeader(
"Billing",
"Billing",
billingNote,
renderSummaryStrip([
{ label: "Hosted billing", value: String(hostedBillingCount) },
{ label: "Self-hosted tasks", value: showSelfHostedCommercial ? "Available" : "Not attached" }
])
) + (hosted ? renderHostedBillingCards(accounts, showSelfHostedCommercial) : "") + (showSelfHostedCommercial ? '<div class="billing-shell billing-shell-idle"><div class="billing-shell-main"><div class="billing-shell-main-head"><div class="account-panel-kicker">Self-hosted billing</div><h3>Pick the self-hosted job</h3><p>Use self-hosted billing only for self-hosted purchases. Open one path at a time when hosted billing does not apply.</p></div><div class="billing-action-list">' + renderBillingActionRow("open-manage-billing", "Manage subscriptions", "Billing", "Open Stripe for self-hosted plan, invoice, and payment changes.", "manage-billing-panel", "manage-inline-email", ["Plan changes", "Invoices"]) + renderBillingActionRow("open-retrieve-billing", "Retrieve licenses", "Licenses", "Recover the latest active self-hosted license and invoice link.", "retrieve-billing-panel", "retrieve-inline-email", ["Latest active license", "Invoice lookup"]) + renderBillingActionRow("open-refund-billing", "Refund requests", "Refunds", "Request a self-serve refund when the purchase is still eligible.", "refund-billing-panel", "refund-inline-email", ["Eligibility check", "Revocation"]) + renderBillingActionRow("open-data-billing", "Data and privacy", "Privacy", "Request export or deletion for commercial account data.", "data-billing-panel", "data-export-email", ["Export", "Deletion"]) + '</div><div class="billing-inline-support"><div class="account-panel-kicker">Escalation only</div><h4>Use Support only after Billing fails</h4><p>' + selfHostedBillingEscalationCopy + '</p><div class="billing-inline-support-actions"><button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="support">Open support</button><a class="portal-support-link" href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + '</a></div></div></div><div class="billing-shell-detail" id="billing-detail-shell" hidden>' + renderBillingTaskPanel(
"Manage subscriptions",
"Open Stripe for self-hosted plan, invoice, and payment changes.",
"manage-billing-panel",
@@ -131,45 +131,31 @@ describe('shell view', function() {
);
expect(html).toContain('portal-shell-nav');
expect(html).toContain('Overview');
expect(html).toContain('Summary');
expect(html).toContain('Access');
expect(html).toContain('Billing');
expect(html).toContain('Support');
expect(html).toContain('1 account');
expect(html).toContain('3 workspaces');
expect(html).toContain('1 ready workspace');
expect(html).toContain('Accounts');
expect(html).toContain('Workspaces');
expect(html).toContain('Ready');
expect(html).toContain('2 workspaces to review');
expect(html).toContain('id="billing-section"');
expect(html).toContain('portal-account-context');
expect(html).toContain('Owner role');
expect(html).toContain('Hosted billing attached');
expect(html).toContain('MSP account');
expect(html).toContain('Acme MSP');
expect(html).toContain('Manage workspaces, access, and billing for this account.');
expect(html).toContain('3 workspaces');
expect(html).toContain('Create workspace');
expect(html).not.toContain('Manage billing');
expect(html).not.toContain('Manage team');
expect(html).toContain('Current state');
expect(html).toContain('Hosted workspace counts, current state, and the next step.');
expect(html).toContain('section-context-strip');
expect(html).toContain('Needs attention');
expect(html).toContain('Ready');
expect(html).toContain('Next action');
expect(html).toContain('2 workspaces need review');
expect(html).toContain('1 workspace is ready to use');
expect(html).toContain('Open Workspaces');
expect(html).toContain('Open Access');
expect(html).toContain('account-stage-header-actions');
expect(html).toContain('portal-summary-strip');
expect(html).toContain('0 suspended workspaces');
expect(html).toContain('Alpha Workspace');
expect(html).toContain('Beta Workspace');
expect(html).toContain('Gamma Workspace');
expect(html).toContain('Needs attention');
expect(html).toContain('1 workspace ready to use');
expect(html).toContain('Unhealthy</span>');
expect(html).toContain('Checking</span>');
expect(html).toContain('Ready');
expect(html).toContain('This workspace is in a failed state.');
expect(html).toContain('Latest health check is still pending.');
expect(html).toContain('/api/accounts/acct_1/tenants/ws_active/handoff');
@@ -189,7 +175,7 @@ describe('shell view', function() {
expect(html).toContain('Invite people');
expect(html).toContain('Change roles');
expect(html).toContain('Remove access');
expect(html).toContain('Review the hosted roster, then open one access job at a time.');
expect(html).toContain('Open one access task at a time, then use the roster below.');
expect(html).toContain('data-action="set-access-job"');
expect(html).toContain('Access task');
expect(html).toContain('id="access-detail-acct_1" hidden');
@@ -202,20 +188,17 @@ describe('shell view', function() {
expect(html).toContain('data-can-manage="true"');
expect(html).toContain('Remove stale access');
expect(html).toContain('data-action="workspace-action"');
expect(html).toContain('Hosted billing attached');
expect(html).toContain('Hosted billing only');
expect(html).toContain('Use this billing surface only for hosted billing on your hosted workspace accounts.');
expect(html).toContain('Hosted billing');
expect(html).toContain('Not attached');
expect(html).toContain('Hosted billing by account.');
expect(html).toContain('Escalation only');
expect(html).toContain('Open Workspaces, Access, or Billing first');
expect(html).toContain('Email support');
expect(html).toContain('Workspaces or Access');
expect(html).toContain('Workspace or access failed');
expect(html).toContain('Open Workspaces');
expect(html).toContain('Open Access');
expect(html).toContain('Hosted billing failed');
expect(html).toContain('Send these details');
expect(html).toContain('What to send');
expect(html).toContain('Open billing');
expect(html).toContain('Try first');
expect(html).toContain('Path');
expect(html).toContain('Account or email');
expect(html).toContain('Failed action');
expect(html).toContain('Billing');
expect(html).not.toContain('Hosted only');
expect(html).not.toContain('Self-hosted tools');
expect(html).not.toContain('Self-hosted billing');
@@ -284,16 +267,17 @@ describe('shell view', function() {
})
);
expect(html).toContain('Self-hosted billing available');
expect(html).toContain('Self-hosted tasks');
expect(html).toContain('Available');
expect(html).toContain('Self-hosted billing');
expect(html).toContain('Pick the self-hosted job');
expect(html).toContain('Use hosted billing first when the request belongs to a hosted workspace account.');
expect(html).toContain('Hosted billing by account. Self-hosted purchases stay separate below.');
expect(html).toContain('Use self-hosted billing only for self-hosted purchases.');
expect(html).toContain('id="open-retrieve-billing"');
expect(html).toContain('id="billing-detail-shell" hidden');
expect(html).toContain('data-account-billing-action="clear-billing-panel"');
expect(html).toContain('id="data-billing-panel"');
expect(html).toContain('Billing failed');
expect(html).toContain('Use Support only after Workspaces, Access, or Billing fails.');
expect(html).toContain('licenses, refunds, or privacy');
expect(html).toContain('Escalate with the same hosted billing action or self-hosted path and the exact failed step.');
});
@@ -340,7 +324,6 @@ describe('shell view', function() {
})
);
var overviewIndex = html.indexOf('data-shell-action="activate-section" data-shell-section="overview"');
var workspacesIndex = html.indexOf('data-shell-action="activate-section" data-shell-section="workspaces"');
var accessIndex = html.indexOf('data-shell-action="activate-section" data-shell-section="access"');
var billingIndex = html.indexOf('data-shell-action="activate-section" data-shell-section="billing"');
@@ -350,7 +333,6 @@ describe('shell view', function() {
expect(accessIndex).toBeGreaterThan(workspacesIndex);
expect(billingIndex).toBeGreaterThan(accessIndex);
expect(supportIndex).toBeGreaterThan(billingIndex);
expect(overviewIndex).toBeGreaterThan(supportIndex);
expect(html).not.toContain('Services');
});
@@ -387,7 +369,8 @@ describe('shell view', function() {
expect((html.match(/portal-account-context-stat/g) || []).length).toBe(0);
expect((html.match(/account-context-chip\"/g) || []).length).toBe(0);
expect(html).toContain('Admin role');
expect(html).toContain('Manage workspaces, access, and billing for this account.');
expect(html).toContain('Cloud account');
expect(html).not.toContain('Manage workspaces, access, and billing for this account.');
expect(html).not.toContain('portal-account-context-summary');
});
@@ -420,38 +403,24 @@ describe('shell view', function() {
})
);
expect(html).toContain('Open a workspace and review current state here. An owner or admin must create or change hosted workspaces.');
expect(html).toContain('Tech role');
expect(html).toContain('Open workspaces and review access here. Billing and account changes require an owner or admin.');
expect(html).toContain('0 workspaces need review');
expect(html).toContain('1 workspace is ready to use');
expect(html).toContain('Owner or admin required');
expect(html).toContain('Review who already has access to this hosted account. An owner or admin must make changes.');
expect(html).toContain('Review who can act on this account. An owner or admin must make changes.');
expect(html).toContain('Review the hosted roster here. An owner or admin must make changes.');
expect(html).toContain('An owner or admin on this account needs to open hosted billing.');
expect(html).toContain('Escalation only');
expect(html).toContain('Use Support only after Workspaces review, Access review, owner/admin handoff, or hosted Billing fails.');
expect(html).toContain('Review Workspaces or Access, or contact owner/admin first');
expect(html).toContain('Email support');
expect(html).toContain('Workspaces or Access');
expect(html).toContain('Review or owner/admin handoff failed');
expect(html).toContain('Recheck the same workspace or roster first. Escalate only if the required owner or admin change still cannot complete.');
expect(html).toContain('Task');
expect(html).toContain('Use Workspaces for workspace state or Access for roster state before you escalate.');
expect(html).toContain('Owner/admin handoff');
expect(html).toContain('Include the account, workspace, and the change that still needs owner or admin action.');
expect(html).toContain('Open Workspaces');
expect(html).toContain('Open Access');
expect(html).toContain('Hosted billing or owner/admin handoff failed');
expect(html).toContain('Escalate only if hosted billing still needs owner/admin action or still fails.');
expect(html).toContain('Hosted billing. Say if it still needed owner/admin action.');
expect(html).toContain('Include the hosted account that still needed owner/admin billing action.');
expect(html).toContain('Try first');
expect(html).toContain('Review Workspaces or Access first. If billing is involved, hand it to an owner or admin before you escalate.');
expect(html).toContain('Path');
expect(html).toContain('Workspaces review, Access review, owner/admin handoff, or hosted billing.');
expect(html).toContain('Account or email');
expect(html).toContain('Hosted account and workspace, or hosted billing account.');
expect(html).toContain('data-can-manage="false"');
expect(html).not.toContain('Invite people, change roles, and remove account access.');
expect(html).not.toContain('Open a workspace, review lifecycle state, or create one.');
expect(html).not.toContain('Open a workspace, review lifecycle state, or create a new one without mixing in access or billing work.');
expect(html).not.toContain('data-action="invite-member"');
expect(html).not.toContain('data-action="set-access-job"');
expect(html).not.toContain('Self-hosted billing, licenses, refunds, and privacy stay in Billing.');
@@ -494,7 +463,7 @@ describe('shell view', function() {
expect(html).not.toContain('READ_ONLY');
});
it('keeps next action permission-honest for hosted view-only accounts with no workspace ready', function() {
it('keeps hosted view-only empty accounts on plain review surfaces', function() {
var html = renderAuthenticatedPortalHTML(
createContext({
bootstrap: createBootstrap({
@@ -515,14 +484,12 @@ describe('shell view', function() {
})
);
expect(html).toContain('Review who can act');
expect(html).toContain('Review Access to see who can create or manage the first workspace on this account.');
expect(html).toContain('Open Access');
expect(html).not.toContain('Choose the right task path');
expect(html).not.toContain('If this is an access change, go to Access. If it is a billing or license issue, go to Billing. Support is only for escalation.');
expect(html).toContain('No hosted workspaces are attached yet. An owner or admin must create the first one.');
expect(html).toContain('Review who can act on this account. An owner or admin must make changes.');
expect(html).toContain('Owner or admin required');
});
it('keeps ready state honest for hosted view-only accounts with no workspace yet', function() {
it('keeps workspaces empty state honest for hosted view-only accounts with no workspace yet', function() {
var html = renderAuthenticatedPortalHTML(
createContext({
bootstrap: createBootstrap({
@@ -543,12 +510,12 @@ describe('shell view', function() {
})
);
expect(html).toContain('0 workspaces are ready to use');
expect(html).toContain('0 workspaces ready to use');
expect(html).toContain('No hosted workspace exists yet. An owner or admin must create the first one.');
expect(html).not.toContain('Open Workspaces to see the current state of each hosted workspace.');
});
it('keeps ready state honest for managed hosted accounts with no workspace yet', function() {
it('keeps workspaces empty state honest for managed hosted accounts with no workspace yet', function() {
var html = renderAuthenticatedPortalHTML(
createContext({
bootstrap: createBootstrap({
@@ -569,12 +536,12 @@ describe('shell view', function() {
})
);
expect(html).toContain('0 workspaces are ready to use');
expect(html).toContain('0 workspaces ready to use');
expect(html).toContain('No hosted workspace exists yet. Create the first one in Workspaces.');
expect(html).not.toContain('Open Workspaces to see the current state of each hosted workspace.');
});
it('keeps next action on review surfaces for suspended hosted view-only accounts', function() {
it('keeps suspended hosted view-only accounts on review surfaces', function() {
var html = renderAuthenticatedPortalHTML(
createContext({
bootstrap: createBootstrap({
@@ -603,14 +570,12 @@ describe('shell view', function() {
})
);
expect(html).toContain('Review workspace state');
expect(html).toContain('Open Workspaces to review current state, then hand off changes to an owner or admin.');
expect(html).toContain('Open Workspaces');
expect(html).toContain('Open Access');
expect(html).not.toContain('Choose the right task path');
expect(html).toContain('Paused Workspace');
expect(html).toContain('1 workspace suspended');
expect(html).toContain('Owner or admin required');
});
it('keeps overview attention copy honest when only suspended workspaces remain', function() {
it('keeps workspace facts honest when only suspended workspaces remain', function() {
var html = renderAuthenticatedPortalHTML(
createContext({
bootstrap: createBootstrap({
@@ -639,8 +604,8 @@ describe('shell view', function() {
})
);
expect(html).toContain('0 workspaces are ready to use');
expect(html).toContain('1 workspace is suspended. Resume it before opening it again.');
expect(html).toContain('0 workspaces ready to use');
expect(html).toContain('1 workspace suspended');
expect(html).not.toContain('Active workspaces look clear for routine use.');
});
@@ -658,17 +623,18 @@ describe('shell view', function() {
expect(html).not.toContain('Current state');
expect(html).not.toContain('data-shell-section="workspaces"');
expect(html).not.toContain('data-shell-section="access"');
expect(html).toContain('Use this billing surface only for self-hosted subscriptions, licenses, refunds, and privacy requests.');
expect(html).toContain('Use Billing for self-hosted subscriptions, licenses, refunds, and privacy requests.');
expect(html).toContain('Self-hosted subscriptions, licenses, refunds, and privacy requests.');
expect(html).toContain('Use self-hosted billing only for self-hosted purchases. Open one path at a time when hosted billing does not apply.');
expect(html).toContain('Escalate with the same self-hosted billing path and the exact failed step.');
expect(html).toContain('Billing');
expect(html).toContain('Self-hosted billing available');
expect(html).toContain('Self-hosted tasks');
expect(html).toContain('Available');
expect(html).toContain('Open Billing first');
expect(html).toContain('Email support');
expect(html).toContain('Use Support only after Billing fails.');
expect(html).toContain('Self-hosted billing failed');
expect(html).toContain('Account or email');
expect(html).toContain('No hosted billing attached');
expect(html).toContain('Commercial billing email used for the self-hosted purchase.');
expect(html).not.toContain('Escalate with the same hosted billing action or self-hosted path and the exact failed step.');
expect(html).not.toContain('Workspace or access path failed');
expect(html).not.toContain('Hosted workspace or access');
@@ -744,11 +710,10 @@ describe('shell view', function() {
);
expect(html).toContain('Suspended');
expect(html).toContain('0 workspaces need review');
expect(html).toContain('0 workspaces are ready to use');
expect(html).toContain('Every hosted workspace is suspended right now.');
expect(html).toContain('0 workspaces to review');
expect(html).toContain('0 workspaces ready to use');
expect(html).toContain('Create workspace');
expect(html).toContain('1 workspace is suspended. Resume it before opening it again.');
expect(html).toContain('1 workspace suspended');
});
it('preserves the simplified top-tab shell hooks in the rendered shell', function() {
+218 -232
View File
@@ -120,70 +120,6 @@ function supportLeadCopy(hasHostedAccounts: boolean, hostedViewOnly: boolean, sh
: 'Use Support only after Workspaces, Access, or hosted Billing fails.';
}
function hostedSupportRouteTitle(hostedViewOnly: boolean): string {
return hostedViewOnly ? 'Review or owner/admin handoff failed' : 'Workspace or access failed';
}
function hostedSupportRouteDescription(hostedViewOnly: boolean): string {
return hostedViewOnly
? 'Recheck the same workspace or roster first. Escalate only if the required owner or admin change still cannot complete.'
: 'Retry the same workspace or access task first. Escalate only if the same task still fails.';
}
function hostedSupportRouteTaskCopy(hostedViewOnly: boolean): string {
return hostedViewOnly
? 'Use Workspaces for workspace state or Access for roster state before you escalate.'
: 'Use Workspaces for lifecycle work or Access for roster work before you escalate.';
}
function hostedSupportRouteContextLabel(hostedViewOnly: boolean): string {
return hostedViewOnly ? 'Owner/admin handoff' : 'Failed step';
}
function hostedSupportRouteContextCopy(hostedViewOnly: boolean): string {
return hostedViewOnly
? 'Include the account, workspace, and the change that still needs owner or admin action.'
: 'Include the account, workspace, and the failed step.';
}
function billingSupportRouteTitle(hostedViewOnly: boolean, showSelfHostedCommercial: boolean): string {
if (hostedViewOnly) {
return showSelfHostedCommercial ? 'Billing or owner/admin handoff failed' : 'Hosted billing or owner/admin handoff failed';
}
return showSelfHostedCommercial ? 'Billing failed' : 'Hosted billing failed';
}
function billingSupportRouteDescription(hostedViewOnly: boolean, showSelfHostedCommercial: boolean): string {
if (hostedViewOnly) {
return showSelfHostedCommercial
? 'Escalate only if hosted billing still needs owner/admin action or a self-hosted billing task still fails.'
: 'Escalate only if hosted billing still needs owner/admin action or still fails.';
}
return showSelfHostedCommercial
? 'Escalate only if hosted billing or a self-hosted billing task still fails.'
: 'Escalate only if hosted billing still fails.';
}
function billingSupportRouteJobCopy(hostedViewOnly: boolean, showSelfHostedCommercial: boolean): string {
if (hostedViewOnly) {
return showSelfHostedCommercial
? 'Hosted billing, licenses, refunds, or privacy. Say if hosted billing still needed owner/admin action.'
: 'Hosted billing. Say if it still needed owner/admin action.';
}
return showSelfHostedCommercial
? 'Hosted billing, licenses, refunds, or privacy.'
: 'Hosted billing.';
}
function billingSupportRouteAccountCopy(hostedViewOnly: boolean, showSelfHostedCommercial: boolean): string {
if (showSelfHostedCommercial) {
return 'Include the hosted account for hosted billing or the billing email for self-hosted work.';
}
return hostedViewOnly
? 'Include the hosted account that still needed owner/admin billing action.'
: 'Include the hosted account for the failed billing step.';
}
function supportRunbookPathCopy(hasHostedAccounts: boolean, hostedViewOnly: boolean, showSelfHostedCommercial: boolean): string {
if (!hasHostedAccounts) return 'Billing, licenses, refunds, or privacy.';
if (hostedViewOnly) {
@@ -307,6 +243,33 @@ function renderSectionContextChips(chips: string[]): string {
}).join('') + '</div>';
}
function renderFactLine(className: string, facts: string[]): string {
if (!facts.length) return '';
return (
'<div class="' + className + '">' +
facts.map(function(fact) {
return '<span>' + escapeHTML(fact) + '</span>';
}).join('<span class="portal-fact-separator">•</span>') +
'</div>'
);
}
function renderSummaryStrip(items: Array<{ label: string; value: string }>): string {
if (!items.length) return '';
return (
'<div class="portal-summary-strip">' +
items.map(function(item) {
return (
'<div class="portal-summary-item">' +
'<span>' + escapeHTML(item.label) + '</span>' +
'<strong>' + escapeHTML(item.value) + '</strong>' +
'</div>'
);
}).join('') +
'</div>'
);
}
function attentionWorkspaces(workspaces: PortalWorkspaceSummary[]): PortalWorkspaceSummary[] {
var results: PortalWorkspaceSummary[] = [];
for (var i = 0; i < workspaces.length; i += 1) {
@@ -338,14 +301,14 @@ function renderAccountContextStrip(account: PortalAccountSummary): string {
'<section class="portal-account-context">' +
'<div class="portal-account-context-copy">' +
'<div class="portal-account-context-meta">' +
'<span class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + '</span>' +
'<span class="account-eyebrow">Pulse Account</span>' +
'<span class="portal-account-context-separator">•</span>' +
'<span class="portal-account-context-access">' + escapeHTML(accountContextRoleMeta(account)) + '</span>' +
'<span class="portal-account-context-access">' + escapeHTML(accountKindLabel(account)) + '</span>' +
'</div>' +
'<div class="portal-account-context-row portal-account-context-row-title">' +
'<h2>' + escapeHTML(account.name) + '</h2>' +
'</div>' +
'<p>' + escapeHTML(accountContextLeadCopy(account)) + '</p>' +
'<p>' + escapeHTML(accountContextRoleMeta(account)) + '</p>' +
'</div>' +
'</section>'
);
@@ -365,9 +328,9 @@ function renderPortalContextStrip(accounts: PortalAccountSummary[], showSelfHost
'<span class="portal-account-context-access">' + escapeHTML(accounts.length === 1 ? '1 hosted account' : String(accounts.length) + ' hosted accounts') + '</span>' +
'</div>' +
'<div class="portal-account-context-row portal-account-context-row-title">' +
'<h2>Accounts</h2>' +
'<h2>Hosted accounts</h2>' +
'</div>' +
'<p>Open workspaces, review access, and handle billing from one account surface.</p>' +
'<p>Workspaces, access, and billing by account.</p>' +
'</div>' +
'</section>'
);
@@ -394,7 +357,7 @@ interface ShellNavEntry {
title: string;
}
function visibleShellSections(bootstrap: PortalBootstrapData): ShellNavEntry[] {
function primaryShellSections(bootstrap: PortalBootstrapData): ShellNavEntry[] {
var accounts = Array.isArray(bootstrap.accounts) ? bootstrap.accounts : [];
var hosted = hasHostedAccounts(accounts);
var showSelfHostedCommercial = hasSelfHostedCommercial(bootstrap);
@@ -413,16 +376,25 @@ function visibleShellSections(bootstrap: PortalBootstrapData): ShellNavEntry[] {
if (hostedBillingCount > 0 || showSelfHostedCommercial) {
sections.push({ section: 'billing', title: 'Billing' });
}
sections.push({ section: 'support', title: 'Support' });
if (hosted) {
sections.push({ section: 'overview', title: 'Overview' });
}
return sections;
}
function shellSectionButton(section: PortalShellSection, activeSection: PortalShellSection, title: string): string {
function utilityShellSections(_bootstrap: PortalBootstrapData): ShellNavEntry[] {
return [{ section: 'support', title: 'Support' }];
}
function visibleShellSections(bootstrap: PortalBootstrapData): ShellNavEntry[] {
return primaryShellSections(bootstrap).concat(utilityShellSections(bootstrap));
}
function shellSectionButton(
section: PortalShellSection,
activeSection: PortalShellSection,
title: string,
variant: 'primary' | 'utility'
): string {
return (
'<button class="portal-shell-nav-link' + (activeSection === section ? ' active' : '') + '" type="button" data-shell-action="activate-section" data-shell-section="' + section + '">' +
'<button class="portal-shell-nav-link portal-shell-nav-link-' + variant + (activeSection === section ? ' active' : '') + '" type="button" data-shell-action="activate-section" data-shell-section="' + section + '">' +
'<span class="portal-shell-nav-row">' +
'<span class="portal-shell-nav-label">' + title + '</span>' +
'</span>' +
@@ -434,9 +406,9 @@ function renderShellNavigation(bootstrap: PortalBootstrapData, activeSection: Po
var sections = visibleShellSections(bootstrap);
return (
'<nav class="portal-shell-nav" aria-label="Pulse Account sections">' +
'<div class="portal-shell-nav-group">' +
'<div class="portal-shell-nav-group portal-shell-nav-group-primary">' +
sections.map(function(entry) {
return shellSectionButton(entry.section, activeSection, entry.title);
return shellSectionButton(entry.section, activeSection, entry.title, entry.section === 'support' ? 'utility' : 'primary');
}).join('') +
'</div>' +
'</nav>'
@@ -837,13 +809,13 @@ function renderShellOverviewSection(context: ShellViewContext): string {
'<section class="account-content-panel account-content-panel-overview">' +
'<div class="account-stage-header account-stage-header-overview overview-stage-header">' +
'<div>' +
'<div class="account-panel-kicker">Overview</div>' +
'<div class="account-panel-kicker">Summary</div>' +
'<h3>Current state</h3>' +
'<p>Hosted workspace counts, current state, and the next step.</p>' +
'<p>Use this summary only when you need a quick account readout.</p>' +
renderSectionContextChips(chips) +
'</div>' +
'</div>' +
'<div class="overview-task-grid">' +
'<div class="overview-task-grid overview-task-grid-compact">' +
renderOverviewAttentionCard(accounts, entries, showSelfHostedCommercial) +
renderOverviewReadyCard(accounts, entries, context.accountAPIBasePath) +
renderOverviewNextActionCard(accounts, entries, context.accountAPIBasePath, showSelfHostedCommercial) +
@@ -852,6 +824,38 @@ function renderShellOverviewSection(context: ShellViewContext): string {
);
}
function renderPageHeader(kicker: string, title: string, copy: string, summaryHTML = ''): string {
return (
'<div class="portal-page-header">' +
'<div>' +
'<div class="account-panel-kicker">' + escapeHTML(kicker) + '</div>' +
'<h2>' + escapeHTML(title) + '</h2>' +
'<p>' + escapeHTML(copy) + '</p>' +
'</div>' +
summaryHTML +
'</div>'
);
}
function renderAccountBlockHeader(
account: PortalAccountSummary,
facts: string[],
actionsHTML = '',
copy = ''
): string {
return (
'<div class="portal-account-block-header">' +
'<div class="portal-account-block-copy">' +
'<div class="account-panel-kicker">' + escapeHTML(accountKindLabel(account)) + '</div>' +
'<h3>' + escapeHTML(account.name) + '</h3>' +
renderFactLine('portal-account-block-facts', [accountContextRoleMeta(account)].concat(facts)) +
(copy ? '<p>' + escapeHTML(copy) + '</p>' : '') +
'</div>' +
(actionsHTML ? '<div class="portal-account-block-actions">' + actionsHTML + '</div>' : '') +
'</div>'
);
}
function renderNoHostedWorkspacesSection(): string {
return (
'<section class="account-content-panel account-content-panel-workspaces">' +
@@ -893,13 +897,11 @@ function renderNoHostedAccessSection(): string {
function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPIBasePath: string): string {
var workspaces = Array.isArray(account.workspaces) ? account.workspaces : [];
var readyCount = countReadyWorkspaces(workspaces);
var attentionCount = attentionWorkspaces(workspaces).length;
var suspendedCount = countWorkspacesByState(workspaces, 'suspended');
var sectionCopy = account.can_manage
? 'Open a workspace, review lifecycle state, or create a new one without mixing in access or billing work.'
: 'Open a workspace and review current state here. An owner or admin must create or change hosted workspaces.';
var workspaceListSummary = account.can_manage
? 'Open a workspace to work in it. Use the lifecycle view only when you are reviewing state or making account-level changes.'
: 'Open a workspace here. An owner or admin must handle lifecycle or creation changes.';
? 'Open a workspace to work in it. Use Lifecycle only when you need account-level changes.'
: 'Open a workspace here. An owner or admin must create or change hosted workspaces.';
var workspaceManagement = '';
var addWorkspaceForm = '';
var workspaceHeaderActions = '';
@@ -1026,42 +1028,33 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI
return (
'<section class="account-content-panel account-content-panel-workspaces">' +
'<div class="account-stage-header">' +
'<div class="account-stage-header-row">' +
'<div>' +
'<div class="account-panel-kicker">Workspaces</div>' +
'<h3>Workspaces</h3>' +
'<p>' + escapeHTML(sectionCopy) + '</p>' +
renderSectionContextChips([
workspaceTotalChipLabel(workspaces.length),
readyWorkspaceSectionChipLabel(readyCount),
suspendedWorkspaceSectionChipLabel(suspendedCount),
]) +
'</div>' +
'<div class="account-stage-header-actions">' + workspaceHeaderActions + '</div>' +
'</div>' +
'</div>' +
renderAccountBlockHeader(
account,
[
workspaceTotalChipLabel(workspaces.length),
readyWorkspaceSectionChipLabel(readyCount),
reviewWorkspaceChipLabel(attentionCount),
suspendedWorkspaceSectionChipLabel(suspendedCount),
],
workspaceHeaderActions
) +
'<div class="workspace-operations-shell workspace-operations-shell-idle" id="workspace-operations-shell-' +
escapeAttr(account.id) +
'">' +
'<div class="workspace-operations-main">' +
workspaceHTML +
'</div>' +
'<div class="workspace-operations-detail" id="workspace-operations-detail-' +
escapeAttr(account.id) +
'" hidden>' +
workspaceManagement +
'</div>' +
'<div class="workspace-operations-main">' +
workspaceHTML +
'</div>' +
'</div>' +
'</section>'
);
}
function renderAccountAccessSection(account: PortalAccountSummary): string {
var accessHeaderTitle = account.can_manage ? 'Manage access' : 'Review access';
var accessHeaderCopy = account.can_manage
? 'Review the hosted roster, then open one access job at a time.'
: 'Review who already has access to this hosted account. An owner or admin must make changes.';
var accessTaskStrip = account.can_manage
? (
'<div class="access-task-strip">' +
@@ -1140,35 +1133,18 @@ function renderAccountAccessSection(account: PortalAccountSummary): string {
'" data-can-manage="' +
escapeAttr(account.can_manage ? 'true' : 'false') +
'">' +
'<div class="access-management-header">' +
'<div>' +
'<div class="account-panel-kicker">Access</div>' +
'<h3>' + accessHeaderTitle + '</h3>' +
'<p>' + accessHeaderCopy + '</p>' +
accessTaskStrip +
'</div>' +
'</div>' +
renderAccountBlockHeader(
account,
[account.can_manage ? 'Manage access' : 'Review roster'],
accessTaskStrip,
account.can_manage
? 'Open one access task at a time, then use the roster below.'
: 'Review who can act on this account. An owner or admin must make changes.'
) +
'<div class="access-management-stats" id="access-stats-' +
escapeAttr(account.id) +
'"></div>' +
'<div class="access-shell access-shell-idle" id="access-shell-' + escapeAttr(account.id) + '">' +
'<div class="access-shell-main">' +
'<div class="access-roster-column">' +
'<div class="access-roster">' +
'<div class="access-panel-heading">' +
'<h4>People on this account</h4>' +
'<p>' + (account.can_manage
? 'Review the hosted roster here, then open the exact access job you need.'
: 'Review the hosted roster here. An owner or admin must make changes.') + '</p>' +
'</div>' +
'<div class="access-roster-list" id="access-list-' +
escapeAttr(account.id) +
'">' +
'<div class="access-list-message">Loading…</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
(account.can_manage
? (
'<div class="access-shell-detail" id="access-detail-' + escapeAttr(account.id) + '" hidden>' +
@@ -1195,6 +1171,23 @@ function renderAccountAccessSection(account: PortalAccountSummary): string {
'</div>'
)
: '') +
'<div class="access-shell-main">' +
'<div class="access-roster-column">' +
'<div class="access-roster">' +
'<div class="access-panel-heading">' +
'<h4>People on this account</h4>' +
'<p>' + (account.can_manage
? 'Review the hosted roster here, then open the exact access job you need.'
: 'Review the hosted roster here. An owner or admin must make changes.') + '</p>' +
'</div>' +
'<div class="access-roster-list" id="access-list-' +
escapeAttr(account.id) +
'">' +
'<div class="access-list-message">Loading…</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</section>' +
'</section>'
@@ -1207,31 +1200,46 @@ function renderHostedBillingCards(accounts: PortalAccountSummary[], showSelfHost
});
if (!hostedBillingAccounts.length) {
return (
'<div class="billing-task-card billing-task-card-muted">' +
'<div class="account-panel-kicker">Hosted billing</div>' +
'<h3>No hosted billing attached</h3>' +
'<section class="billing-surface-block billing-surface-block-empty">' +
'<div class="billing-surface-header">' +
'<div class="account-panel-kicker">Hosted billing</div>' +
'<h3>No hosted billing attached</h3>' +
'</div>' +
'<p>' + escapeHTML(showSelfHostedCommercial
? 'Use the self-hosted billing tools below only if you are managing a self-hosted purchase.'
: 'Hosted invoices, payment methods, and subscription changes are not attached to this Pulse account right now.'
? 'Use the self-hosted billing tools below only when the request belongs to a self-hosted purchase.'
: 'Hosted invoices, payment methods, and subscription changes are not attached to this account.'
) + '</p>' +
'</div>'
'</section>'
);
}
return hostedBillingAccounts.map(function(account) {
var actionHTML = account.can_manage
? '<button type="button" class="btn-primary btn-compact" data-action="open-billing" data-account-id="' + escapeAttr(account.id) + '">Open hosted billing</button>'
: '<div class="billing-task-note">An owner or admin on this account needs to open hosted billing.</div>';
return (
'<article class="billing-task-card">' +
return (
'<section class="billing-surface-block">' +
'<div class="billing-surface-header">' +
'<div class="account-panel-kicker">Hosted billing</div>' +
'<h3>' + escapeHTML(account.name) + '</h3>' +
'<p>Invoices, payment methods, and hosted subscription changes for this account live here.</p>' +
'<div class="billing-task-meta">Keep workspace lifecycle work in Workspaces and access changes in Access.</div>' +
'<div class="billing-task-actions">' + actionHTML + '</div>' +
'</article>'
);
}).join('');
'<h3>Hosted billing</h3>' +
'</div>' +
'<div class="billing-action-list billing-action-list-surface">' +
hostedBillingAccounts.map(function(account) {
var actionHTML = account.can_manage
? '<button type="button" class="btn-primary btn-compact" data-action="open-billing" data-account-id="' + escapeAttr(account.id) + '">Open hosted billing</button>'
: '<div class="billing-task-note">An owner or admin on this account needs to open hosted billing.</div>';
return (
'<article class="billing-action-row billing-action-row-surface">' +
'<div class="billing-action-main">' +
'<div class="billing-action-copy">' +
'<h3>' + escapeHTML(account.name) + '</h3>' +
'<p>Invoices, payment methods, and hosted subscription changes for this account.</p>' +
'</div>' +
'<div class="billing-action-meta">Keep workspace changes in Workspaces and roster changes in Access.</div>' +
'</div>' +
'<div class="billing-action-cta">' + actionHTML + '</div>' +
'</article>'
);
}).join('') +
'</div>' +
'</section>'
);
}
function renderBillingTaskPanel(title: string, copy: string, panelID: string, bodyHTML: string): string {
@@ -1265,78 +1273,39 @@ function renderSupportSection(context: ShellViewContext): string {
var hostedViewOnly = hasHostedAccounts && !canManageHostedTasks;
var supportLead = supportLeadCopy(hasHostedAccounts, hostedViewOnly, showSelfHostedCommercial);
var supportChips = supportSectionChipLabels(hasHostedAccounts, hostedViewOnly, supportEmail);
var routeCards = hasHostedAccounts
var retryCopy = hasHostedAccounts
? (
'<div class="portal-support-route-card">' +
'<div class="account-panel-kicker">Workspaces or Access</div>' +
'<h3>' + hostedSupportRouteTitle(hostedViewOnly) + '</h3>' +
'<p>' + hostedSupportRouteDescription(hostedViewOnly) + '</p>' +
'<div class="portal-support-points">' +
'<div class="portal-support-point"><strong>Task</strong><span>' + hostedSupportRouteTaskCopy(hostedViewOnly) + '</span></div>' +
'<div class="portal-support-point"><strong>' + hostedSupportRouteContextLabel(hostedViewOnly) + '</strong><span>' + hostedSupportRouteContextCopy(hostedViewOnly) + '</span></div>' +
'</div>' +
'<div class="portal-support-actions">' +
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="workspaces">Open Workspaces</button>' +
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="access">Open Access</button>' +
'<a class="portal-support-link" href="mailto:' + escapeAttr(supportEmail) + '">' + escapeHTML(supportEmail) + '</a>' +
'</div>' +
'</div>' +
'<div class="portal-support-route-card">' +
'<div class="account-panel-kicker">' + (showSelfHostedCommercial ? 'Billing' : 'Hosted billing') + '</div>' +
'<h3>' + billingSupportRouteTitle(hostedViewOnly, showSelfHostedCommercial) + '</h3>' +
'<p>' + billingSupportRouteDescription(hostedViewOnly, showSelfHostedCommercial) + '</p>' +
'<div class="portal-support-points">' +
'<div class="portal-support-point"><strong>Billing job</strong><span>' + billingSupportRouteJobCopy(hostedViewOnly, showSelfHostedCommercial) + '</span></div>' +
'<div class="portal-support-point"><strong>Account or email</strong><span>' + billingSupportRouteAccountCopy(hostedViewOnly, showSelfHostedCommercial) + '</span></div>' +
'</div>' +
'<div class="portal-support-actions">' +
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="billing">Open billing</button>' +
'<a class="portal-support-link" href="mailto:' + escapeAttr(supportEmail) + '">' + escapeHTML(supportEmail) + '</a>' +
'</div>' +
'</div>'
hostedViewOnly
? 'Review Workspaces or Access first. If billing is involved, hand it to an owner or admin before you escalate.'
: 'Retry the same Workspaces, Access, or Billing step before you escalate.'
)
: (
'<div class="portal-support-route-card">' +
'<div class="account-panel-kicker">Self-hosted billing</div>' +
'<h3>Self-hosted billing failed</h3>' +
'<p>Escalate only if a self-hosted billing task still fails.</p>' +
'<div class="portal-support-points">' +
'<div class="portal-support-point"><strong>Billing job</strong><span>Billing, licenses, refunds, or privacy.</span></div>' +
'<div class="portal-support-point"><strong>Account or email</strong><span>Include the commercial billing email and the failed step.</span></div>' +
'</div>' +
'<div class="portal-support-actions">' +
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="billing">Open billing</button>' +
'<a class="portal-support-link" href="mailto:' + escapeAttr(supportEmail) + '">' + escapeHTML(supportEmail) + '</a>' +
'</div>' +
'</div>'
);
var runbookSteps = hasHostedAccounts
: 'Retry the same Billing step before you escalate.';
var supportActions = hasHostedAccounts
? (
'<div class="portal-support-runbook-step"><strong>1. Failed path</strong><span>' + supportRunbookPathCopy(hasHostedAccounts, hostedViewOnly, showSelfHostedCommercial) + '</span></div>' +
'<div class="portal-support-runbook-step"><strong>2. Account or email</strong><span>' + supportRunbookAccountCopy(hasHostedAccounts, showSelfHostedCommercial) + '</span></div>' +
'<div class="portal-support-runbook-step"><strong>3. Failed action</strong><span>Name the exact button, form, or billing step that failed and what happened next.</span></div>'
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="workspaces">Workspaces</button>' +
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="access">Access</button>' +
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="billing">Billing</button>'
)
: (
'<div class="portal-support-runbook-step"><strong>1. Billing job</strong><span>' + supportRunbookPathCopy(hasHostedAccounts, hostedViewOnly, showSelfHostedCommercial) + '</span></div>' +
'<div class="portal-support-runbook-step"><strong>2. Account or email</strong><span>' + supportRunbookAccountCopy(hasHostedAccounts, showSelfHostedCommercial) + '</span></div>' +
'<div class="portal-support-runbook-step"><strong>3. Failed action</strong><span>Name the exact button, form, or billing step that failed and what happened next.</span></div>'
);
: '<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="billing">Billing</button>';
return (
'<section class="portal-support-panel">' +
'<div class="account-panel-kicker">Support</div>' +
'<h2>Support</h2>' +
'<p>' + escapeHTML(supportLead) + '</p>' +
renderSectionContextChips(supportChips) +
'<div class="portal-support-layout">' +
'<div class="portal-support-route-grid">' +
routeCards +
'</div>' +
'<div class="portal-support-runbook">' +
'<div class="account-panel-kicker">What to send</div>' +
'<h3>Send these details</h3>' +
'<p>Send the failed path, account or email, and failed action.</p>' +
'<div class="portal-support-runbook-list">' +
runbookSteps +
renderPageHeader(
'Support',
'Support',
supportLead,
renderFactLine('portal-page-facts', supportChips)
) +
'<div class="portal-support-simple">' +
'<div class="portal-support-simple-card">' +
'<div class="portal-support-simple-list">' +
'<div class="portal-support-simple-row"><strong>Try first</strong><span>' + escapeHTML(retryCopy) + '</span></div>' +
'<div class="portal-support-simple-row"><strong>Path</strong><span>' + escapeHTML(supportRunbookPathCopy(hasHostedAccounts, hostedViewOnly, showSelfHostedCommercial)) + '</span></div>' +
'<div class="portal-support-simple-row"><strong>Account or email</strong><span>' + escapeHTML(supportRunbookAccountCopy(hasHostedAccounts, showSelfHostedCommercial)) + '</span></div>' +
'<div class="portal-support-simple-row"><strong>Failed action</strong><span>Name the exact button, form, or billing step that failed and what happened next.</span></div>' +
'</div>' +
'<div class="portal-support-simple-actions">' +
supportActions +
'<a class="portal-support-link" href="mailto:' + escapeAttr(supportEmail) + '">' + escapeHTML(supportEmail) + '</a>' +
'</div>' +
'</div>' +
'</div>' +
@@ -1371,14 +1340,15 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
})
? preferredSection
: (shellSections[0] ? shellSections[0].section : 'billing');
var allWorkspaces = collectWorkspaces(accounts);
var hostedBillingCount = accounts.filter(function(account) {
return account.has_billing;
}).length;
var billingNote = hosted
? (showSelfHostedCommercial
? 'Use hosted billing first when the request belongs to a hosted workspace account. Self-hosted licenses, refunds, and privacy stay separate underneath it.'
: 'Use this billing surface only for hosted billing on your hosted workspace accounts.')
: 'Use this billing surface only for self-hosted subscriptions, licenses, refunds, and privacy requests.';
? 'Hosted billing by account. Self-hosted purchases stay separate below.'
: 'Hosted billing by account.')
: 'Self-hosted subscriptions, licenses, refunds, and privacy requests.';
var selfHostedBillingEscalationCopy = hosted
? 'Escalate with the same hosted billing action or self-hosted path and the exact failed step.'
: 'Escalate with the same self-hosted billing path and the exact failed step.';
@@ -1409,9 +1379,26 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
(hosted
? (
'<section class="portal-content-panel portal-content-panel-workspaces">' +
renderPageHeader(
'Workspaces',
'Workspaces',
'Open a workspace or manage lifecycle by account.',
renderSummaryStrip([
{ label: 'Accounts', value: String(accounts.length) },
{ label: 'Workspaces', value: String(allWorkspaces.length) },
{ label: 'Ready', value: String(countReadyWorkspaces(allWorkspaces)) },
{ label: 'Needs review', value: String(attentionWorkspaces(allWorkspaces).length) },
{ label: 'Suspended', value: String(countWorkspacesByState(allWorkspaces, 'suspended')) },
])
) +
workspacesContent +
'</section>' +
'<section class="portal-content-panel portal-content-panel-access">' +
renderPageHeader(
'Access',
'Access',
'Review the roster or open one access task by account.'
) +
accessContent +
'</section>' +
'<section class="portal-content-panel portal-content-panel-overview">' +
@@ -1420,17 +1407,16 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
)
: '') +
'<section class="portal-content-panel portal-content-panel-billing billing-section" id="billing-section">' +
'<div class="billing-header">' +
'<div>' +
'<div class="account-panel-kicker">Billing</div>' +
'<h2>Billing</h2>' +
renderSectionContextChips(billingHeaderChipLabels(hostedBillingCount, showSelfHostedCommercial)) +
'</div>' +
'<div class="billing-note">' + billingNote + '</div>' +
'</div>' +
(hosted
? ('<div class="billing-overview-grid">' + renderHostedBillingCards(accounts, showSelfHostedCommercial) + '</div>')
: '') +
renderPageHeader(
'Billing',
'Billing',
billingNote,
renderSummaryStrip([
{ label: 'Hosted billing', value: String(hostedBillingCount) },
{ label: 'Self-hosted tasks', value: showSelfHostedCommercial ? 'Available' : 'Not attached' },
])
) +
(hosted ? renderHostedBillingCards(accounts, showSelfHostedCommercial) : '') +
(showSelfHostedCommercial
? ('<div class="billing-shell billing-shell-idle">' +
'<div class="billing-shell-main">' +
+641 -61
View File
@@ -4581,14 +4581,17 @@ header .logout-btn,
/* Signed-in portal reset: simpler, flatter account surface */
.portal-shell-main {
width: min(1080px, calc(100vw - 48px));
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
padding: 0 0 32px;
gap: 18px;
padding: 10px 0 40px;
}
.portal-shell-main > .portal-content-panel {
display: none !important;
width: 100%;
}
.portal-shell[data-shell-section="workspaces"] .portal-content-panel-workspaces,
@@ -4601,7 +4604,7 @@ header .logout-btn,
.portal-account-context {
display: block;
padding: 0 0 12px;
padding: 0 0 14px;
border: none;
border-bottom: 1px solid var(--line);
border-radius: 0;
@@ -4622,15 +4625,15 @@ header .logout-btn,
}
.portal-account-context-row-title h2 {
font-size: 30px;
line-height: 1.04;
letter-spacing: -0.04em;
font-size: 32px;
line-height: 1.02;
letter-spacing: -0.045em;
}
.portal-account-context-copy p {
max-width: 820px;
font-size: 15px;
line-height: 1.55;
max-width: 760px;
font-size: 14px;
line-height: 1.5;
}
.portal-account-context-summary {
@@ -4640,6 +4643,10 @@ header .logout-btn,
.portal-shell-nav {
position: static;
top: auto;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
border: none;
border-bottom: 1px solid var(--line);
background: transparent;
@@ -4652,8 +4659,8 @@ header .logout-btn,
.portal-shell-nav-group {
display: flex;
flex-direction: row;
gap: 12px;
padding: 0 0 4px;
gap: 10px;
padding: 0 0 6px;
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
@@ -4663,6 +4670,16 @@ header .logout-btn,
display: none;
}
.portal-shell-nav-group-primary {
flex: 1 1 auto;
min-width: 0;
}
.portal-shell-nav-group-utility {
flex: 0 0 auto;
justify-content: flex-end;
}
.portal-shell-nav-link {
min-width: 0;
padding: 0 8px 10px;
@@ -4685,6 +4702,14 @@ header .logout-btn,
color: var(--ink);
}
.portal-shell-nav-link-utility {
color: var(--ink-soft);
}
.portal-shell-nav-link-utility.active {
border-bottom-color: var(--line-strong);
}
.portal-shell-nav-row {
justify-content: flex-start;
gap: 0;
@@ -4695,6 +4720,11 @@ header .logout-btn,
font-weight: 600;
}
.portal-shell-nav-link-utility .portal-shell-nav-label {
font-size: 13px;
font-weight: 500;
}
.portal-shell-nav-badge {
display: none !important;
}
@@ -4712,32 +4742,32 @@ header .logout-btn,
}
.account-surface + .account-surface {
margin-top: 24px;
margin-top: 28px;
}
.account-stage-header,
.billing-header {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
margin-bottom: 18px;
gap: 10px;
margin-bottom: 14px;
}
.account-stage-header h3,
.billing-header h2,
.portal-support-panel h2 {
font-size: 24px;
line-height: 1.12;
letter-spacing: -0.03em;
font-size: 26px;
line-height: 1.08;
letter-spacing: -0.035em;
}
.account-stage-header p,
.billing-header p,
.billing-note,
.portal-support-panel > p {
.portal-support-panel p {
max-width: 760px;
font-size: 14px;
line-height: 1.55;
line-height: 1.5;
}
.section-context-strip {
@@ -4753,15 +4783,28 @@ header .logout-btn,
margin-right: 8px;
}
.overview-task-grid {
grid-template-columns: 1fr;
gap: 12px;
.overview-task-grid-compact {
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 14px;
background: #fff;
}
.overview-task-grid-compact .overview-task-card {
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
background: transparent;
padding: 16px 18px;
}
.overview-task-grid-compact .overview-task-card:first-child {
border-top: none;
}
.overview-task-card,
.billing-task-card,
.portal-support-route-card,
.portal-support-runbook,
.workspace-list-wrap,
.workspace-management-panel,
.access-roster,
@@ -4769,61 +4812,64 @@ header .logout-btn,
.access-invite-panel,
.access-job-note-panel,
.access-policy-panel,
.billing-surface-block,
.billing-shell-main,
.billing-shell-detail,
.billing-panel {
.billing-panel,
.portal-support-simple-card {
border: 1px solid var(--line);
border-radius: 12px;
border-radius: 14px;
background: #fff;
}
.overview-task-card,
.billing-task-card,
.portal-support-route-card,
.portal-support-runbook,
.billing-shell-main,
.billing-shell-detail,
.billing-panel {
padding: 18px;
}
.workspace-operations-shell,
.access-shell,
.billing-shell,
.portal-support-layout {
grid-template-columns: 1fr;
.billing-shell {
grid-template-columns: minmax(0, 1fr);
gap: 14px;
}
.workspace-list-wrap,
.access-roster,
.billing-surface-block,
.portal-support-simple-card {
overflow: hidden;
}
.workspace-list-toolbar,
.access-panel-heading {
padding: 16px 18px 10px;
padding: 14px 18px 8px;
}
.workspace-list-head {
grid-template-columns: minmax(0, 1.6fr) 120px 120px 160px;
grid-template-columns: minmax(0, 1.7fr) 116px 116px 168px;
gap: 12px;
padding: 8px 18px;
padding: 9px 18px;
}
.workspace-row {
grid-template-columns: minmax(0, 1.6fr) 120px 120px 160px;
grid-template-columns: minmax(0, 1.7fr) 116px 116px 168px;
gap: 12px;
padding: 14px 18px;
}
.workspace-actions {
flex-wrap: wrap;
justify-content: flex-start !important;
}
.access-management-header {
margin-bottom: 12px;
}
.access-management-stats {
display: block;
margin-bottom: 12px;
margin-bottom: 10px;
}
.access-stat-summary {
padding: 0 0 8px;
font-size: 12px;
padding: 0 0 10px;
font-size: 13px;
font-weight: 500;
color: var(--ink-muted);
}
@@ -4832,34 +4878,43 @@ header .logout-btn,
color: var(--danger);
}
.access-shell-main,
.access-roster-column,
.access-roster {
width: 100%;
}
.access-task-strip {
gap: 6px;
margin-top: 10px;
gap: 8px;
margin-top: 12px;
}
.access-task-button {
border-radius: 10px;
}
.access-task-button.active {
.access-task-button.active,
.access-task-button.is-active {
border-color: var(--accent);
background: #edf3fe;
color: var(--accent);
}
.access-roster-list {
padding: 0 18px 8px;
padding: 0 18px 10px;
}
.access-roster-head {
padding: 8px 18px;
padding: 9px 18px;
}
.access-member-row {
padding: 12px 0;
}
.billing-overview-grid {
grid-template-columns: 1fr;
gap: 12px;
.billing-header {
grid-template-columns: minmax(0, 1fr);
align-items: start;
}
.billing-note {
@@ -4867,45 +4922,570 @@ header .logout-btn,
border: none;
}
.billing-surface-block {
padding: 0;
}
.billing-surface-block + .billing-surface-block {
margin-top: 14px;
}
.billing-surface-block-empty {
padding: 18px;
}
.billing-surface-header {
padding: 18px 18px 10px;
}
.billing-action-list-surface {
display: flex;
flex-direction: column;
}
.billing-action-row-surface {
padding: 16px 18px;
}
.billing-action-row {
padding: 14px 0;
}
.portal-support-route-grid {
gap: 12px;
.portal-support-simple {
max-width: 880px;
}
.portal-support-simple-card {
padding: 0;
}
.portal-support-simple-list {
display: flex;
flex-direction: column;
}
.portal-support-simple-row {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 16px;
padding: 14px 18px;
border-top: 1px solid var(--line);
}
.portal-support-simple-row:first-child {
border-top: none;
}
.portal-support-simple-row strong {
font-size: 13px;
color: var(--ink);
}
.portal-support-simple-row span {
font-size: 14px;
line-height: 1.5;
color: var(--ink-soft);
}
.portal-support-simple-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 16px 18px;
border-top: 1px solid var(--line);
background: #fafbfc;
}
@media (max-width: 960px) {
.portal-shell-main {
width: min(100%, calc(100vw - 32px));
gap: 16px;
}
.portal-shell-nav {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.portal-shell-nav-group {
gap: 8px;
}
.portal-shell-nav-group-utility {
justify-content: flex-start;
}
.portal-account-context {
padding-bottom: 10px;
padding-bottom: 12px;
}
.portal-account-context-row-title h2,
.account-stage-header h3,
.billing-header h2,
.portal-support-panel h2 {
font-size: 22px;
font-size: 24px;
}
.portal-support-simple-row {
grid-template-columns: 1fr;
gap: 6px;
}
}
@media (max-width: 640px) {
.portal-shell-main {
width: min(100%, calc(100vw - 20px));
}
.portal-shell-nav-link {
padding: 0 6px 8px;
}
.portal-shell-nav-link-utility {
padding-bottom: 6px;
}
.portal-account-context-row-title h2 {
font-size: 24px;
font-size: 26px;
}
.access-management-stats {
grid-template-columns: 1fr;
}
}
/* Calmer account surface pass */
.portal-shell-main {
max-width: 1120px;
margin: 0 auto;
width: min(100%, calc(100vw - 48px));
padding: 0 0 56px;
gap: 20px;
}
.portal-account-context {
padding: 28px 0 8px;
border-bottom: none;
background: transparent;
}
.portal-account-context-copy {
gap: 4px;
}
.portal-account-context-meta {
font-size: 12px;
}
.portal-account-context-row-title h2 {
font-size: 32px;
line-height: 1.04;
letter-spacing: -0.04em;
}
.portal-account-context-copy p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: var(--ink-muted);
}
.portal-shell-nav {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
}
.portal-shell-nav-group {
padding-bottom: 0;
gap: 20px;
}
.portal-shell-nav-group-primary {
flex: 0 0 auto;
}
.portal-shell-nav-group-utility {
flex: 0 0 auto;
margin-left: 0;
}
.portal-shell-nav-link {
padding: 0 0 12px;
width: auto;
min-width: max-content;
flex: 0 0 auto;
display: inline-flex;
white-space: nowrap;
}
.portal-shell-nav-link-utility {
color: var(--ink-muted);
}
.portal-page-header {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
margin-bottom: 18px;
}
.portal-page-header h2 {
font-size: 28px;
line-height: 1.06;
letter-spacing: -0.04em;
}
.portal-page-header p {
max-width: 640px;
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--ink-muted);
}
.portal-page-facts,
.portal-account-block-facts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0;
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: var(--ink-muted);
}
.portal-fact-separator {
margin: 0 10px;
color: var(--line-strong, var(--line));
}
.portal-summary-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
width: 100%;
min-width: 0;
}
.portal-summary-item {
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: #fff;
}
.portal-summary-item span {
display: block;
margin-bottom: 4px;
font-size: 12px;
line-height: 1.4;
color: var(--ink-muted);
}
.portal-summary-item strong {
display: block;
font-size: 18px;
line-height: 1.2;
color: var(--ink);
}
.account-surface + .account-surface {
margin-top: 18px;
}
.portal-account-block-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.portal-account-block-copy h3 {
font-size: 20px;
line-height: 1.12;
letter-spacing: -0.03em;
}
.portal-account-block-copy p {
margin: 10px 0 0;
font-size: 14px;
line-height: 1.55;
color: var(--ink-muted);
}
.portal-account-block-actions {
display: flex;
align-items: flex-start;
gap: 12px;
}
.access-section-shell .portal-account-block-header,
.billing-section .portal-page-header,
.portal-support-panel .portal-page-header {
align-items: flex-start;
}
.access-section-shell .portal-account-block-header {
flex-direction: column;
}
.access-section-shell .portal-account-block-actions {
width: 100%;
}
.workspace-list-wrap,
.workspace-management-panel,
.access-roster,
.access-task-panel,
.billing-surface-block,
.billing-shell-main,
.billing-shell-detail,
.billing-panel,
.portal-support-panel {
border-radius: 14px;
}
.workspace-list-toolbar {
padding: 14px 18px 8px;
}
.workspace-list-summary {
font-size: 13px;
line-height: 1.5;
color: var(--ink-muted);
}
.workspace-list-head {
grid-template-columns: minmax(0, 1.8fr) 110px 120px 180px;
gap: 12px;
padding: 8px 18px;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-muted);
}
.workspace-row {
grid-template-columns: minmax(0, 1.8fr) 110px 120px 180px;
gap: 12px;
padding: 16px 18px;
}
.workspace-row-note {
font-size: 13px;
}
.workspace-actions {
justify-content: flex-end;
gap: 8px;
}
.workspace-operations-shell,
.access-shell,
.billing-shell {
gap: 12px;
}
.access-management-stats {
margin-bottom: 10px;
}
.access-task-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.access-task-button {
border-radius: 999px;
background: #fff;
}
.access-roster-list {
padding: 0 18px 4px;
}
.billing-surface-block {
padding: 18px;
}
.billing-surface-header {
margin-bottom: 8px;
}
.billing-action-list-surface {
gap: 0;
}
.billing-action-row-surface {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
padding: 16px 0;
border-top: 1px solid var(--line);
}
.billing-action-row-surface:first-child {
padding-top: 0;
border-top: none;
}
.billing-action-row-surface:last-child {
padding-bottom: 0;
}
.billing-action-row-surface .billing-action-copy h3 {
font-size: 18px;
}
.billing-action-row-surface .billing-action-meta {
margin-top: 4px;
font-size: 13px;
line-height: 1.5;
color: var(--ink-muted);
}
.portal-support-panel {
border: 1px solid var(--line);
background: #fff;
padding: 18px;
}
.portal-support-simple-card {
padding: 0;
border: none;
background: transparent;
}
.portal-support-simple-row {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 16px;
padding: 14px 0;
border-top: 1px solid var(--line);
}
.portal-support-simple-row:first-child {
padding-top: 0;
border-top: none;
}
.portal-support-simple-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
@media (max-width: 960px) {
.portal-shell-main {
width: min(100%, calc(100vw - 40px));
padding-bottom: 40px;
}
.portal-shell-nav {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.portal-shell-nav-group {
gap: 12px;
}
.portal-shell-nav-group-utility {
margin-left: 0;
}
.portal-account-block-header {
flex-direction: column;
align-items: flex-start;
}
.portal-summary-strip {
width: 100%;
min-width: 0;
}
.billing-action-row-surface {
grid-template-columns: 1fr;
align-items: flex-start;
}
}
@media (max-width: 720px) {
.workspace-list-head {
display: none;
}
.workspace-row {
grid-template-columns: 1fr;
gap: 10px;
}
.workspace-row-status-cell {
justify-content: flex-start;
}
.workspace-actions {
justify-content: flex-start;
width: 100%;
}
.workspace-actions form,
.workspace-actions button {
max-width: none;
}
.portal-summary-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.access-task-strip {
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.access-task-strip::-webkit-scrollbar {
display: none;
}
.portal-support-simple-row {
grid-template-columns: 1fr;
gap: 6px;
}
}
@media (max-width: 640px) {
.portal-shell-main {
width: min(100%, calc(100vw - 24px));
padding-bottom: 32px;
}
.portal-account-context {
padding-top: 20px;
}
.portal-account-context-row-title h2 {
font-size: 28px;
}
.portal-page-header h2 {
font-size: 24px;
}
.portal-summary-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}