portal: replace shell chrome with flat GitHub Settings layout

Replaces the portal-y dashboard shell (hero context strip, sidebar nav,
summary strips, fact chips, kicker labels) with a flat, disciplined layout:

- Identity bar: compact inline h2 + role + account kind
- Tab bar: simple horizontal tabs with active underline
- Content panels: direct workspace table / access roster / billing cards
  without redundant per-section page headers or summary stat strips
- Simplified block headers: account name + actions only, no fact chips
- Support section: direct h2 + help text, no page header wrapper

CSS rewritten from 5492 lines to a minimal flat design system:
white background, no shadows, no gradients, 6px radius, system fonts.

All 79 tests updated and passing.
This commit is contained in:
rcourtman
2026-03-29 21:26:00 +01:00
parent a71f370731
commit ebec18c528
7 changed files with 882 additions and 5175 deletions
@@ -404,9 +404,12 @@ through a versioned href so new portal icon revisions bypass browser cache on
deploy instead of waiting for asset expiry.
That same typed overview contract must also preserve a calm, flat account-tool
visual posture across all portal scenarios: no gradients, heavy shadows, or
decorative dashboard chrome; lighter shell framing; list/detail and row-based
task presentation; restrained accent use; and hierarchy driven by spacing,
typography, and dividers rather than dense pills, stacked metrics, or
decorative dashboard chrome. The shell uses a compact identity bar (account name,
role, kind) and a horizontal tab bar for Workspaces, Access, Billing, and
Support. Content panels render directly below the tab bar without redundant
page headers, summary strips, or fact chips. Section block headers render the
account name and inline actions only. Hierarchy is driven by spacing,
typography, and 1px borders rather than cards, pills, stacked metrics, or
ornamental side rails competing with the active task.
That same typed page contract also applies before auth: the signed-out portal
surface must keep one obvious sign-in action plus precise account-scope
@@ -274,7 +274,7 @@ deployment instead of appearing only after a live push. The portal page itself
must also reference that shared favicon through a versioned href so updated
icon revisions bypass browser cache on deploy instead of waiting for asset
expiry.
That same frontend delivery boundary must keep the account portal visual language sharp and restrained, avoiding gradients, heavy shadows, decorative SaaS styling, and shell chrome that dominates the active task. The canonical target is a calm, flat account-operations surface: lighter shell framing, list/detail and row-based task presentation, spacing/typography/divider hierarchy, and only limited badge or pill use where the runtime truth genuinely needs it.
That same frontend delivery boundary must keep the account portal visual language sharp and restrained, avoiding gradients, heavy shadows, decorative SaaS styling, and shell chrome that dominates the active task. The canonical target is a calm, flat account-operations surface closer to GitHub Settings or Android Settings than to a dashboard: a compact identity bar (account name, role, kind), a horizontal tab bar for section navigation, and direct content panels for Workspaces, Access, Billing, and Support. Shell framing uses white backgrounds, 1px borders, minimal border-radius, and hierarchy driven by spacing and typography rather than cards, pills, or ornamental headers. Section block headers render the account name and inline actions only; fact chips, summary strips, and decorative kicker labels do not appear in the primary task surface.
That same portal delivery boundary also owns the checked-in embedded bundle in
`internal/cloudcp/portal/dist/`. Visual or interaction changes are not
complete when they exist only in the local preview runtime or in hand-edited
@@ -53,7 +53,7 @@ describe('shell runtime', function() {
installShell({ store: store });
var navGroup = document.querySelector('.portal-shell-nav-group') as HTMLElement | null;
var navGroup = document.querySelector('.portal-tab-bar') as HTMLElement | null;
expect(navGroup).not.toBeNull();
if (!navGroup) return;
@@ -73,7 +73,7 @@ describe('shell runtime', function() {
scrollIntoView.mockClear();
store.setActiveShellSection('access');
var activeLink = document.querySelector('.portal-shell-nav-link.active') as HTMLElement | null;
var activeLink = document.querySelector('.portal-tab.active') as HTMLElement | null;
expect(activeLink?.getAttribute('data-shell-section')).toBe('access');
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest', inline: 'center' });
@@ -100,7 +100,7 @@ describe('shell runtime', function() {
installShell({ store: store });
var activeLink = document.querySelector('.portal-shell-nav-link.active') as HTMLElement | null;
var activeLink = document.querySelector('.portal-tab.active') as HTMLElement | null;
expect(activeLink?.getAttribute('data-shell-section')).toBe('workspaces');
});
});
@@ -14,7 +14,7 @@ export interface ShellDeps {
export function installShell(deps: ShellDeps): void {
function revealActiveNavLink(activeLink: HTMLElement | null) {
if (!activeLink) return;
var group = activeLink.closest('.portal-shell-nav-group') as HTMLElement | null;
var group = activeLink.closest('.portal-tab-bar') as HTMLElement | null;
if (!group || group.scrollWidth <= group.clientWidth) return;
if (typeof activeLink.scrollIntoView === 'function') {
activeLink.scrollIntoView({ block: 'nearest', inline: 'center' });
@@ -33,7 +33,7 @@ export function installShell(deps: ShellDeps): void {
var button = node as HTMLElement;
var isActive = button.getAttribute('data-shell-section') === activeSection;
button.classList.toggle('active', isActive);
if (isActive && button.classList.contains('portal-shell-nav-link')) {
if (isActive && button.classList.contains('portal-tab')) {
activeLink = button;
}
});
@@ -130,30 +130,28 @@ describe('shell view', function() {
})
);
expect(html).toContain('portal-shell-nav');
expect(html).toContain('portal-tab-bar');
expect(html).toContain('Summary');
expect(html).toContain('Access');
expect(html).toContain('Billing');
expect(html).toContain('Support');
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('portal-identity-bar');
expect(html).toContain('Owner');
expect(html).toContain('MSP account');
expect(html).toContain('Acme MSP');
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('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('1 workspace ready to use');
expect(html).toContain('1 ready workspace');
expect(html).toContain('Unhealthy</span>');
expect(html).toContain('Checking</span>');
expect(html).toContain('This workspace is in a failed state.');
@@ -171,11 +169,9 @@ describe('shell view', function() {
expect(html).toContain('id="workspace-operations-detail-acct_1" hidden');
expect(html.indexOf('id="add-ws-form-acct_1"')).toBeGreaterThan(html.indexOf('Open a new hosted workspace'));
expect(html).toContain('data-action="clear-workspace-selection"');
expect(html).toContain('Manage access');
expect(html).toContain('Invite people');
expect(html).toContain('Change roles');
expect(html).toContain('Remove access');
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');
@@ -189,11 +185,6 @@ describe('shell view', function() {
expect(html).toContain('Remove stale access');
expect(html).toContain('data-action="workspace-action"');
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('Try first');
expect(html).toContain('Path');
expect(html).toContain('Account or email');
@@ -201,8 +192,6 @@ describe('shell view', function() {
expect(html).toContain('Billing');
expect(html).not.toContain('Hosted only');
expect(html).not.toContain('Self-hosted tools');
expect(html).not.toContain('Self-hosted billing');
expect(html).not.toContain('Pick the self-hosted job');
expect(html).not.toContain('Use self-hosted billing only for self-hosted purchases.');
expect(html).not.toContain('id="open-retrieve-billing"');
expect(html).not.toContain('id="billing-detail-shell" hidden');
@@ -267,17 +256,12 @@ describe('shell view', function() {
})
);
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('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('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.');
});
@@ -365,10 +349,10 @@ describe('shell view', function() {
})
);
expect((html.match(/portal-account-context\"/g) || []).length).toBe(1);
expect((html.match(/portal-identity-bar\"/g) || []).length).toBe(1);
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('Admin');
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');
@@ -403,16 +387,11 @@ describe('shell view', function() {
})
);
expect(html).toContain('Tech role');
expect(html).toContain('Tech');
expect(html).toContain('1 workspace is ready to use');
expect(html).toContain('Owner or admin required');
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('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');
@@ -457,7 +436,6 @@ describe('shell view', function() {
})
);
expect(html).toContain('Read-only role');
expect(html).toContain('Read-only');
expect(html).not.toContain('Read_only role');
expect(html).not.toContain('READ_ONLY');
@@ -485,7 +463,6 @@ describe('shell view', function() {
);
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');
});
@@ -510,7 +487,7 @@ describe('shell view', function() {
})
);
expect(html).toContain('0 workspaces ready to use');
expect(html).toContain('0 ready workspaces');
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.');
});
@@ -536,7 +513,7 @@ describe('shell view', function() {
})
);
expect(html).toContain('0 workspaces ready to use');
expect(html).toContain('0 ready workspaces');
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.');
});
@@ -571,7 +548,7 @@ describe('shell view', function() {
);
expect(html).toContain('Paused Workspace');
expect(html).toContain('1 workspace suspended');
expect(html).toContain('1 suspended workspace');
expect(html).toContain('Owner or admin required');
});
@@ -604,8 +581,8 @@ describe('shell view', function() {
})
);
expect(html).toContain('0 workspaces ready to use');
expect(html).toContain('1 workspace suspended');
expect(html).toContain('0 ready workspaces');
expect(html).toContain('1 suspended workspace');
expect(html).not.toContain('Active workspaces look clear for routine use.');
});
@@ -618,21 +595,14 @@ describe('shell view', function() {
})
);
expect(html).toContain('Billing account');
expect(html).toContain('Support');
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 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('Self-hosted billing');
expect(html).toContain('Use self-hosted billing only for self-hosted purchases.');
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 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('Account or email');
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.');
@@ -711,15 +681,15 @@ describe('shell view', function() {
expect(html).toContain('Suspended');
expect(html).toContain('0 workspaces to review');
expect(html).toContain('0 workspaces ready to use');
expect(html).toContain('0 ready workspaces');
expect(html).toContain('Create workspace');
expect(html).toContain('1 workspace suspended');
expect(html).toContain('1 suspended workspace');
});
it('preserves the simplified top-tab shell hooks in the rendered shell', function() {
var html = renderAuthenticatedPortalHTML(createContext());
expect(html).toContain('portal-shell-main');
expect(html).toContain('portal-shell-nav-group');
expect(html).toContain('portal-tab-bar');
expect(html).not.toContain('portal-shell-layout');
});
});
@@ -296,59 +296,34 @@ function accountContextLeadCopy(account: PortalAccountSummary): string {
: 'Open workspaces and review access here. Account changes require an owner or admin.';
}
function renderAccountContextStrip(account: PortalAccountSummary): string {
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">•</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: PortalAccountSummary[], showSelfHostedCommercial: boolean): string {
function renderIdentityBar(accounts: PortalAccountSummary[], showSelfHostedCommercial: boolean): string {
if (accounts.length === 1) {
return renderAccountContextStrip(accounts[0]);
var account = accounts[0];
return (
'<div class="portal-identity-bar">' +
'<h2>' + escapeHTML(account.name) + '</h2>' +
'<span class="portal-identity-sep">·</span>' +
'<span>' + escapeHTML(portalRoleLabel(account.role)) + '</span>' +
'<span class="portal-identity-sep">·</span>' +
'<span>' + escapeHTML(accountKindLabel(account)) + '</span>' +
'</div>'
);
}
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">•</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>'
'<div class="portal-identity-bar">' +
'<h2>Pulse Account</h2>' +
'<span class="portal-identity-sep">·</span>' +
'<span>' + String(accounts.length) + ' accounts</span>' +
'</div>'
);
}
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">•</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>'
'<div class="portal-identity-bar">' +
'<h2>Pulse Account</h2>' +
'<span class="portal-identity-sep">·</span>' +
'<span>' + escapeHTML(showSelfHostedCommercial ? 'Self-hosted billing' : 'Billing') + '</span>' +
'</div>'
);
}
@@ -387,30 +362,15 @@ 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 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: PortalBootstrapData, activeSection: PortalShellSection): string {
function renderTabBar(bootstrap: PortalBootstrapData, activeSection: PortalShellSection): string {
var sections = visibleShellSections(bootstrap);
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 class="portal-tab-bar" aria-label="Pulse Account sections">' +
sections.map(function(entry) {
var isActive = activeSection === entry.section;
var cls = 'portal-tab' + (isActive ? ' active' : '') + (entry.section === 'support' ? ' portal-tab-utility' : '');
return '<button class="' + cls + '" type="button" data-shell-action="activate-section" data-shell-section="' + entry.section + '">' + entry.title + '</button>';
}).join('') +
'</nav>'
);
}
@@ -827,12 +787,8 @@ 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 +
'<h2>' + escapeHTML(title) + '</h2>' +
(copy ? '<p>' + escapeHTML(copy) + '</p>' : '') +
'</div>'
);
}
@@ -844,14 +800,9 @@ function renderAccountBlockHeader(
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 class="portal-section-header">' +
'<h3>' + escapeHTML(account.name) + '</h3>' +
(actionsHTML ? '<div>' + actionsHTML + '</div>' : '') +
'</div>'
);
}
@@ -1260,7 +1211,7 @@ function renderBillingTaskPanel(title: string, copy: string, panelID: string, bo
function renderSupportSection(context: ShellViewContext): string {
var accounts = Array.isArray(context.bootstrap.accounts) ? context.bootstrap.accounts : [];
var hasHostedAccounts = accounts.length > 0;
var isHosted = accounts.length > 0;
var showSelfHostedCommercial = hasSelfHostedCommercial(context.bootstrap);
var supportEmail = context.bootstrap.support_email || '';
var canManageHostedTasks = false;
@@ -1270,17 +1221,15 @@ function renderSupportSection(context: ShellViewContext): string {
break;
}
}
var hostedViewOnly = hasHostedAccounts && !canManageHostedTasks;
var supportLead = supportLeadCopy(hasHostedAccounts, hostedViewOnly, showSelfHostedCommercial);
var supportChips = supportSectionChipLabels(hasHostedAccounts, hostedViewOnly, supportEmail);
var retryCopy = hasHostedAccounts
var hostedViewOnly = isHosted && !canManageHostedTasks;
var retryCopy = isHosted
? (
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 = hasHostedAccounts
var supportActions = isHosted
? (
'<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>' +
@@ -1289,18 +1238,14 @@ function renderSupportSection(context: ShellViewContext): string {
: '<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)
) +
'<h2>Support</h2>' +
'<p>Use Support only after the self-service path fails.</p>' +
'<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>Path</strong><span>' + escapeHTML(supportRunbookPathCopy(isHosted, hostedViewOnly, showSelfHostedCommercial)) + '</span></div>' +
'<div class="portal-support-simple-row"><strong>Account or email</strong><span>' + escapeHTML(supportRunbookAccountCopy(isHosted, 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">' +
@@ -1340,10 +1285,6 @@ 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
? 'Hosted billing by account. Self-hosted purchases stay separate below.'
@@ -1374,31 +1315,14 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
return (
'<div class="portal-shell" data-shell-section="' + activeSection + '">' +
'<div class="portal-shell-main">' +
renderPortalContextStrip(accounts, showSelfHostedCommercial) +
renderShellNavigation(context.bootstrap, activeSection) +
renderIdentityBar(accounts, showSelfHostedCommercial) +
renderTabBar(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">' +
@@ -1407,33 +1331,22 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
)
: '') +
'<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>' +
'<h3>Self-hosted billing</h3>' +
'<p>Use self-hosted billing only for self-hosted purchases.</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']) +
renderBillingActionRow('open-manage-billing', 'Manage subscriptions', 'Open', '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', 'Open', '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', 'Open', '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', 'Open', '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>' +
'<h4>Support</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>' +
File diff suppressed because it is too large Load Diff