Files
dezky/apps/portal/pages/admin/billing.vue
T
Ronni Baslund 0bd4e5498e 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
2026-05-28 20:00:33 +02:00

570 lines
25 KiB
Vue
Raw 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">
// 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>