style(portal): turn support into escalation desk

This commit is contained in:
rcourtman 2026-03-28 04:40:06 +00:00
parent 4c4684d446
commit 8fb55097ec
6 changed files with 195 additions and 15 deletions

View file

@ -1,5 +1,5 @@
{
"source_hash": "5b47e0e6bc3d415732986d172946239c90f4770278b376eac60332e2e43e6e23",
"source_hash": "feb5de02547ef564c065366222a3551f4d56198f98db27747b883bd79f938116",
"build_inputs": [
"package.json",
"tsconfig.json",

View file

@ -856,9 +856,17 @@ header .logout-btn:hover,
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-top: 20px;
}
.portal-support-layout {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.88fr);
gap: 14px;
margin-top: 18px;
}
.portal-support-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
@ -870,11 +878,70 @@ header .logout-btn:hover,
letter-spacing: -0.02em;
}
.portal-support-card p {
margin: 10px 0 14px;
margin: 0;
font-size: 14px;
line-height: 1.65;
color: var(--ink-soft);
}
.portal-support-runbook {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #fbfcfd;
}
.portal-support-runbook h3 {
margin: 0;
font-size: 18px;
letter-spacing: -0.02em;
}
.portal-support-runbook p {
margin: 0;
font-size: 13px;
line-height: 1.55;
color: var(--ink-soft);
}
.portal-support-runbook-list,
.portal-support-points {
display: flex;
flex-direction: column;
gap: 0;
}
.portal-support-runbook-step,
.portal-support-point {
display: flex;
flex-direction: column;
gap: 4px;
padding: 11px 0 11px 12px;
border-top: 1px solid var(--line);
border-left: 2px solid rgba(11, 106, 114, 0.14);
}
.portal-support-runbook-step:first-child,
.portal-support-point:first-child {
padding-top: 0;
border-top: none;
}
.portal-support-runbook-step strong,
.portal-support-point strong {
font-size: 12px;
line-height: 1.35;
color: var(--ink);
}
.portal-support-runbook-step span,
.portal-support-point span {
font-size: 12px;
line-height: 1.5;
color: var(--ink-soft);
}
.portal-support-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: auto;
}
.account-stage-header {
padding: 0 2px 12px;
border-bottom: none;
@ -2136,6 +2203,9 @@ header .logout-btn:hover,
.portal-shell-nav-group {
grid-template-columns: 1fr;
}
.portal-support-layout {
grid-template-columns: 1fr;
}
.intro-card,
.service-panel {
padding: 18px;

View file

@ -1926,7 +1926,7 @@
]) + '</div><button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="workspaces">Close team desk</button></div><div class="team-management-stats" id="team-stats-' + escapeAttr(account.id) + '"></div><div class="team-management-grid"><div class="team-roster-column"><div class="team-roster"><div class="team-panel-heading"><h4>People on this account</h4><p>Keep the roster small and role assignment explicit. The people listed here are the ones who can operate the hosted fleet.</p></div><div class="team-roster-list" id="team-list-' + escapeAttr(account.id) + '"><div class="team-list-message">Loading\u2026</div></div></div><div class="team-review-desk"><div class="team-panel-heading team-panel-heading-tight"><div class="account-panel-kicker">Review desk</div><h4>Keep access disciplined</h4><p>Use the roster as a controlled operator surface, not a dumping ground for vague shared access.</p></div><div class="team-review-grid"><div class="team-review-card"><strong>Owners stay rare</strong><span>Reserve Owner for billing, team, and full hosted control. Default to Admin, Tech, or Read-only first.</span></div><div class="team-review-card"><strong>Keep operators narrow</strong><span>Use Tech for workspace operations and Read-only for verification instead of handing out broader access.</span></div><div class="team-review-card"><strong>Remove stale access fast</strong><span>If someone no longer owns the work, remove them instead of leaving dormant access attached to the account.</span></div></div></div></div><div class="team-side-column"><div class="team-operations-panel"><div class="team-panel-heading team-panel-heading-tight"><div class="account-panel-kicker">Access desk</div><h4>Invite and role policy</h4><p>Keep the roster deliberate. Invite the smallest role first, then tighten access as responsibilities become clearer.</p></div><div class="team-operations-grid"><div class="team-invite-panel"><div class="team-panel-heading"><h4>Invite someone new</h4><p>Add another operator with the minimum role they need for this account.</p></div><div class="team-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>' + accessPolicy + "</div></div></div></div></section></section>";
}
function renderSupportSection(context) {
return '<section class="portal-support-panel"><div class="account-panel-kicker">Support</div><h2>Support and escalation</h2><p>Use support when hosted access looks wrong, billing does not behave as expected, or you need help with commercial licensing and privacy actions.</p><div class="portal-support-card-grid"><div class="portal-support-card"><h3>Account support</h3><p>For access, tenant handoff, team, and billing issues, contact the hosted operations desk.</p><a class="portal-support-link" href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + '</a></div><div class="portal-support-card"><h3>Commercial services</h3><p>Self-hosted subscriptions, license recovery, refunds, and privacy requests all route through the same account surface.</p><button type="button" class="btn-secondary" data-shell-action="activate-section" data-shell-section="services">Open account services</button></div></div></section>';
return '<section class="portal-support-panel"><div class="account-panel-kicker">Support</div><h2>Support and escalation</h2><p>Use support when hosted access looks wrong, billing does not behave as expected, or you need help with commercial licensing and privacy actions.</p>' + renderSectionContextChips(["Hosted issues", "Commercial requests", context.bootstrap.support_email ? "Email" : "Support"]) + '<div class="portal-support-layout"><div class="portal-support-card-grid"><div class="portal-support-card"><div class="account-panel-kicker">Hosted account</div><h3>Account support</h3><p>Use this route when tenant handoff, workspace access, team control, or hosted billing looks wrong.</p><div class="portal-support-points"><div class="portal-support-point"><strong>Route here for hosted issues</strong><span>Access, handoff, team, and hosted billing all belong on the hosted account path.</span></div><div class="portal-support-point"><strong>Keep the account context intact</strong><span>Include the account, workspace, and action that failed so support can pick up the same operator path quickly.</span></div></div><div class="portal-support-actions"><a class="portal-support-link" href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + '</a></div></div><div class="portal-support-card"><div class="account-panel-kicker">Commercial</div><h3>Commercial services</h3><p>Self-hosted subscriptions, license recovery, refunds, and privacy requests all route through the account services desk first.</p><div class="portal-support-points"><div class="portal-support-point"><strong>Start in Account services</strong><span>Use the billing, license, refund, or privacy desk before escalating a commercial issue.</span></div><div class="portal-support-point"><strong>Escalate from the same desk</strong><span>Keep the request in one place instead of splitting it between billing and operator surfaces.</span></div></div><div class="portal-support-actions"><button type="button" class="btn-secondary" data-shell-action="activate-section" data-shell-section="services">Open account services</button></div></div></div><div class="portal-support-runbook"><div class="account-panel-kicker">Escalation desk</div><h3>Route the issue cleanly</h3><p>Keep hosted operations, commercial requests, and pure support escalation on their own paths so the next person does not have to reconstruct the account state.</p><div class="portal-support-runbook-list"><div class="portal-support-runbook-step"><strong>1. Confirm the scope</strong><span>Decide whether the problem is hosted operations, commercial self-service, or direct support escalation.</span></div><div class="portal-support-runbook-step"><strong>2. Keep hosted and commercial separate</strong><span>Workspace and team problems stay in their own desks. Billing, license, refund, and privacy work stay in Account services.</span></div><div class="portal-support-runbook-step"><strong>3. Escalate with context</strong><span>Include the account, workspace, and exact failed action so the escalation path starts with the same facts you saw.</span></div></div></div></div></section>';
}
function renderHeaderHTML(context) {
if (context.bootstrap.authenticated) {

View file

@ -211,6 +211,8 @@ describe('shell view', function() {
expect(html).toContain('License desk');
expect(html).toContain('Refund desk');
expect(html).toContain('Privacy desk');
expect(html).toContain('Route the issue cleanly');
expect(html).toContain('Open account services');
});
it('renders self-hosted overview copy when no hosted accounts are attached', function() {

View file

@ -801,16 +801,43 @@ function renderSupportSection(context: ShellViewContext): string {
'<div class="account-panel-kicker">Support</div>' +
'<h2>Support and escalation</h2>' +
'<p>Use support when hosted access looks wrong, billing does not behave as expected, or you need help with commercial licensing and privacy actions.</p>' +
'<div class="portal-support-card-grid">' +
'<div class="portal-support-card">' +
'<h3>Account support</h3>' +
'<p>For access, tenant handoff, team, and billing issues, contact the hosted operations desk.</p>' +
'<a class="portal-support-link" href="mailto:' + escapeAttr(context.bootstrap.support_email || '') + '">' + escapeHTML(context.bootstrap.support_email || '') + '</a>' +
renderSectionContextChips(['Hosted issues', 'Commercial requests', context.bootstrap.support_email ? 'Email' : 'Support']) +
'<div class="portal-support-layout">' +
'<div class="portal-support-card-grid">' +
'<div class="portal-support-card">' +
'<div class="account-panel-kicker">Hosted account</div>' +
'<h3>Account support</h3>' +
'<p>Use this route when tenant handoff, workspace access, team control, or hosted billing looks wrong.</p>' +
'<div class="portal-support-points">' +
'<div class="portal-support-point"><strong>Route here for hosted issues</strong><span>Access, handoff, team, and hosted billing all belong on the hosted account path.</span></div>' +
'<div class="portal-support-point"><strong>Keep the account context intact</strong><span>Include the account, workspace, and action that failed so support can pick up the same operator path quickly.</span></div>' +
'</div>' +
'<div class="portal-support-actions">' +
'<a class="portal-support-link" href="mailto:' + escapeAttr(context.bootstrap.support_email || '') + '">' + escapeHTML(context.bootstrap.support_email || '') + '</a>' +
'</div>' +
'</div>' +
'<div class="portal-support-card">' +
'<div class="account-panel-kicker">Commercial</div>' +
'<h3>Commercial services</h3>' +
'<p>Self-hosted subscriptions, license recovery, refunds, and privacy requests all route through the account services desk first.</p>' +
'<div class="portal-support-points">' +
'<div class="portal-support-point"><strong>Start in Account services</strong><span>Use the billing, license, refund, or privacy desk before escalating a commercial issue.</span></div>' +
'<div class="portal-support-point"><strong>Escalate from the same desk</strong><span>Keep the request in one place instead of splitting it between billing and operator surfaces.</span></div>' +
'</div>' +
'<div class="portal-support-actions">' +
'<button type="button" class="btn-secondary" data-shell-action="activate-section" data-shell-section="services">Open account services</button>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="portal-support-card">' +
'<h3>Commercial services</h3>' +
'<p>Self-hosted subscriptions, license recovery, refunds, and privacy requests all route through the same account surface.</p>' +
'<button type="button" class="btn-secondary" data-shell-action="activate-section" data-shell-section="services">Open account services</button>' +
'<div class="portal-support-runbook">' +
'<div class="account-panel-kicker">Escalation desk</div>' +
'<h3>Route the issue cleanly</h3>' +
'<p>Keep hosted operations, commercial requests, and pure support escalation on their own paths so the next person does not have to reconstruct the account state.</p>' +
'<div class="portal-support-runbook-list">' +
'<div class="portal-support-runbook-step"><strong>1. Confirm the scope</strong><span>Decide whether the problem is hosted operations, commercial self-service, or direct support escalation.</span></div>' +
'<div class="portal-support-runbook-step"><strong>2. Keep hosted and commercial separate</strong><span>Workspace and team problems stay in their own desks. Billing, license, refund, and privacy work stay in Account services.</span></div>' +
'<div class="portal-support-runbook-step"><strong>3. Escalate with context</strong><span>Include the account, workspace, and exact failed action so the escalation path starts with the same facts you saw.</span></div>' +
'</div>' +
'</div>' +
'</div>' +
'</section>'

View file

@ -961,10 +961,19 @@ header .logout-btn:hover,
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-top: 20px;
}
.portal-support-layout {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.88fr);
gap: 14px;
margin-top: 18px;
}
.portal-support-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
@ -978,12 +987,80 @@ header .logout-btn:hover,
}
.portal-support-card p {
margin: 10px 0 14px;
margin: 0;
font-size: 14px;
line-height: 1.65;
color: var(--ink-soft);
}
.portal-support-runbook {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #fbfcfd;
}
.portal-support-runbook h3 {
margin: 0;
font-size: 18px;
letter-spacing: -0.02em;
}
.portal-support-runbook p {
margin: 0;
font-size: 13px;
line-height: 1.55;
color: var(--ink-soft);
}
.portal-support-runbook-list,
.portal-support-points {
display: flex;
flex-direction: column;
gap: 0;
}
.portal-support-runbook-step,
.portal-support-point {
display: flex;
flex-direction: column;
gap: 4px;
padding: 11px 0 11px 12px;
border-top: 1px solid var(--line);
border-left: 2px solid rgba(11, 106, 114, 0.14);
}
.portal-support-runbook-step:first-child,
.portal-support-point:first-child {
padding-top: 0;
border-top: none;
}
.portal-support-runbook-step strong,
.portal-support-point strong {
font-size: 12px;
line-height: 1.35;
color: var(--ink);
}
.portal-support-runbook-step span,
.portal-support-point span {
font-size: 12px;
line-height: 1.5;
color: var(--ink-soft);
}
.portal-support-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: auto;
}
.account-stage-header {
padding: 0 2px 12px;
border-bottom: none;
@ -2440,6 +2517,10 @@ header .logout-btn:hover,
grid-template-columns: 1fr;
}
.portal-support-layout {
grid-template-columns: 1fr;
}
.intro-card,
.service-panel {
padding: 18px;