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).
This commit is contained in:
+212
-238
@@ -1,76 +1,130 @@
|
||||
<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.
|
||||
// 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 paymentOpen = ref(false)
|
||||
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)
|
||||
|
||||
// AddSeats math
|
||||
const used = 11
|
||||
const current = 25
|
||||
const pricePerSeat = 78
|
||||
const daysUntilRenewal = 96
|
||||
// Add-seats math, fed by the real subscription. perSeatMonthly is already
|
||||
// cycle-normalized + in major units.
|
||||
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 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 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' },
|
||||
]
|
||||
|
||||
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 downloadInvoice(id: string) {
|
||||
toast.info('Downloading invoice…', id)
|
||||
}
|
||||
function viewInvoice(id: string) {
|
||||
toast.info('Opening invoice', id)
|
||||
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', 'Resumes automatically on 28 Aug 2026')
|
||||
toast.ok('Subscription paused', renewsAt.value ? `Resumes on ${fmtDate(renewsAt.value)}` : undefined)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -80,14 +134,7 @@ function confirmPause() {
|
||||
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">
|
||||
@@ -97,15 +144,15 @@ function confirmPause() {
|
||||
<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 class="hero-title">{{ planLabel }}</div>
|
||||
<div class="hero-sub">{{ seatLimit }} seats · invoiced {{ cycleLabel }}</div>
|
||||
</div>
|
||||
<Badge tone="accent">Renews 28 Aug 2026</Badge>
|
||||
<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">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 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">
|
||||
@@ -116,36 +163,40 @@ function confirmPause() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Payment + business details -->
|
||||
<!-- 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="paymentOpen = true">Update</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="pmOpen = true">{{ paymentMethod ? 'Update' : 'Add card' }}</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 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="detailsOpen = true">Edit</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="openDetails">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>
|
||||
<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>
|
||||
@@ -158,8 +209,8 @@ function confirmPause() {
|
||||
<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>
|
||||
<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">
|
||||
@@ -169,14 +220,18 @@ function confirmPause() {
|
||||
</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>
|
||||
<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="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>
|
||||
<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>
|
||||
@@ -184,103 +239,6 @@ function confirmPause() {
|
||||
</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"
|
||||
@@ -291,7 +249,7 @@ function confirmPause() {
|
||||
@close="pauseOpen = false"
|
||||
@confirm="confirmPause"
|
||||
>
|
||||
Members keep access until the end of the current billing cycle (28 Aug 2026), after
|
||||
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>
|
||||
@@ -302,12 +260,11 @@ function confirmPause() {
|
||||
<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', current: false },
|
||||
{ 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', current: false },
|
||||
{ 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 }}</div>
|
||||
<Mono dim>{{ p.price }}</Mono>
|
||||
<div class="plan-name">{{ p.name }}{{ p.current ? ' · current' : '' }}</div>
|
||||
<div class="plan-d">{{ p.d }}</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -325,8 +282,8 @@ function confirmPause() {
|
||||
<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>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>
|
||||
|
||||
@@ -344,25 +301,49 @@ function confirmPause() {
|
||||
|
||||
<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 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>Charged to <Mono>Visa •••• 4242</Mono>. Seats are added instantly — invitations can be sent right away.</div>
|
||||
<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 ${prorated.toLocaleString('da-DK')} DKK`)">
|
||||
<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>
|
||||
|
||||
@@ -404,7 +385,21 @@ function confirmPause() {
|
||||
letter-spacing: -0.01em;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.visa-row {
|
||||
.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;
|
||||
@@ -413,9 +408,10 @@ function confirmPause() {
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.visa {
|
||||
width: 40px;
|
||||
.pm-chip {
|
||||
height: 28px;
|
||||
min-width: 44px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
@@ -427,9 +423,9 @@ function confirmPause() {
|
||||
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; }
|
||||
.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; }
|
||||
@@ -460,39 +456,9 @@ function confirmPause() {
|
||||
.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; }
|
||||
|
||||
/* 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; }
|
||||
.empty-row { text-align: center; padding: 40px 16px; }
|
||||
|
||||
.trust {
|
||||
padding: 12px;
|
||||
@@ -566,4 +532,12 @@ function confirmPause() {
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user