feat: portal redesign, pricing catalog, partner-staff invites

- portal: new admin/ and partner/ surfaces with full component library
  (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables,
  layouts, partner-routing middleware, and supporting server APIs
- pricing: Price schema/module with operator CRUD, pricing.vue catalog UI,
  Subscription extended with cycle/currency/perSeatAmount/seats snapshots
  for stable MRR aggregation
- partner staff: User.partnerId, invite-partner-user DTO and flow,
  /partners/:slug/users endpoints, InvitePartnerUserModal, shared
  dezky-partner-staff Authentik group
- /me: partner-aware endpoint returning user + partner context so portal
  can route between end-user and partner-admin surfaces
- tenant: seats field for portfolio displays and future MRR calculations
- operator: pricing page, signed-out page, useMe/useToast composables,
  ToastStack
This commit is contained in:
Ronni Baslund
2026-05-28 20:00:33 +02:00
parent be430179d9
commit 0bd4e5498e
144 changed files with 22110 additions and 209 deletions
+569
View File
@@ -0,0 +1,569 @@
<script setup lang="ts">
// Strict port of project/platform-screens.jsx `BillingScreen` (lines 1134-1257)
// with UpdatePaymentMethodModal (1262), EditBillingDetailsModal (1357) and
// AddSeatsModal (1415). Hero plan card on a 1.4fr/1fr split with the payment
// + business sub-cards on the right.
const toast = useToast()
const paymentOpen = ref(false)
const detailsOpen = ref(false)
const seatsOpen = ref(false)
const pauseOpen = ref(false)
const planOpen = ref(false)
// AddSeats math
const used = 11
const current = 25
const pricePerSeat = 78
const daysUntilRenewal = 96
const extra = ref(5)
const totalSeats = computed(() => current + extra.value)
const monthly = computed(() => extra.value * pricePerSeat)
const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 30)))
// UpdatePaymentMethod modal state
type Method = 'card' | 'invoice' | 'sepa'
const method = ref<Method>('card')
const card = reactive({ number: '', name: 'Anne Baslund', exp: '', cvc: '', country: 'DK', zip: '1620' })
// Edit billing details state
const det = reactive({
company: 'Baslund ApS',
cvr: '42 18 09 33',
contact: 'Anne Baslund',
email: 'billing@dezky.com',
addr1: 'Vesterbrogade 14',
addr2: '',
zip: '1620',
city: 'København V',
country: 'DK',
vat: 'DK 42 18 09 33',
currency: 'DKK',
})
const invoices = [
{ id: 'INV-2026-005', date: '01 May 2026', amount: '1.940,00 DKK', status: 'Paid' },
{ id: 'INV-2026-004', date: '01 Apr 2026', amount: '1.940,00 DKK', status: 'Paid' },
{ id: 'INV-2026-003', date: '01 Mar 2026', amount: '1.560,00 DKK', status: 'Paid' },
{ id: 'INV-2026-002', date: '01 Feb 2026', amount: '1.560,00 DKK', status: 'Paid' },
{ id: 'INV-2026-001', date: '01 Jan 2026', amount: '1.560,00 DKK', status: 'Paid' },
]
const payMethods = [
{ v: 'card' as const, l: 'Card', d: 'Visa · MC · Amex' },
{ v: 'invoice' as const, l: 'Invoice (EAN)', d: 'Net 14 · DK B2B' },
{ v: 'sepa' as const, l: 'SEPA · MobilePay', d: 'Direct debit · DK' },
]
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 downloadInvoice(id: string) {
toast.info('Downloading invoice…', id)
}
function viewInvoice(id: string) {
toast.info('Opening invoice', id)
}
function confirmPause() {
pauseOpen.value = false
toast.ok('Subscription paused', 'Resumes automatically on 28 Aug 2026')
}
</script>
<template>
<div>
<PageHeader
eyebrow="Billing"
title="Subscription & invoices"
subtitle="Manage your plan, payment method, and tax-compliant invoices."
>
<template #actions>
<UiButton variant="secondary" @click="toast.info('Bundling invoice ZIP…')">
<template #leading><UiIcon name="download" :size="14" /></template>
Download all (.zip)
</UiButton>
</template>
</PageHeader>
<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">Business</div>
<div class="hero-sub">25 seats · invoiced monthly</div>
</div>
<Badge tone="accent">Renews 28 Aug 2026</Badge>
</div>
<div class="hero-stats">
<div><div class="hero-label">Seats used</div><div class="hero-num">11 / 25</div></div>
<div><div class="hero-label">This month</div><div class="hero-num">1.940 DKK</div></div>
<div><div class="hero-label">Next invoice</div><div class="hero-num">01 Jun</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 + business details -->
<Card>
<div class="card-head">
<div>
<Eyebrow>Payment</Eyebrow>
<div class="card-title">Payment method</div>
</div>
<UiButton size="sm" variant="ghost" @click="paymentOpen = true">Update</UiButton>
</div>
<div class="visa-row">
<div class="visa">VISA</div>
<div class="visa-meta">
<div class="visa-num"> 4242</div>
<div class="visa-sub">Expires 11/2028 · Anne Baslund</div>
</div>
</div>
<div class="card-head">
<div>
<Eyebrow>Business</Eyebrow>
<div class="card-title">Billing details</div>
</div>
<UiButton size="sm" variant="ghost" @click="detailsOpen = true">Edit</UiButton>
</div>
<dl class="def">
<div><dt>Company</dt><dd>Baslund ApS</dd></div>
<div><dt>CVR</dt><dd>42 18 09 33</dd></div>
<div><dt>Address</dt><dd>Vesterbrogade 14, 1620 København V</dd></div>
<div><dt>VAT</dt><dd>DK 42 18 09 33</dd></div>
<div><dt>Currency</dt><dd>DKK · EUR available</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" @click="exportInvoices('OIOUBL')">OIOUBL (B2B)</UiButton>
<UiButton size="sm" variant="secondary" @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.id }}</Mono></td>
<td>{{ inv.date }}</td>
<td><span class="amount">{{ inv.amount }}</span></td>
<td><Badge tone="ok" dot>{{ inv.status.toLowerCase() }}</Badge></td>
<td class="right">
<UiButton size="sm" variant="ghost" @click="downloadInvoice(inv.id)"><template #leading><UiIcon name="download" :size="13" /></template>PDF</UiButton>
<UiButton size="sm" variant="ghost" @click="viewInvoice(inv.id)"><template #leading><UiIcon name="external" :size="13" /></template>View</UiButton>
</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- Update payment method modal -->
<Modal :open="paymentOpen" eyebrow="Billing · payment method" title="Update payment method" size="md" @close="paymentOpen = false">
<div class="pay">
<div>
<Eyebrow>Pay by</Eyebrow>
<div class="pay-options">
<button v-for="o in payMethods" :key="o.v" :class="{ active: method === o.v }" @click="method = o.v">
<div class="po-label">{{ o.l }}</div>
<Mono dim>{{ o.d }}</Mono>
</button>
</div>
</div>
<template v-if="method === 'card'">
<label class="field"><Eyebrow>Card number</Eyebrow>
<div class="input-row">
<UiIcon name="card" :size="15" stroke="var(--text-mute)" />
<input v-model="card.number" placeholder="4242 4242 4242 4242" />
<Mono dim>VISA · MC · AMEX</Mono>
</div>
</label>
<label class="field"><Eyebrow>Name on card</Eyebrow><input class="input" v-model="card.name" /></label>
<div class="grid-2">
<label class="field"><Eyebrow>Expiry</Eyebrow><input class="input" v-model="card.exp" placeholder="MM / YY" /></label>
<label class="field"><Eyebrow>CVC</Eyebrow><input class="input" v-model="card.cvc" placeholder="3 digits" /></label>
</div>
<div class="grid-14-1">
<label class="field"><Eyebrow>Country</Eyebrow><CountrySelect v-model="card.country" /></label>
<label class="field"><Eyebrow>Postal code</Eyebrow><input class="input" v-model="card.zip" /></label>
</div>
</template>
<template v-else-if="method === 'invoice'">
<label class="field"><Eyebrow>EAN number</Eyebrow><input class="input" placeholder="5790000000000" /></label>
<label class="field"><Eyebrow>Purchase order reference (optional)</Eyebrow><input class="input" placeholder="Internal PO # to include on invoice" /></label>
<div class="note">
<Mono dim>// OIOUBL · Nemhandel</Mono>
<div class="note-body">Invoices are delivered to your EAN via the Nemhandel network. Payment terms are <b>net 14 days</b> from invoice date. The first invoice arrives on the next billing cycle.</div>
</div>
</template>
<template v-else>
<label class="field"><Eyebrow>IBAN</Eyebrow><input class="input" placeholder="DK00 0000 0000 0000 00" /></label>
<label class="field"><Eyebrow>Account holder name</Eyebrow><input class="input" value="Baslund ApS" /></label>
<label class="check"><input type="checkbox" checked /> I authorise Dezky to debit this account by SEPA Direct Debit</label>
</template>
<div class="trust">
<UiIcon name="shield" :size="14" stroke="var(--ok)" />
<div>Payment details are tokenised by our processor. Dezky never sees raw card numbers, IBANs, or CVC codes. PCI DSS Level 1.</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="paymentOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="paymentOpen = false; toast.ok('Payment method saved')">
<template #leading><UiIcon name="check" :size="13" /></template>
Save payment method
</UiButton>
</template>
</Modal>
<!-- Edit billing details modal -->
<Modal :open="detailsOpen" eyebrow="Billing · business details" title="Edit billing details" size="lg" @close="detailsOpen = false">
<div class="details">
<div class="grid-co">
<label class="field"><Eyebrow>Company name</Eyebrow><input class="input" v-model="det.company" /></label>
<label class="field"><Eyebrow>CVR / org. number</Eyebrow><input class="input" v-model="det.cvr" /></label>
</div>
<div class="grid-2">
<label class="field"><Eyebrow>Billing contact</Eyebrow><input class="input" v-model="det.contact" /></label>
<label class="field"><Eyebrow>Invoice email</Eyebrow><input class="input" v-model="det.email" /></label>
</div>
<label class="field"><Eyebrow>Address line 1</Eyebrow><input class="input" v-model="det.addr1" /></label>
<label class="field"><Eyebrow>Address line 2 (optional)</Eyebrow><input class="input" v-model="det.addr2" placeholder="Floor, suite, c/o…" /></label>
<div class="grid-zip">
<label class="field"><Eyebrow>Postal code</Eyebrow><input class="input" v-model="det.zip" /></label>
<label class="field"><Eyebrow>City</Eyebrow><input class="input" v-model="det.city" /></label>
<label class="field"><Eyebrow>Country</Eyebrow><CountrySelect v-model="det.country" /></label>
</div>
<div class="grid-co">
<label class="field"><Eyebrow>VAT number</Eyebrow><input class="input" v-model="det.vat" /></label>
<label class="field"><Eyebrow>Currency</Eyebrow><input class="input" v-model="det.currency" /></label>
</div>
<div class="note">
<Mono dim>// VAT</Mono>
<div class="note-body">For Danish customers, the CVR + VAT must match. Reverse-charge applies for EU B2B customers outside Denmark (we won't charge VAT, you self-account).</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="detailsOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="detailsOpen = false; toast.ok('Billing details saved')">
<template #leading><UiIcon name="check" :size="13" /></template>
Save details
</UiButton>
</template>
</Modal>
<!-- 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 (28 Aug 2026), 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: 'basic', name: 'Basic', price: '49 DKK / seat / mo', d: 'Mail · Drev · 50 GB' },
{ id: 'business', name: 'Business · current', price: '78 DKK / seat / mo', d: 'Everything in Basic + Møder + Chat · 200 GB', current: true },
{ id: 'enterprise', name: 'Enterprise', price: 'from 140 DKK / seat / mo', d: 'SSO contracts · audit log retention · 1 TB' },
]" :key="p.id" :class="['plan-card', { active: p.current }]">
<div class="plan-name">{{ p.name }}</div>
<Mono dim>{{ p.price }}</Mono>
<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">{{ used }}</div></div>
<div><Eyebrow>Current seats</Eyebrow><div class="big">{{ current }}</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' }} × {{ pricePerSeat }} DKK / month</span><Mono>{{ monthly.toLocaleString('da-DK') }} DKK / mo</Mono></div>
<div class="bb-row sep"><span class="dim">Prorated for current cycle ({{ daysUntilRenewal }} days until renewal)</span><Mono dim>{{ prorated.toLocaleString('da-DK') }} DKK</Mono></div>
<div class="bb-row total"><span>Charged today</span><span class="hero-amount">{{ prorated.toLocaleString('da-DK') }} DKK</span></div>
<div class="bb-row"><span class="dim">Next invoice on 01 Jun 2026</span><Mono dim>{{ (1940 + monthly).toLocaleString('da-DK') }} DKK</Mono></div>
</div>
<div class="trust">
<UiIcon name="card" :size="14" stroke="var(--text-mute)" />
<div>Charged to <Mono>Visa •••• 4242</Mono>. 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 ${prorated.toLocaleString('da-DK')} DKK`)">
<template #leading><UiIcon name="plus" :size="13" /></template>
Add {{ extra }} seat{{ extra === 1 ? '' : 's' }}
</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;
}
.visa-row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: var(--bg);
border-radius: 6px;
margin-bottom: 16px;
}
.visa {
width: 40px;
height: 28px;
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;
}
.visa-meta { flex: 1; }
.visa-num { font-family: var(--font-mono); font-size: 13px; }
.visa-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; }
.amount { font-family: var(--font-mono); font-size: 13px; font-weight: 500; }
/* Modal forms */
.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); }
.input-row {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 36px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.input-row input { flex: 1; border: none; outline: none; background: transparent; font-family: var(--font-mono); font-size: 13px; color: var(--text); letter-spacing: 0.05em; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.grid-14-1 { display: grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
.grid-co { display: grid; grid-template-columns: 1fr 200px; gap: 12px; }
.grid-zip { display: grid; grid-template-columns: 120px 1fr 1fr; gap: 12px; }
.pay { display: flex; flex-direction: column; gap: 16px; }
.pay-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 8px; }
.pay-options button { padding: 12px; border-radius: 6px; text-align: left; font-family: inherit; cursor: pointer; border: 1px solid var(--border); background: var(--surface); }
.pay-options button.active { border-color: var(--text); background: var(--bg); }
.po-label { font-size: 13px; font-weight: 500; }
.details { display: flex; flex-direction: column; gap: 14px; }
.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; }
.check { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.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; }
</style>