Files
dezky/apps/website/components/landing/PartnerCalculator.vue
T
Ronni Baslund d668b1b6a6 feat(website): responsive / mobile layouts
Make the marketing site mobile-friendly across every page and section.
Desktop appearance is unchanged; all breakpoint logic targets <=768px.

- Fluid section padding via clamp(); equal grids use auto-fit/minmax,
  asymmetric grids stack to one column via scoped-CSS media queries
- Nav: real hamburger menu on mobile (links, lang toggle, login, CTA)
- ProductMockup: scales the whole dashboard to fit (zoom) instead of
  reflowing its internals into a tall stack
- Lower oversized heading clamp() minimums so titles no longer overflow
  at ~390px (hero, page headers, final CTA, brand cover/chapter)
- HowItWorks: row-gap when steps stack so node markers clear the text
- Compare + partners tables: stacked rows now label each value with its
  column (Dezky vs hyperscaler / CSP) instead of an ambiguous header
- Footer columns, tiers, calculator and tables stack cleanly on mobile
2026-06-06 15:55:35 +02:00

112 lines
5.4 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">
// Interactive reseller margin calculator. Margin is PROGRESSIVE (like tax
// brackets): the first 500 users earn 15%, users 5011000 earn 30%, and every
// 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 { useTheme, useCopy, useLang } from '~/composables/useLanding'
const t = useTheme()
const copy = useCopy()
const lang = useLang()
const c = computed(() => copy.value.pages.partners.calc)
const LIST_PRICE = 49 // DKK per user per month
const seats = ref(600)
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 dkk = new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK', maximumFractionDigits: 0 })
const fmtMonthly = computed(() => dkk.format(monthly.value))
const fmtAnnual = computed(() => dkk.format(annual.value))
// Expose theme border color to scoped CSS via v-bind() so media-query-driven
// border direction changes (left on desktop, top on mobile) can still use the
// reactive theme value without window reads.
const borderColor = computed(() => t.value.border)
</script>
<template>
<div class="calc-grid" :style="{ display: 'grid', gap: '0', border: `1px solid ${t.border}`, borderRadius: '4px', overflow: 'hidden' }">
<!-- Controls -->
<div :style="{ padding: '36px', background: t.surface, display: 'flex', flexDirection: 'column', gap: '28px', justifyContent: 'center' }">
<div>
<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: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '20px', color: t.fg }">{{ nf.format(seats) }}</span>
</div>
<input v-model.number="seats" type="range" min="10" max="2000" step="10" :style="{ width: '100%', accentColor: t.signal, cursor: 'pointer' }" >
</div>
<!-- Progressive bracket breakdown -->
<div>
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgMuted, letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: '14px' }">{{ c.marginLabel }}</div>
<div :style="{ display: 'flex', flexDirection: 'column', gap: '10px' }">
<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>
</div>
<!-- Output -->
<div class="calc-output" :style="{ padding: '36px', background: t.bgAlt, display: 'flex', flexDirection: 'column', justifyContent: 'center' }">
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgMuted, letterSpacing: '0.08em', textTransform: 'uppercase' }">{{ c.monthlyLabel }}</div>
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(40px, 5vw, 60px)', letterSpacing: '-0.03em', lineHeight: 1.0, color: t.fg, marginTop: '8px' }">{{ fmtMonthly }}</div>
<div :style="{ marginTop: '20px', paddingTop: '20px', borderTop: `1px solid ${t.border}`, fontFamily: '\'Inter\', sans-serif', fontSize: '15px', color: t.fgMuted }">
{{ c.annualLabel }} <span :style="{ color: t.fg, fontWeight: 600 }">{{ fmtAnnual }}</span>
</div>
</div>
</div>
</template>
<style scoped>
/* Desktop: two equal columns; output panel has a left divider. */
.calc-grid {
grid-template-columns: 1fr 1fr;
}
.calc-output {
border-left: 1px solid v-bind(borderColor);
}
/* Mobile: stack to single column; swap left divider to a top divider. */
@media (max-width: 768px) {
.calc-grid {
grid-template-columns: 1fr;
}
.calc-output {
border-left: none;
border-top: 1px solid v-bind(borderColor);
}
}
</style>