Files
Ronni Baslund 3288fde693 feat(portal): customer-admin surface on real data + Stripe billing + session resilience
Access & navigation
- Gate partner-mode strictly to partner staff so admins/end-users never inherit
  leftover partner-view state; purge stale session entry on hydrate.
- Role-driven admin entry: useMe.isTenantAdmin, Admin/Personal tiles in the app
  launcher, and an /admin route guard in the global middleware (fail closed).
- Drop the duplicate user identity block from the sidebar footer.

Admin pages on real data
- New tenant-scoped, membership-gated endpoints: GET /tenants/:slug/{audit,users,
  invoices}; useTenant composable resolves the active workspace + subscription.
- Dashboard: real seats, spend (cycle-normalized + minor-units), plan, renewal,
  and recent audit; unbacked sections removed.
- Users & groups: real members; Groups/Invitations/Service accounts shown as
  honest "coming soon".
- Subscription & invoices: real plan hero, invoice history, and billing details.

Stripe payment method (Elements + SetupIntent)
- StripeClient: publishable key + getDefaultCard/createSetupIntent/setDefaultCard.
- CustomerBillingController + BillingService methods (ensure-customer on demand).
- Portal: PaymentMethodModal, useStripeJs (CDN load), proxies; hidePostalCode.

Editable billing details & whitelabel branding
- PATCH /tenants/:slug/billing-info (narrow: company/VAT/country/email).
- TenantBranding schema/service + GET/PUT /tenants/:slug/branding: real product
  name, accent colour, and per-tenant email-template overrides.
- Branding preview + sidebar workspace mark wired to real name/plan/seats/colour
  with YIQ auto-contrast (readableOn util).

Session resilience
- Request offline_access so Authentik issues a refresh token (automaticRefresh).
- Silent refresh + single retry on 401 for writes (useApiFetch, incl. partner
  pages) and reads (useMe.fetchMe) — no redirect, no lost input.
- Modal backdrop closes only on press+release on the backdrop (no more
  drag-select-to-close).
2026-05-31 00:19:34 +02:00

544 lines
24 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// Subscription & invoices. Real data: the plan hero (plan, seats, spend,
// renewal), billing details (tenant.billingInfo) and the invoice history
// (/api/tenants/:slug/invoices) all come from platform-api.
//
// No real source yet → shown as "coming soon": the stored payment method
// (Stripe doesn't expose it to platform-api). Plan-change, pause and add-seats
// still toast-stub their mutations — subscription writes are operator-only, so
// a customer admin can't commit them yet. Figures shown are the real numbers.
import type { InvoiceDoc, PaymentMethodCard, TenantUserDoc } from '~/types/workspace'
const toast = useToast()
const { request } = useApiFetch()
const { fetchMe } = useMe()
await fetchMe()
const { tenant, subscription, planLabel, currency, seatLimit, perSeatMonthly, monthlySpend, renewsAt } = useTenant()
const slug = computed(() => tenant.value?.slug ?? '')
const { data: users } = await useFetch<TenantUserDoc[]>(
() => `/api/tenants/${slug.value}/users`,
{ key: 'billing-users', default: () => [], immediate: !!slug.value, watch: [slug] },
)
const { data: invoices } = await useFetch<InvoiceDoc[]>(
() => `/api/tenants/${slug.value}/invoices`,
{ key: 'billing-invoices', default: () => [], immediate: !!slug.value, watch: [slug] },
)
const { data: paymentMethod } = await useFetch<PaymentMethodCard | null>(
() => `/api/tenants/${slug.value}/payment-method`,
{ key: 'billing-pm', default: () => null, immediate: !!slug.value, watch: [slug] },
)
const pmOpen = ref(false)
function onCardSaved(card: PaymentMethodCard | null) {
paymentMethod.value = card
toast.ok('Payment method updated')
}
// ── Edit billing details ─────────────────────────────────────────────────
const detailsOpen = ref(false)
const savingDetails = ref(false)
const det = reactive({ companyName: '', vatId: '', country: '', contactEmail: '' })
function openDetails() {
const b = billingInfo.value
det.companyName = b.companyName ?? ''
det.vatId = b.vatId ?? ''
det.country = b.country ?? ''
det.contactEmail = b.contactEmail ?? ''
detailsOpen.value = true
}
async function saveDetails() {
savingDetails.value = true
try {
await request(`/api/tenants/${slug.value}/billing-info`, { method: 'PATCH', body: { ...det } })
await fetchMe(true) // refresh cached /me so the displayed billingInfo updates
detailsOpen.value = false
toast.ok('Billing details saved')
} catch (e: unknown) {
const msg = (e as { data?: { message?: string } })?.data?.message
toast.bad('Could not save billing details', Array.isArray(msg) ? msg.join(', ') : msg)
} finally {
savingDetails.value = false
}
}
function pmExpiry(c: PaymentMethodCard): string {
return `${String(c.expMonth).padStart(2, '0')}/${c.expYear}`
}
const seatsUsed = computed(() => (users.value ?? []).filter((u) => u.active !== false).length)
const billingInfo = computed(() => tenant.value?.billingInfo ?? {})
const moneyFmt = computed(
() => new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency.value, maximumFractionDigits: 0 }),
)
function fmtDate(d: Date | null, opts: Intl.DateTimeFormatOptions = { day: '2-digit', month: 'short', year: 'numeric' }): string {
return d ? d.toLocaleDateString('da-DK', opts) : '—'
}
const cycleLabel = computed(() => {
const c = subscription.value?.cycle
return c === 'quarterly' ? 'quarterly' : c === 'yearly' ? 'yearly' : 'monthly'
})
// Invoice amounts carry their own currency and are in minor units.
function fmtMinor(minor: number, cur: string): string {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: cur, maximumFractionDigits: 0 }).format(
Math.round(minor / 100),
)
}
function invDate(inv: InvoiceDoc): string {
const iso = inv.periodStart ?? inv.createdAt
return iso ? new Date(iso).toLocaleDateString('da-DK', { day: '2-digit', month: 'short', year: 'numeric' }) : '—'
}
const invStatusTone = (s: string): 'ok' | 'warn' | 'bad' | 'neutral' =>
s === 'paid' ? 'ok' : s === 'past_due' || s === 'uncollectible' ? 'bad' : s === 'void' ? 'neutral' : 'warn'
// ── Action modals (mutations not wired — stubs) ──────────────────────────
const seatsOpen = ref(false)
const pauseOpen = ref(false)
const planOpen = ref(false)
// Add-seats math, fed by the real subscription. perSeatMonthly is already
// cycle-normalized + in major units.
const extra = ref(5)
const pricePerSeat = computed(() => perSeatMonthly.value)
const daysUntilRenewal = computed(() => {
if (!renewsAt.value) return 30
return Math.max(0, Math.round((renewsAt.value.getTime() - Date.now()) / 86_400_000))
})
const totalSeats = computed(() => seatLimit.value + extra.value)
const monthly = computed(() => extra.value * pricePerSeat.value)
const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal.value / 30)))
function quickSet(n: number) { extra.value = n }
function exportInvoices(format: 'OIOUBL' | 'CSV') {
toast.info(`Exporting invoices as ${format}`, format === 'OIOUBL' ? 'B2B · Nemhandel' : 'comma-separated · UTF-8')
}
function openInvoice(inv: InvoiceDoc) {
if (inv.pdfUrl || inv.hostedInvoiceUrl) {
window.open(inv.pdfUrl ?? inv.hostedInvoiceUrl, '_blank', 'noopener')
} else {
toast.info('Invoice document not available yet', inv.number ?? '')
}
}
function confirmPause() {
pauseOpen.value = false
toast.ok('Subscription paused', renewsAt.value ? `Resumes on ${fmtDate(renewsAt.value)}` : undefined)
}
</script>
<template>
<div>
<PageHeader
eyebrow="Billing"
title="Subscription & invoices"
subtitle="Manage your plan, payment method, and tax-compliant invoices."
/>
<div class="content">
<div class="top-row">
<!-- Hero plan card -->
<Card :pad="0">
<div class="hero">
<div class="hero-head">
<div>
<div class="kicker">// current plan</div>
<div class="hero-title">{{ planLabel }}</div>
<div class="hero-sub">{{ seatLimit }} seats · invoiced {{ cycleLabel }}</div>
</div>
<Badge tone="accent">{{ renewsAt ? `Renews ${fmtDate(renewsAt)}` : (subscription?.status ?? '—') }}</Badge>
</div>
<div class="hero-stats">
<div><div class="hero-label">Seats used</div><div class="hero-num">{{ seatsUsed }} / {{ seatLimit }}</div></div>
<div><div class="hero-label">Per month</div><div class="hero-num">{{ moneyFmt.format(monthlySpend) }}</div></div>
<div><div class="hero-label">Next invoice</div><div class="hero-num">{{ fmtDate(renewsAt, { day: '2-digit', month: 'short' }) }}</div></div>
</div>
</div>
<div class="hero-actions">
<UiButton variant="primary" @click="planOpen = true">Change plan</UiButton>
<UiButton variant="secondary" @click="seatsOpen = true">Add seats</UiButton>
<div class="spacer" />
<UiButton variant="ghost" @click="pauseOpen = true">Pause subscription</UiButton>
</div>
</Card>
<!-- Payment (coming soon) + business details (real) -->
<Card>
<div class="card-head">
<div>
<Eyebrow>Payment</Eyebrow>
<div class="card-title">Payment method</div>
</div>
<UiButton size="sm" variant="ghost" @click="pmOpen = true">{{ paymentMethod ? 'Update' : 'Add card' }}</UiButton>
</div>
<div v-if="paymentMethod" class="pm-row">
<div class="pm-chip">{{ paymentMethod.brand.toUpperCase() }}</div>
<div class="pm-meta">
<div class="pm-num"> {{ paymentMethod.last4 }}</div>
<div class="pm-sub">Expires {{ pmExpiry(paymentMethod) }}</div>
</div>
</div>
<div v-else class="soon-box">
<UiIcon name="card" :size="16" stroke="var(--text-mute)" />
<div>No card on file. Add one to pay invoices automatically handled securely by Stripe.</div>
</div>
<div class="card-head">
<div>
<Eyebrow>Business</Eyebrow>
<div class="card-title">Billing details</div>
</div>
<UiButton size="sm" variant="ghost" @click="openDetails">Edit</UiButton>
</div>
<dl class="def">
<div><dt>Company</dt><dd>{{ billingInfo.companyName || tenant?.name || '—' }}</dd></div>
<div><dt>VAT</dt><dd>{{ billingInfo.vatId || '—' }}</dd></div>
<div><dt>Country</dt><dd>{{ billingInfo.country || '—' }}</dd></div>
<div><dt>Invoice email</dt><dd>{{ billingInfo.contactEmail || '—' }}</dd></div>
<div><dt>Currency</dt><dd>{{ currency }}</dd></div>
</dl>
</Card>
</div>
<!-- Invoices table -->
<Card :pad="0">
<div class="invoices-head">
<div>
<Eyebrow>History</Eyebrow>
<div class="card-title">Invoices</div>
</div>
<div class="invoices-actions">
<UiButton size="sm" variant="secondary" :disabled="invoices.length === 0" @click="exportInvoices('OIOUBL')">OIOUBL (B2B)</UiButton>
<UiButton size="sm" variant="secondary" :disabled="invoices.length === 0" @click="exportInvoices('CSV')">CSV</UiButton>
</div>
</div>
<table class="inv-table">
<thead>
<tr>
<th>Invoice</th><th>Date</th><th>Amount</th><th>Status</th><th></th>
</tr>
</thead>
<tbody>
<tr v-for="inv in invoices" :key="inv._id">
<td><Mono>{{ inv.number || inv._id.slice(-8) }}</Mono></td>
<td>{{ invDate(inv) }}</td>
<td><span class="amount">{{ fmtMinor(inv.amountDue, inv.currency) }}</span></td>
<td><Badge :tone="invStatusTone(inv.status)" dot>{{ inv.status }}</Badge></td>
<td class="right">
<UiButton size="sm" variant="ghost" @click="openInvoice(inv)"><template #leading><UiIcon name="external" :size="13" /></template>View</UiButton>
</td>
</tr>
<tr v-if="invoices.length === 0" class="no-hover">
<td colspan="5" class="empty-row">
<Mono dim>No invoices yet. They'll appear here after your first billing cycle.</Mono>
</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- Pause subscription confirmation -->
<ConfirmDialog
:open="pauseOpen"
eyebrow="Billing · subscription"
title="Pause subscription?"
confirm-label="Pause subscription"
tone="danger"
@close="pauseOpen = false"
@confirm="confirmPause"
>
Members keep access until the end of the current billing cycle{{ renewsAt ? ` (${fmtDate(renewsAt)})` : '' }}, after
which sign-ins are blocked and data is held in cold storage. You can resume any time
to restore full access.
</ConfirmDialog>
<!-- Plan-change flow stub -->
<Modal :open="planOpen" eyebrow="Billing · plan" title="Change plan" size="md" @close="planOpen = false">
<div class="plan-stack">
<div class="lead">Pick a new tier. We'll prorate the difference and apply it on your next invoice.</div>
<div class="plan-options">
<button v-for="p in [
{ id: 'mvp', name: 'Starter', d: 'Mail · Drev · 50 GB', current: planLabel === 'Starter' },
{ id: 'pro', name: 'Business', d: 'Everything in Starter + Møder + Chat · 200 GB', current: planLabel === 'Business' },
{ id: 'enterprise', name: 'Enterprise', d: 'SSO contracts · audit log retention · 1 TB', current: planLabel === 'Enterprise' },
]" :key="p.id" :class="['plan-card', { active: p.current }]">
<div class="plan-name">{{ p.name }}{{ p.current ? ' · current' : '' }}</div>
<div class="plan-d">{{ p.d }}</div>
</button>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="planOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="planOpen = false; toast.ok('Plan change scheduled', 'Takes effect on next invoice')">
<template #leading><UiIcon name="check" :size="13" /></template>
Schedule change
</UiButton>
</template>
</Modal>
<!-- Add seats modal -->
<Modal :open="seatsOpen" eyebrow="Billing · seats" title="Add seats" size="md" @close="seatsOpen = false">
<div class="seats">
<div class="seats-3col">
<div><Eyebrow>Active users</Eyebrow><div class="big">{{ seatsUsed }}</div></div>
<div><Eyebrow>Current seats</Eyebrow><div class="big">{{ seatLimit }}</div></div>
<div><Eyebrow>After change</Eyebrow><div class="big ok">{{ totalSeats }}</div></div>
</div>
<div>
<Eyebrow>How many seats to add</Eyebrow>
<div class="stepper">
<button class="step-btn" @click="extra = Math.max(1, extra - 1)"><span class="minus" /></button>
<input type="number" :value="extra" @input="(e) => (extra = Math.max(1, Math.min(500, parseInt((e.target as HTMLInputElement).value || '0') || 1)))" />
<button class="step-btn" @click="extra = Math.min(500, extra + 1)"><UiIcon name="plus" :size="14" /></button>
</div>
<div class="presets">
<button v-for="n in [5, 10, 25, 50]" :key="n" :class="{ active: extra === n }" @click="quickSet(n)">+{{ n }}</button>
</div>
</div>
<div class="bill-box">
<Eyebrow>What you'll pay</Eyebrow>
<div class="bb-row"><span>{{ extra }} new seat{{ extra === 1 ? '' : 's' }} × {{ moneyFmt.format(pricePerSeat) }} / month</span><Mono>{{ moneyFmt.format(monthly) }} / mo</Mono></div>
<div class="bb-row sep"><span class="dim">Prorated for current cycle ({{ daysUntilRenewal }} days until renewal)</span><Mono dim>{{ moneyFmt.format(prorated) }}</Mono></div>
<div class="bb-row total"><span>Charged today</span><span class="hero-amount">{{ moneyFmt.format(prorated) }}</span></div>
<div class="bb-row"><span class="dim"><template v-if="renewsAt">Next invoice on {{ fmtDate(renewsAt) }}</template><template v-else>Next invoice</template></span><Mono dim>{{ moneyFmt.format(monthlySpend + monthly) }}</Mono></div>
</div>
<div class="trust">
<UiIcon name="card" :size="14" stroke="var(--text-mute)" />
<div>Seats are added instantly — invitations can be sent right away.</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="seatsOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="seatsOpen = false; toast.ok(`${extra} seats added · charged ${moneyFmt.format(prorated)}`)">
<template #leading><UiIcon name="plus" :size="13" /></template>
Add {{ extra }} seat{{ extra === 1 ? '' : 's' }}
</UiButton>
</template>
</Modal>
<!-- Update payment method (Stripe Elements) -->
<PaymentMethodModal :open="pmOpen" :slug="slug" @close="pmOpen = false" @saved="onCardSaved" />
<!-- Edit billing details -->
<Modal :open="detailsOpen" eyebrow="Billing · business details" title="Edit billing details" size="md" @close="detailsOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Company name</Eyebrow><input class="input" v-model="det.companyName" placeholder="Baslund ApS" /></label>
<label class="field"><Eyebrow>VAT / CVR number</Eyebrow><input class="input" v-model="det.vatId" placeholder="DK12345678" /></label>
<label class="field"><Eyebrow>Country</Eyebrow><CountrySelect v-model="det.country" placeholder="Select country" /></label>
<label class="field"><Eyebrow>Invoice email</Eyebrow><input class="input" v-model="det.contactEmail" placeholder="billing@company.com" /></label>
<div class="note">
<Mono dim>// VAT</Mono>
<div class="note-body">For Danish customers the CVR + VAT must match. EU B2B customers outside Denmark are reverse-charged (we won't add VAT; you self-account).</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="detailsOpen = false">Cancel</UiButton>
<UiButton variant="primary" :disabled="savingDetails" @click="saveDetails">
<template #leading><UiIcon name="check" :size="13" /></template>
{{ savingDetails ? 'Saving…' : 'Save details' }}
</UiButton>
</template>
</Modal>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px 40px; max-width: 1200px; }
.top-row { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; margin-bottom: 16px; }
/* Hero plan card */
.hero { padding: 28px; background: var(--text); color: var(--bg); border-radius: 8px 8px 0 0; }
.hero-head { display: flex; justify-content: space-between; align-items: flex-start; }
.kicker { font-family: var(--font-mono); font-size: 11px; color: var(--accent); letter-spacing: 0.1em; }
.hero-title {
font-family: var(--font-display);
font-size: 38px;
font-weight: 600;
letter-spacing: -0.025em;
margin-top: 10px;
}
.hero-sub { font-size: 13px; opacity: 0.6; margin-top: 4px; }
.hero-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
margin-top: 28px;
padding-top: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.hero-label { font-family: var(--font-mono); font-size: 10px; opacity: 0.5; letter-spacing: 0.12em; text-transform: uppercase; }
.hero-num { font-family: var(--font-display); font-size: 28px; font-weight: 600; margin-top: 6px; }
.hero-actions { padding: 24px; display: flex; gap: 8px; align-items: center; }
.spacer { flex: 1; }
/* Payment + business sub-cards inside one Card */
.card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.card-head + .card-head { margin-top: 16px; }
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 18px;
letter-spacing: -0.01em;
margin-top: 4px;
}
.soon-box {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 14px;
background: var(--bg);
border: 1px dashed var(--border-hi, var(--border));
border-radius: 6px;
margin-bottom: 16px;
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
}
.pm-row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: var(--bg);
border-radius: 6px;
margin-bottom: 16px;
}
.pm-chip {
height: 28px;
min-width: 44px;
padding: 0 8px;
border-radius: 4px;
background: var(--text);
color: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.05em;
}
.pm-meta { flex: 1; }
.pm-num { font-family: var(--font-mono); font-size: 13px; }
.pm-sub { font-size: 11px; color: var(--text-mute); margin-top: 2px; }
.def { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 12px; column-gap: 16px; }
.def > div { display: contents; }
.def dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); }
.def dd { margin: 0; font-size: 13px; color: var(--text); }
/* Invoices table */
.invoices-head {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.invoices-actions { display: flex; gap: 8px; }
.inv-table { width: 100%; border-collapse: collapse; }
.inv-table th {
text-align: left;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-weight: 500;
}
.inv-table td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
.inv-table tr:last-child td { border-bottom: none; }
.inv-table .right { text-align: right; display: flex; gap: 6px; justify-content: flex-end; }
.inv-table tr.no-hover td { cursor: default; }
.amount { font-family: var(--font-mono); font-size: 13px; font-weight: 500; }
.empty-row { text-align: center; padding: 40px 16px; }
.trust {
padding: 12px;
background: var(--bg);
border-radius: 6px;
border: 1px solid var(--border);
display: flex;
gap: 10px;
align-items: flex-start;
font-size: 12px;
color: var(--text-dim);
line-height: 1.55;
}
/* Add seats */
.seats { display: flex; flex-direction: column; gap: 18px; }
.seats-3col {
padding: 16px;
background: var(--bg);
border-radius: 8px;
border: 1px solid var(--border);
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.seats-3col > div { padding: 0 12px; border-right: 1px solid var(--border); }
.seats-3col > div:first-child { padding-left: 0; }
.seats-3col > div:last-child { padding-right: 0; border-right: none; }
.big { font-family: var(--font-display); font-weight: 600; font-size: 24px; margin-top: 4px; }
.big.ok { color: var(--ok); }
.stepper { display: flex; align-items: center; gap: 12px; margin: 12px 0 10px; }
.step-btn { width: 36px; height: 36px; border-radius: 6px; background: var(--surface); border: 1px solid var(--border); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.step-btn .minus { width: 12px; height: 2px; background: var(--text); display: block; }
.stepper input {
flex: 1;
height: 56px;
padding: 0 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
font-family: var(--font-display);
font-size: 32px;
font-weight: 600;
color: var(--text);
text-align: center;
outline: none;
}
.presets { display: flex; gap: 6px; flex-wrap: wrap; }
.presets button { padding: 4px 10px; border-radius: 4px; cursor: pointer; background: var(--surface); color: var(--text); border: 1px solid var(--border); font-family: var(--font-mono); font-size: 11px; }
.presets button.active { background: var(--text); color: var(--bg); border-color: var(--text); }
.bill-box { padding: 16px; background: var(--surface); border-radius: 8px; border: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; }
.bb-row { display: flex; justify-content: space-between; font-size: 13px; align-items: baseline; }
.bb-row.sep { padding-bottom: 8px; border-bottom: 1px solid var(--border); }
.bb-row.total { font-weight: 600; }
.bb-row .dim { color: var(--text-mute); }
.hero-amount { font-family: var(--font-display); font-size: 18px; letter-spacing: -0.01em; }
/* Plan-change modal */
.plan-stack { display: flex; flex-direction: column; gap: 14px; }
.lead { font-size: 13px; color: var(--text-mute); line-height: 1.55; }
.plan-options { display: flex; flex-direction: column; gap: 8px; }
.plan-card {
padding: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
text-align: left;
font-family: inherit;
color: var(--text);
cursor: pointer;
}
.plan-card.active { border-color: var(--text); background: var(--bg); }
.plan-name { font-size: 14px; font-weight: 500; }
.plan-d { font-size: 12px; color: var(--text-mute); margin-top: 6px; }
/* Edit billing details modal */
.form-stack { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.input { height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: 13px; color: var(--text); outline: none; }
.input:focus { border-color: var(--text); }
.note { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); font-size: 12px; color: var(--text-dim); line-height: 1.55; }
.note-body { margin-top: 6px; }
</style>