Files
dezky/apps/operator/pages/pricing.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

347 lines
12 KiB
Vue

<script setup lang="ts">
// Pricing catalog editor. Operator-only. Each (plan, cycle) is a single row
// with three independent per-currency amounts (DKK / EUR / USD). Operator
// types clean round numbers in each currency — no FX derivation. Empty cells
// mean "we don't sell this plan/cycle in that currency."
//
// Backed by /prices in platform-api. Amounts are stored in MINOR units
// (4900 = 49.00); display uses major-unit strings with 2 decimals.
const CURRENCIES = ['DKK', 'EUR', 'USD'] as const
type Currency = (typeof CURRENCIES)[number]
interface PriceRow {
_id: string
plan: 'mvp' | 'pro' | 'enterprise'
cycle: 'monthly' | 'quarterly' | 'yearly'
amounts: Partial<Record<Currency, number>>
active: boolean
createdAt?: string
updatedAt?: string
}
const toast = useToast()
const showInactive = ref(false)
const { data: prices, refresh } = await useFetch<PriceRow[]>(
() => `/api/prices${showInactive.value ? '?includeInactive=true' : ''}`,
{ key: 'pricing-catalog', default: () => [], watch: [showInactive] },
)
const PLAN_LABEL: Record<PriceRow['plan'], string> = {
mvp: 'Starter',
pro: 'Business',
enterprise: 'Enterprise',
}
const CYCLE_LABEL: Record<PriceRow['cycle'], string> = {
monthly: 'Monthly',
quarterly: 'Quarterly',
yearly: 'Yearly',
}
function toMajor(minor?: number): string {
if (typeof minor !== 'number') return ''
return (minor / 100).toFixed(2)
}
function toMinor(major: string): number | undefined {
if (!major.trim()) return undefined // empty → unset that currency
const cleaned = major.replace(/\s+/g, '').replace(',', '.')
const n = Number(cleaned)
if (!Number.isFinite(n) || n < 0) return NaN
return Math.round(n * 100)
}
// Per-row draft. Keyed by row._id, holds one major-unit string per currency.
// undefined draft = not currently editing.
type Draft = Record<Currency, string>
const drafts = reactive<Record<string, Draft>>({})
const saving = ref<string | null>(null)
function startEdit(row: PriceRow) {
drafts[row._id] = {
DKK: toMajor(row.amounts.DKK),
EUR: toMajor(row.amounts.EUR),
USD: toMajor(row.amounts.USD),
}
}
function cancelEdit(id: string) {
delete drafts[id]
}
async function saveEdit(row: PriceRow) {
const draft = drafts[row._id]
if (!draft) return
const next: Partial<Record<Currency, number>> = {}
for (const c of CURRENCIES) {
const parsed = toMinor(draft[c])
if (parsed === undefined) continue // empty input → currency stays unset
if (Number.isNaN(parsed)) {
toast.warn(`Invalid ${c} amount`, 'Enter a number ≥ 0, or blank to leave unset')
return
}
next[c] = parsed
}
saving.value = row._id
try {
await $fetch(`/api/prices/${row._id}`, { method: 'PATCH', body: { amounts: next } })
toast.ok('Prices updated', `${PLAN_LABEL[row.plan]} · ${CYCLE_LABEL[row.cycle]}`)
cancelEdit(row._id)
await refresh()
} catch (err) {
const e = err as { data?: { data?: { message?: string }; message?: string } }
toast.warn('Update failed', e.data?.data?.message ?? e.data?.message ?? 'Try again')
} finally {
saving.value = null
}
}
async function toggleActive(row: PriceRow) {
saving.value = row._id
try {
await $fetch(`/api/prices/${row._id}`, { method: 'PATCH', body: { active: !row.active } })
toast.ok(
row.active ? 'Price deactivated' : 'Price reactivated',
`${PLAN_LABEL[row.plan]} · ${CYCLE_LABEL[row.cycle]}`,
)
await refresh()
} catch (err) {
const e = err as { data?: { data?: { message?: string }; message?: string } }
toast.warn('Toggle failed', e.data?.data?.message ?? e.data?.message ?? 'Try again')
} finally {
saving.value = null
}
}
// Add-row form. Used to insert an Enterprise row or replace a deactivated one.
const addForm = reactive<{
plan: PriceRow['plan']
cycle: PriceRow['cycle']
amounts: Record<Currency, string>
}>({
plan: 'enterprise',
cycle: 'monthly',
amounts: { DKK: '', EUR: '', USD: '' },
})
const adding = ref(false)
async function addRow() {
const amounts: Partial<Record<Currency, number>> = {}
for (const c of CURRENCIES) {
const parsed = toMinor(addForm.amounts[c])
if (parsed === undefined) continue
if (Number.isNaN(parsed)) {
toast.warn(`Invalid ${c} amount`, 'Enter a number ≥ 0')
return
}
amounts[c] = parsed
}
if (Object.keys(amounts).length === 0) {
toast.warn('No prices entered', 'Set at least one currency amount')
return
}
adding.value = true
try {
await $fetch('/api/prices', {
method: 'POST',
body: { plan: addForm.plan, cycle: addForm.cycle, amounts },
})
toast.ok('Row added', `${PLAN_LABEL[addForm.plan]} · ${CYCLE_LABEL[addForm.cycle]}`)
for (const c of CURRENCIES) addForm.amounts[c] = ''
await refresh()
} catch (err) {
const e = err as { data?: { data?: { message?: string }; message?: string } }
toast.warn('Add failed', e.data?.data?.message ?? e.data?.message ?? 'Try again')
} finally {
adding.value = false
}
}
// Sort: active first, then plan order (mvp/pro/enterprise), then cycle order.
const PLAN_ORDER: Record<PriceRow['plan'], number> = { mvp: 0, pro: 1, enterprise: 2 }
const CYCLE_ORDER: Record<PriceRow['cycle'], number> = { monthly: 0, quarterly: 1, yearly: 2 }
const sortedPrices = computed<PriceRow[]>(() =>
[...(prices.value ?? [])].sort((a, b) => {
if (a.active !== b.active) return a.active ? -1 : 1
if (a.plan !== b.plan) return PLAN_ORDER[a.plan] - PLAN_ORDER[b.plan]
return CYCLE_ORDER[a.cycle] - CYCLE_ORDER[b.cycle]
}),
)
</script>
<template>
<div class="stage">
<PageHeader
eyebrow="Operator · operator.dezky.local"
title="Pricing catalog"
subtitle="One row per plan + cycle, with independent prices per currency. Changes affect subscriptions provisioned from now on — existing customers keep the price snapshot taken at provisioning."
>
<template #actions>
<label class="toggle">
<input v-model="showInactive" type="checkbox" />
Show inactive
</label>
</template>
</PageHeader>
<Card :pad="0">
<table>
<thead>
<tr>
<th>Plan</th>
<th>Cycle</th>
<th v-for="c in CURRENCIES" :key="c" class="th-amount">{{ c }} / seat</th>
<th>Status</th>
<th class="th-actions">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="sortedPrices.length === 0" class="empty">
<td :colspan="4 + CURRENCIES.length">
<span class="empty-inner">No prices yet add one below.</span>
</td>
</tr>
<tr v-for="row in sortedPrices" :key="row._id" :class="{ inactive: !row.active }">
<td>
<div class="cell-name">{{ PLAN_LABEL[row.plan] }}</div>
<Mono dim>{{ row.plan }}</Mono>
</td>
<td>{{ CYCLE_LABEL[row.cycle] }}</td>
<td v-for="c in CURRENCIES" :key="c" class="cell-amount">
<template v-if="drafts[row._id]">
<input
v-model="drafts[row._id][c]"
type="text"
inputmode="decimal"
class="amount-input"
:placeholder="`— ${c}`"
:disabled="saving === row._id"
@keydown.enter="saveEdit(row)"
@keydown.escape="cancelEdit(row._id)"
/>
</template>
<template v-else>
<template v-if="row.amounts[c] !== undefined">
<span class="amount">{{ toMajor(row.amounts[c]) }}</span>
</template>
<Mono v-else dim></Mono>
</template>
</td>
<td>
<Badge :tone="row.active ? 'ok' : 'neutral'" dot>{{ row.active ? 'active' : 'inactive' }}</Badge>
</td>
<td class="cell-actions">
<div class="actions">
<template v-if="drafts[row._id]">
<UiButton size="sm" variant="primary" :disabled="saving === row._id" @click="saveEdit(row)">
{{ saving === row._id ? 'Saving' : 'Save' }}
</UiButton>
<UiButton size="sm" variant="ghost" :disabled="saving === row._id" @click="cancelEdit(row._id)">
Cancel
</UiButton>
</template>
<template v-else>
<UiButton size="sm" variant="secondary" @click="startEdit(row)">Edit</UiButton>
<UiButton size="sm" variant="ghost" :disabled="saving === row._id" @click="toggleActive(row)">
{{ row.active ? 'Deactivate' : 'Reactivate' }}
</UiButton>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</Card>
<Card>
<h2>Add a row</h2>
<p class="hint">
Used for missing Enterprise rows or to replace a deactivated row.
Fill in only the currencies you want to offer; blank cells mean
"not sold in this currency."
</p>
<div class="add-form">
<label class="field">
<Eyebrow>Plan</Eyebrow>
<select v-model="addForm.plan">
<option value="mvp">Starter</option>
<option value="pro">Business</option>
<option value="enterprise">Enterprise</option>
</select>
</label>
<label class="field">
<Eyebrow>Cycle</Eyebrow>
<select v-model="addForm.cycle">
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
</select>
</label>
<label v-for="c in CURRENCIES" :key="c" class="field">
<Eyebrow>{{ c }} / seat</Eyebrow>
<input
v-model="addForm.amounts[c]"
type="text"
inputmode="decimal"
:placeholder="`e.g. ${c === 'DKK' ? '49.00' : '7.00'}`"
/>
</label>
<UiButton variant="primary" :disabled="adding" @click="addRow">
{{ adding ? 'Adding' : 'Add row' }}
</UiButton>
</div>
</Card>
</div>
</template>
<style scoped>
.stage { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 18px; }
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-mute);
}
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
th { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); font-weight: 500; }
.cell-name { font-size: 13px; font-weight: 500; }
.empty .empty-inner { display: block; padding: 24px 0; text-align: center; color: var(--text-mute); }
tr.inactive { opacity: 0.55; }
.th-amount, .cell-amount { text-align: right; width: 130px; white-space: nowrap; }
.amount { font-family: var(--font-mono); font-size: 13px; font-variant-numeric: tabular-nums; }
.amount-input {
width: 96px;
padding: 6px 8px;
text-align: right;
background: var(--surface);
border: 1px solid var(--border-hi);
border-radius: 4px;
font-family: var(--font-mono);
font-size: 13px;
color: var(--text);
}
.th-actions, .cell-actions { text-align: right; width: 220px; }
.actions { display: inline-flex; gap: 6px; align-items: center; justify-content: flex-end; }
.add-form { display: grid; grid-template-columns: repeat(5, 1fr) auto; gap: 12px; align-items: end; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field select, .field input {
height: 34px;
padding: 0 12px;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: 0;
}
.hint { font-size: 13px; color: var(--text-dim); margin: 0 0 12px; }
</style>