mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Redesign portal as a simpler account tool
This commit is contained in:
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "9f9d5468a58d29a9e38839b7c671e9b48a2cbedb21919ebe32e2a44256a030aa",
|
||||
"source_hash": "105a2cf01f7b31e356d71c2f826b18be16165e4c9c61fba4504283bf965662ff",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+537
-60
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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">' +
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user