feat(website): tier-driven progressive partner margin calculator

The /partners margin calculator now derives margin from the user count using
progressive brackets (like tax brackets): first 500 users at 15%, 501–1000 at
30%, 1001+ at 40%, off the 49 kr/user/mo list price. Replaces the manual margin
slider with a live per-bracket breakdown. Rates are read from the tier copy;
tier thresholds aligned to 501 / 1.001 so the cards and calculator agree.
This commit is contained in:
Ronni Baslund
2026-06-05 15:59:19 +02:00
parent 6d82502e7b
commit bf183fce07
2 changed files with 51 additions and 20 deletions
@@ -1,20 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
// Interactive reseller margin calculator. Uses the public 49 kr/user/mo list // Interactive reseller margin calculator. Margin is PROGRESSIVE (like tax
// price and the partner margin band; outputs monthly + annual margin. Numbers // brackets): the first 500 users earn 15%, users 5011000 earn 30%, and every
// are illustrative — final wholesale terms are set in the partner agreement. // user beyond 1000 earns 40% — all off the 49 kr/user/mo list price. The
// per-bracket rates come from the tier copy so they never drift.
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useTheme, useCopy } from '~/composables/useLanding' import { useTheme, useCopy, useLang } from '~/composables/useLanding'
const t = useTheme() const t = useTheme()
const copy = useCopy() const copy = useCopy()
const lang = useLang()
const c = computed(() => copy.value.pages.partners.calc) const c = computed(() => copy.value.pages.partners.calc)
const LIST_PRICE = 49 // DKK per user per month const LIST_PRICE = 49 // DKK per user per month
const seats = ref(100) const seats = ref(600)
const marginPct = ref(30)
const monthly = computed(() => Math.round(seats.value * LIST_PRICE * (marginPct.value / 100))) const nf = computed(() => new Intl.NumberFormat(lang.value === 'en' ? 'en-US' : 'da-DK'))
// Progressive brackets. `lo`/`hi` are the user-count bounds; pct is read from
// the matching tier so the calculator and tier cards stay in sync.
const brackets = computed(() => {
const items = copy.value.pages.partners.tiers.items
const f = nf.value
return [
{ lo: 0, hi: 500, pct: parseInt(String(items[0][2]), 10) || 0, label: `0${f.format(500)}` },
{ lo: 500, hi: 1000, pct: parseInt(String(items[1][2]), 10) || 0, label: `${f.format(501)}${f.format(1000)}` },
{ lo: 1000, hi: Infinity, pct: parseInt(String(items[2][2]), 10) || 0, label: `${f.format(1001)}+` },
]
})
function usersIn(b: { lo: number, hi: number }) {
return Math.max(0, Math.min(seats.value, b.hi) - b.lo)
}
const monthly = computed(() =>
Math.round(brackets.value.reduce((sum, b) => sum + usersIn(b) * LIST_PRICE * (b.pct / 100), 0)),
)
const annual = computed(() => monthly.value * 12) const annual = computed(() => monthly.value * 12)
const dkk = new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK', maximumFractionDigits: 0 }) const dkk = new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK', maximumFractionDigits: 0 })
@@ -25,20 +46,30 @@ const fmtAnnual = computed(() => dkk.format(annual.value))
<template> <template>
<div :style="{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0', border: `1px solid ${t.border}`, borderRadius: '4px', overflow: 'hidden' }"> <div :style="{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0', border: `1px solid ${t.border}`, borderRadius: '4px', overflow: 'hidden' }">
<!-- Controls --> <!-- Controls -->
<div :style="{ padding: '36px', background: t.surface, display: 'flex', flexDirection: 'column', gap: '36px', justifyContent: 'center' }"> <div :style="{ padding: '36px', background: t.surface, display: 'flex', flexDirection: 'column', gap: '28px', justifyContent: 'center' }">
<div> <div>
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '12px' }"> <div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '12px' }">
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgMuted, letterSpacing: '0.06em', textTransform: 'uppercase' }">{{ c.seatsLabel }}</span> <span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgMuted, letterSpacing: '0.06em', textTransform: 'uppercase' }">{{ c.seatsLabel }}</span>
<span :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '20px', color: t.fg }">{{ seats }}</span> <span :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '20px', color: t.fg }">{{ nf.format(seats) }}</span>
</div> </div>
<input v-model.number="seats" type="range" min="10" max="1000" step="10" :style="{ width: '100%', accentColor: t.signal, cursor: 'pointer' }" > <input v-model.number="seats" type="range" min="10" max="2000" step="10" :style="{ width: '100%', accentColor: t.signal, cursor: 'pointer' }" >
</div> </div>
<!-- Progressive bracket breakdown -->
<div> <div>
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '12px' }"> <div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgMuted, letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: '14px' }">{{ c.marginLabel }}</div>
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgMuted, letterSpacing: '0.06em', textTransform: 'uppercase' }">{{ c.marginLabel }}</span> <div :style="{ display: 'flex', flexDirection: 'column', gap: '10px' }">
<span :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '20px', color: t.fg }">{{ marginPct }} %</span> <div
v-for="(b, i) in brackets" :key="i"
:style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', opacity: usersIn(b) > 0 ? 1 : 0.4 }"
>
<span :style="{ display: 'flex', alignItems: 'center', gap: '9px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fg }">
<span :style="{ width: '5px', height: '5px', borderRadius: '999px', background: usersIn(b) > 0 ? t.signal : t.fgDim }" />
{{ b.label }}
</span>
<span :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '15px', color: t.fg }">{{ b.pct }} %</span>
</div>
</div> </div>
<input v-model.number="marginPct" type="range" min="15" max="40" step="1" :style="{ width: '100%', accentColor: t.signal, cursor: 'pointer' }" >
</div> </div>
</div> </div>
+6 -6
View File
@@ -227,7 +227,7 @@ export const COPY = {
marginLabel: 'Din margin', marginLabel: 'Din margin',
monthlyLabel: 'Din månedlige margin', monthlyLabel: 'Din månedlige margin',
annualLabel: 'Svarer til årligt', annualLabel: 'Svarer til årligt',
note: 'Beregnet ud fra listeprisen på 49 kr./bruger/md. Endelige wholesale-vilkår aftales ved onboarding.', note: 'Marginen beregnes progressivt pr. trin, ud fra listeprisen på 49 kr./bruger/md. Endelige wholesale-vilkår aftales ved onboarding.',
}, },
compare: { compare: {
label: 'Hvorfor skifte', label: 'Hvorfor skifte',
@@ -248,8 +248,8 @@ export const COPY = {
note: 'Margin- og kravsatser er vejledende og bekræftes i partneraftalen.', note: 'Margin- og kravsatser er vejledende og bekræftes i partneraftalen.',
items: [ items: [
['Registreret', 'Fra første kunde', '15 %', ['White-label-miljø', 'Multi-tenant konsol', 'E-mail-support']], ['Registreret', 'Fra første kunde', '15 %', ['White-label-miljø', 'Multi-tenant konsol', 'E-mail-support']],
['Certificeret', 'Fra 500 brugere', '30 %', ['Alt i Registreret', 'Prioriteret support', 'Co-marketing-materiale']], ['Certificeret', 'Fra 501 brugere', '30 %', ['Alt i Registreret', 'Prioriteret support', 'Co-marketing-materiale']],
['Premier', 'Fra 1.000 brugere', '40 %', ['Alt i Certificeret', 'Dedikeret partneransvarlig', 'Kundeleads & fælles kampagner']], ['Premier', 'Fra 1.001 brugere', '40 %', ['Alt i Certificeret', 'Dedikeret partneransvarlig', 'Kundeleads & fælles kampagner']],
], ],
}, },
faq: { faq: {
@@ -514,7 +514,7 @@ export const COPY = {
marginLabel: 'Your margin', marginLabel: 'Your margin',
monthlyLabel: 'Your monthly margin', monthlyLabel: 'Your monthly margin',
annualLabel: 'Equals annually', annualLabel: 'Equals annually',
note: 'Based on the 49 kr/user/mo list price. Final wholesale terms are agreed at onboarding.', note: 'Margin is calculated progressively per tier, based on the 49 kr/user/mo list price. Final wholesale terms are agreed at onboarding.',
}, },
compare: { compare: {
label: 'Why switch', label: 'Why switch',
@@ -535,8 +535,8 @@ export const COPY = {
note: 'Margin and requirement figures are indicative and confirmed in the partner agreement.', note: 'Margin and requirement figures are indicative and confirmed in the partner agreement.',
items: [ items: [
['Registered', 'From your first customer', '15%', ['White-label environment', 'Multi-tenant console', 'Email support']], ['Registered', 'From your first customer', '15%', ['White-label environment', 'Multi-tenant console', 'Email support']],
['Certified', 'From 500 users', '30%', ['Everything in Registered', 'Priority support', 'Co-marketing material']], ['Certified', 'From 501 users', '30%', ['Everything in Registered', 'Priority support', 'Co-marketing material']],
['Premier', 'From 1,000 users', '40%', ['Everything in Certified', 'Dedicated partner manager', 'Customer leads & joint campaigns']], ['Premier', 'From 1,001 users', '40%', ['Everything in Certified', 'Dedicated partner manager', 'Customer leads & joint campaigns']],
], ],
}, },
faq: { faq: {