Compare commits

..

9 Commits

Author SHA1 Message Date
Ronni Baslund ed660b9a81 chore(website): gitignore stray npm package-lock.json (project uses pnpm) 2026-06-05 16:02:44 +02:00
Ronni Baslund 41af70d57b chore(website): pin TMPDIR in dev script and fix brand domain
Set TMPDIR=/tmp in the dev script so the Nuxt vite-node Unix socket path stays
under macOS's 104-char limit (fixes "Failed to restrict vite-node socket
permissions" on local runs; harmless in the Linux container). Also fix the
package.json description: dezky.com → dezky.eu.
2026-06-05 15:59:23 +02:00
Ronni Baslund bf183fce07 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.
2026-06-05 15:59:19 +02:00
Ronni Baslund 6d82502e7b chore(website): coming-soon badges, standards reframe, pricing, company info
- Suite: "coming soon" badge + dimmed glyph on Meet & Chat (data-driven `soon` flag)
- Stack (section 07): reframe from a vendor shopping-list to open standards +
  portability (no vendor names exposed; keeps the no-lock-in message)
- Pricing: 69 → 49 kr/user/mo
- Company info (footer + contact): Åtoften 33, 6710 Esbjerg V; CVR 43 14 18 21
2026-06-05 14:46:35 +02:00
Ronni Baslund 2e400d86c5 feat(website): partner program page + reseller conversion sections
Add the /partners page rendering the partner pitch: benefits, an interactive
margin calculator (seats × margin → monthly/annual, off the 49 kr list price),
a reseller-facing "CSP vs Dezky" comparison, partner tiers, a 3-step "get
started", and a partner FAQ. Wire the section-06 "see the partner program"
button to it, and align the whitelabel margin bullet to 15–40%.
2026-06-05 14:46:22 +02:00
Ronni Baslund 0a35d9deb6 feat(website): footer sub-pages + shared page layout
Wire every footer link to a real route. Adds a shared `page` layout (Nav +
content + Footer), reusable PageHeader/ComingSoon components, six content pages
(about, contact, brand, roadmap, changelog, migration), and a dynamic [slug]
catch-all for the not-yet-built pages — unknown slugs 404, legal slugs get a
distinct "contact us" body.

Footer links repointed from dead "#" to real paths; section anchors ("/#suite")
smooth-scroll on the homepage and route home + scroll from a sub-page; logo
links home. Page copy (da + en) added under COPY.pages.
2026-06-05 14:40:36 +02:00
Ronni Baslund 4c57d41350 feat(website): rewrite hero headline and switch brand domain to .eu
Hero headline was a broken comma-splice ("Den produktivitetssuite, dine data
bliver i Danmark med."); replace with a clean two-sentence line in both
languages:
  da: "Din digitale arbejdsplads. Data der bliver i EU."
  en: "Your digital workplace. Data that stays in the EU."

Also move all brand references off the unowned dezky.com to dezky.eu
(status page, app dashboard mockup, config/comments). External domains
(zulip.com, Google Fonts) are left untouched.
2026-06-05 12:29:50 +02:00
Ronni Baslund a0f79ab852 chore(scripts): configure git remote in bootstrap
Add a "Configure git remote" step that points origin at the Gitea host
(git@git.lastcloud.io) and pins the host to port 22222 in ~/.ssh/config so
git doesn't default to port 22 and get rejected by the agent offering too many
keys. Idempotent: reuses existing config on re-run. Also adds git to the
prerequisite checks and renumbers the steps to 1-7.
2026-06-05 12:18:07 +02:00
Ronni Baslund 4c3c47cc87 feat(website): localize whitelabel partner cards (da/en)
Partner demo cards in section 06 were hardcoded Danish strings, so they
stayed Danish in EN mode. Move name + subtitle into COPY.whitelabel.partners
for both languages and render them via a mapped computed; per-card accent and
the placeholder style remain presentational config in the component.

Also harden PartnerCard's avatar-initial against an empty name to satisfy
noUncheckedIndexedAccess.
2026-06-05 12:08:57 +02:00
27 changed files with 1136 additions and 97 deletions
+3
View File
@@ -0,0 +1,3 @@
# This app uses pnpm (pnpm-lock.yaml). Ignore stray npm lockfiles so an
# accidental `npm install` doesn't get committed.
package-lock.json
@@ -0,0 +1,19 @@
<script setup lang="ts">
// Placeholder body for not-yet-built sub-pages. Shows the page title under a
// "coming soon" eyebrow, an explanatory line, and a demo CTA. Legal pages pass
// the legal-specific body instead of the generic one.
import { useRoute } from 'vue-router'
import { useCopy, goToSection } from '~/composables/useLanding'
defineProps<{ title: string, body: string }>()
const copy = useCopy()
const route = useRoute()
</script>
<template>
<LandingPageHeader :label="copy.pages.comingSoonKicker" :title="title" :intro="body" />
<LandingContainer pad="48px 64px 160px">
<LandingBtn variant="primary" size="lg" @click="goToSection('#final-cta', route.path)">{{ copy.pages.ctaDemo }} </LandingBtn>
</LandingContainer>
</template>
+13 -9
View File
@@ -1,18 +1,22 @@
<script setup lang="ts">
// Footer — lockup + tagline + legal, four link columns, status row.
// Ported from landing-sections.jsx Footer (light mode). Anchor links smooth-
// scroll; "#" placeholders point at not-yet-built subpages.
// Ported from landing-sections.jsx Footer (light mode). Links are real routes
// now; "/#suite"-style section links smooth-scroll on the homepage and route
// home + scroll from a sub-page.
import { useRoute } from 'vue-router'
import { C } from '~/utils/landingTokens'
import { useTheme, useCopy, scrollToAnchor } from '~/composables/useLanding'
const t = useTheme()
const copy = useCopy()
const route = useRoute()
function onLink(e: MouseEvent, href: string) {
if (href.startsWith('#') && href.length > 1) {
e.preventDefault()
scrollToAnchor(href)
} else if (href === '#') {
// In-page section link ("/#suite"): smooth-scroll in place when already on
// the homepage. Off-page, let NuxtLink route to "/#suite" — index.vue scrolls
// to the hash on mount.
if (href.includes('#') && route.path === '/') {
e.preventDefault()
scrollToAnchor(href.slice(href.indexOf('#')))
}
}
</script>
@@ -33,11 +37,11 @@ function onLink(e: MouseEvent, href: string) {
<div v-for="(col, i) in copy.footer.cols" :key="i">
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: 'rgba(10,10,10,0.45)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: '18px' }">{{ col[0] }}</div>
<div :style="{ display: 'flex', flexDirection: 'column', gap: '12px' }">
<a
v-for="(link, j) in col[1]" :key="j" :href="link[1]"
<NuxtLink
v-for="(link, j) in col[1]" :key="j" :to="link[1]"
:style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '14px', color: 'rgba(10,10,10,0.78)' }"
@click="onLink($event, link[1])"
>{{ link[0] }}</a>
>{{ link[0] }}</NuxtLink>
</div>
</div>
</div>
+27 -14
View File
@@ -2,24 +2,37 @@
// Sticky top nav with logo, anchor links, language toggle, login + demo CTA.
// Ported from landing-sections.jsx Nav (light mode, production subset).
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { APP_URL } from '~/utils/landingTokens'
import { useTheme, useCopy, useLang, toggleLang, scrollToAnchor } from '~/composables/useLanding'
import { useTheme, useCopy, useLang, toggleLang, scrollToAnchor, goToSection } from '~/composables/useLanding'
const t = useTheme()
const copy = useCopy()
const lang = useLang()
const route = useRoute()
const items = computed(() => [
{ label: copy.value.nav.product, href: '#suite' },
{ label: copy.value.nav.security, href: '#sovereignty' },
{ label: copy.value.nav.whitelabel, href: '#whitelabel' },
{ label: copy.value.nav.pricing, href: '#pricing' },
{ label: copy.value.nav.docs, href: '#' },
{ label: copy.value.nav.product, href: '/#suite' },
{ label: copy.value.nav.security, href: '/#sovereignty' },
{ label: copy.value.nav.whitelabel, href: '/#whitelabel' },
{ label: copy.value.nav.pricing, href: '/#pricing' },
{ label: copy.value.nav.docs, href: '/docs' },
])
function onLogo() {
function onLogo(e: MouseEvent) {
// On the homepage, scroll to top in place; from a sub-page let NuxtLink route home.
if (route.path === '/') {
e.preventDefault()
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
function onNav(e: MouseEvent, href: string) {
if (href.includes('#') && route.path === '/') {
e.preventDefault()
scrollToAnchor(href.slice(href.indexOf('#')))
}
}
</script>
<template>
@@ -33,17 +46,17 @@ function onLogo() {
maxWidth: '1280px', margin: '0 auto', padding: '18px 64px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}">
<a href="#" :style="{ display: 'flex', alignItems: 'center', gap: '12px', cursor: 'pointer' }" @click.prevent="onLogo">
<NuxtLink to="/" :style="{ display: 'flex', alignItems: 'center', gap: '12px', cursor: 'pointer' }" @click="onLogo">
<BrandNodeMark :size="32" :fg="t.fg" :accent="t.signal" />
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontWeight: 600, fontSize: '16px', letterSpacing: '-0.02em', color: t.fg }">dezky</div>
</a>
</NuxtLink>
<nav :style="{ display: 'flex', alignItems: 'center', gap: '36px' }">
<a
v-for="(it, i) in items" :key="i" :href="it.href"
<NuxtLink
v-for="(it, i) in items" :key="i" :to="it.href"
:style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '14px', color: t.fgMuted, letterSpacing: '-0.005em' }"
@click.prevent="scrollToAnchor(it.href)"
>{{ it.label }}</a>
@click="onNav($event, it.href)"
>{{ it.label }}</NuxtLink>
</nav>
<div :style="{ display: 'flex', alignItems: 'center', gap: '14px' }">
@@ -57,7 +70,7 @@ function onLogo() {
@click="toggleLang()"
>{{ lang === 'da' ? 'da · en' : 'en · da' }}</button>
<a :href="APP_URL" :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '14px', color: t.fgMuted }">{{ copy.nav.login }}</a>
<LandingBtn variant="primary" @click="scrollToAnchor('#final-cta')">{{ copy.nav.cta }} </LandingBtn>
<LandingBtn variant="primary" @click="goToSection('#final-cta', route.path)">{{ copy.nav.cta }} </LandingBtn>
</div>
</div>
</header>
@@ -0,0 +1,36 @@
<script setup lang="ts">
// Shared header for sub-pages: back link, mono eyebrow with signal dot, big
// title, optional intro. Mirrors the hero/section typography so sub-pages feel
// like the same site.
import { useTheme, useCopy } from '~/composables/useLanding'
defineProps<{ label: string, title: string, intro?: string }>()
const t = useTheme()
const copy = useCopy()
</script>
<template>
<LandingContainer pad="120px 64px 0">
<NuxtLink
to="/"
:style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgMuted, letterSpacing: '0.04em', display: 'inline-flex', alignItems: 'center', gap: '8px' }"
> {{ copy.pages.back }}</NuxtLink>
<div :style="{ display: 'flex', alignItems: 'center', gap: '12px', marginTop: '48px', marginBottom: '24px' }">
<span :style="{ width: '6px', height: '6px', borderRadius: '999px', background: t.signal, boxShadow: `0 0 0 4px ${t.signal}33` }" />
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgMuted, letterSpacing: '0.08em', textTransform: 'uppercase' }">{{ label }}</span>
</div>
<h1 :style="{
fontFamily: '\'Inter Tight\', \'Inter\', sans-serif', fontWeight: 600,
fontSize: 'clamp(40px, 5.4vw, 76px)', letterSpacing: '-0.035em', lineHeight: 1.0,
margin: 0, textWrap: 'balance', color: t.fg, maxWidth: '900px',
}">{{ title }}</h1>
<p
v-if="intro"
:style="{ marginTop: '32px', maxWidth: '620px', fontFamily: '\'Inter\', sans-serif', fontSize: '20px', lineHeight: 1.5, color: t.fgMuted, textWrap: 'pretty' }"
>{{ intro }}</p>
</LandingContainer>
</template>
@@ -0,0 +1,85 @@
<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))
</script>
<template>
<div :style="{ display: 'grid', gridTemplateColumns: '1fr 1fr', 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 :style="{ padding: '36px', background: t.bgAlt, borderLeft: `1px solid ${t.border}`, 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>
@@ -12,7 +12,7 @@ const props = withDefaults(defineProps<{
placeholder?: boolean
}>(), { placeholder: false })
const initial = computed(() => props.name[0].toUpperCase())
const initial = computed(() => (props.name[0] ?? '').toUpperCase())
</script>
<template>
@@ -49,7 +49,7 @@ const recent: [string, string, string][] = [
<span :style="{ width: '10px', height: '10px', borderRadius: '999px', background: '#E23030' }" />
<span :style="{ width: '10px', height: '10px', borderRadius: '999px', background: '#E89A1F' }" />
<span :style="{ width: '10px', height: '10px', borderRadius: '999px', background: '#1F8A5B' }" />
<div :style="{ marginLeft: '16px', padding: '4px 12px', background: m.subtle, borderRadius: '4px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: m.muted }">app.dezky.com / dashboard</div>
<div :style="{ marginLeft: '16px', padding: '4px 12px', background: m.subtle, borderRadius: '4px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: m.muted }">app.dezky.eu / dashboard</div>
</div>
<div :style="{ display: 'grid', gridTemplateColumns: '220px 1fr', minHeight: '460px' }">
+4 -6
View File
@@ -18,18 +18,16 @@ const copy = useCopy()
<div
v-for="(row, i) in copy.stack.rows" :key="i"
:style="{
display: 'grid', gridTemplateColumns: '1fr 1.4fr 0.8fr 1fr 40px',
display: 'grid', gridTemplateColumns: '1.1fr 1.6fr 1.3fr',
gap: '24px', padding: '24px 0',
borderTop: i === 0 ? `1px solid ${t.borderStrong}` : 'none',
borderBottom: `1px solid ${t.border}`,
alignItems: 'baseline', fontFamily: '\'Inter\', sans-serif', fontSize: '15px',
}"
>
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgDim, letterSpacing: '0.08em', textTransform: 'uppercase' }">{{ row[0] }}</div>
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontSize: '20px', fontWeight: 600, color: t.fg, letterSpacing: '-0.015em' }">{{ row[1] }}</div>
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fg, padding: '4px 10px', background: t.bgAlt, borderRadius: '3px', display: 'inline-block', justifySelf: 'start' }">{{ row[2] }}</div>
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgMuted }">{{ row[3] }}</div>
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '14px', color: t.fgDim, textAlign: 'right' }"></div>
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontSize: '20px', fontWeight: 600, color: t.fg, letterSpacing: '-0.015em' }">{{ row[0] }}</div>
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12.5px', color: t.fg, letterSpacing: '0.02em' }">{{ row[1] }}</div>
<div :style="{ color: t.fgMuted }">{{ row[2] }}</div>
</div>
</div>
</LandingContainer>
+14 -1
View File
@@ -25,7 +25,20 @@ const copy = useCopy()
minHeight: '280px',
}"
>
<LandingModuleGlyph :name="card.name" />
<div :style="{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '12px' }">
<span :style="{ opacity: card.soon ? 0.5 : 1 }"><LandingModuleGlyph :name="card.name" /></span>
<span
v-if="card.soon"
:style="{
display: 'inline-flex', alignItems: 'center', gap: '6px', flexShrink: 0,
fontFamily: '\'JetBrains Mono\', monospace', fontSize: '9px', letterSpacing: '0.1em', textTransform: 'uppercase',
color: t.fgMuted, border: `1px solid ${t.border}`, borderRadius: '999px', padding: '4px 9px', whiteSpace: 'nowrap',
}"
>
<span :style="{ width: '5px', height: '5px', borderRadius: '999px', background: t.signal }" />
{{ copy.suite.soonLabel }}
</span>
</div>
<div>
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '22px', color: t.fg, letterSpacing: '-0.02em' }">{{ card.name }}</div>
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '10.5px', color: t.fgDim, marginTop: '6px', letterSpacing: '0.08em', textTransform: 'lowercase' }">{{ card.tag }}</div>
+28 -4
View File
@@ -10,6 +10,22 @@ const dark = useDark()
const sectionBg = computed(() => (dark.value ? '#1A1A17' : '#EFEDE3'))
const cardBg = computed(() => (dark.value ? '#0F0F0D' : '#FFFFFF'))
// Per-card presentation (accent / placeholder). Name + subtitle come from copy
// so the demo tenants translate with the rest of the page; the visual style is
// matched to each card by position.
const cardStyles = [
{ accent: '#D6502A', placeholder: false },
{ accent: '#3956C8', placeholder: false },
] as const
const partnerCards = computed(() =>
copy.value.whitelabel.partners.map((p, i) => ({
...p,
accent: cardStyles[i]?.accent ?? t.value.signal,
placeholder: cardStyles[i]?.placeholder ?? true,
})),
)
</script>
<template>
@@ -29,13 +45,21 @@ const cardBg = computed(() => (dark.value ? '#0F0F0D' : '#FFFFFF'))
</div>
</div>
<div :style="{ marginTop: '40px' }">
<LandingBtn variant="secondary" size="lg">{{ copy.whitelabel.cta }} </LandingBtn>
<LandingBtn variant="secondary" size="lg" @click="navigateTo('/partners')">{{ copy.whitelabel.cta }} </LandingBtn>
</div>
</div>
<div :style="{ display: 'flex', flexDirection: 'column', gap: '16px' }">
<LandingPartnerCard :fg="t.fg" :bg="cardBg" :border="t.border" accent="#D6502A" name="moltke it" subtitle="aalborg · 24 brugere" />
<LandingPartnerCard :fg="t.fg" :bg="cardBg" :border="t.border" accent="#3956C8" name="kraft & partners" subtitle="københavn · 112 brugere" />
<LandingPartnerCard :fg="t.fg" :bg="cardBg" :border="t.border" :accent="t.signal" name="dit firma her" subtitle="—" placeholder />
<LandingPartnerCard
v-for="(p, i) in partnerCards"
:key="i"
:fg="t.fg"
:bg="cardBg"
:border="t.border"
:accent="p.accent"
:name="p.name"
:subtitle="p.subtitle"
:placeholder="p.placeholder"
/>
</div>
</div>
</LandingContainer>
+15
View File
@@ -34,3 +34,18 @@ export function scrollToAnchor(hash: string) {
window.scrollTo({ top, behavior: 'smooth' })
history.replaceState(null, '', hash)
}
// Navigate to a homepage section from anywhere. Footer/Nav links use the form
// "/#suite": when already on the homepage we smooth-scroll in place; from a
// sub-page we route home and index.vue scrolls to the hash on mount. Accepts
// either "/#suite" or "#suite". Returns true if it handled the click (so the
// caller can preventDefault), false to let normal navigation proceed.
export function goToSection(href: string, currentPath: string): boolean {
const hash = href.slice(href.indexOf('#'))
if (currentPath === '/') {
scrollToAnchor(hash)
return true
}
navigateTo(`/${hash}`)
return true
}
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
// Sub-page shell: same Nav + Footer chrome as the landing page, with a content
// slot in between. Used by every footer-linked page so they share the header,
// footer, theme and language toggle.
import { useTheme, useLang } from '~/composables/useLanding'
const t = useTheme()
const lang = useLang()
useHead({ htmlAttrs: { lang } })
</script>
<template>
<div :style="{ background: t.bg, color: t.fg, minHeight: '100vh', display: 'flex', flexDirection: 'column' }">
<LandingNav />
<main :style="{ flex: 1 }">
<slot />
</main>
<LandingFooter />
</div>
</template>
+1 -1
View File
@@ -1,4 +1,4 @@
// Nuxt 4 configuration for the Dezky public marketing site (dezky.com).
// Nuxt 4 configuration for the Dezky public marketing site (dezky.eu).
//
// Unlike apps/portal and apps/operator this surface is fully public — no
// OIDC, no sessions, no platform-api coupling. It can be statically
+3 -3
View File
@@ -2,9 +2,9 @@
"name": "@dezky/website",
"version": "0.0.1",
"private": true,
"description": "Dezky public marketing site — dezky.com landing pages (Nuxt 4)",
"description": "Dezky public marketing site — dezky.eu landing pages (Nuxt 4)",
"scripts": {
"dev": "nuxt dev --host 0.0.0.0 --port 3000",
"dev": "TMPDIR=/tmp nuxt dev --host 0.0.0.0 --port 3000",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview",
@@ -12,7 +12,7 @@
"lint": "eslint ."
},
"dependencies": {
"nuxt": "^4.4.6",
"nuxt": "^4.4.7",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
// Catch-all for the footer's not-yet-built pages. Each known slug renders a
// shared "coming soon" body with its localized title; legal slugs get the
// legal-specific body. Unknown slugs 404 (explicit pages like /about win over
// this dynamic route in Nuxt's resolver).
import { computed } from 'vue'
import { useRoute, useCopy } from '#imports'
definePageMeta({ layout: 'page' })
// Known stub slugs and whether they're legal pages. Keys must match the
// `pages.stubs` keys in landingCopy.ts.
const STUBS: Record<string, { legal: boolean }> = {
customers: { legal: false },
careers: { legal: false },
press: { legal: false },
status: { legal: false },
docs: { legal: false },
blog: { legal: false },
privacy: { legal: true },
dpa: { legal: true },
terms: { legal: true },
sla: { legal: true },
cookies: { legal: true },
}
const route = useRoute()
const copy = useCopy()
const slug = computed(() => String(route.params.slug))
const stub = computed(() => STUBS[slug.value])
if (!stub.value) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
type StubKey = keyof typeof copy.value.pages.stubs
const title = computed(() => copy.value.pages.stubs[slug.value as StubKey])
const body = computed(() => (stub.value!.legal ? copy.value.pages.legalBody : copy.value.pages.comingSoonBody))
useHead({ title: () => `${title.value} · dezky` })
</script>
<template>
<LandingComingSoon :title="title" :body="body" />
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { useTheme, useCopy } from '~/composables/useLanding'
definePageMeta({ layout: 'page' })
const t = useTheme()
const copy = useCopy()
const c = computed(() => copy.value.pages.about)
useHead({ title: () => `${copy.value.pages.about.label} · dezky` })
</script>
<template>
<LandingPageHeader :label="c.label" :title="c.title" :intro="c.intro" />
<LandingContainer pad="64px 64px 160px">
<div :style="{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: '80px', alignItems: 'start' }">
<div :style="{ display: 'flex', flexDirection: 'column', gap: '24px' }">
<p
v-for="(para, i) in c.body" :key="i"
:style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '18px', lineHeight: 1.6, color: t.fg, margin: 0, textWrap: 'pretty' }"
>{{ para }}</p>
</div>
<div :style="{ display: 'flex', flexDirection: 'column', gap: '0', border: `1px solid ${t.border}`, borderRadius: '4px', overflow: 'hidden' }">
<div
v-for="(p, i) in c.principles" :key="i"
:style="{ padding: '24px 28px', borderTop: i === 0 ? 'none' : `1px solid ${t.border}`, background: t.surface }"
>
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '18px', color: t.fg, letterSpacing: '-0.015em' }">{{ p[0] }}</div>
<div :style="{ marginTop: '8px', fontFamily: '\'Inter\', sans-serif', fontSize: '14px', lineHeight: 1.55, color: t.fgMuted }">{{ p[1] }}</div>
</div>
</div>
</div>
</LandingContainer>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { useTheme, useCopy } from '~/composables/useLanding'
definePageMeta({ layout: 'page' })
const t = useTheme()
const copy = useCopy()
const c = computed(() => copy.value.pages.brand)
useHead({ title: () => `${copy.value.pages.brand.label} · dezky` })
</script>
<template>
<LandingPageHeader :label="c.label" :title="c.title" :intro="c.intro" />
<LandingContainer pad="56px 64px 160px">
<div :style="{ display: 'flex', flexDirection: 'column', gap: '0', border: `1px solid ${t.border}`, borderRadius: '4px', overflow: 'hidden', maxWidth: '820px' }">
<div
v-for="(rule, i) in c.rules" :key="i"
:style="{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: '24px', padding: '24px 28px', borderTop: i === 0 ? 'none' : `1px solid ${t.border}`, background: t.surface, alignItems: 'baseline' }"
>
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgDim, letterSpacing: '0.08em', textTransform: 'uppercase' }">{{ rule[0] }}</div>
<div :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.6, color: t.fg }">{{ rule[1] }}</div>
</div>
</div>
<div :style="{ marginTop: '48px' }">
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgDim, letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: '20px' }">{{ c.colorsLabel }}</div>
<div :style="{ display: 'flex', gap: '20px', flexWrap: 'wrap' }">
<div v-for="(col, i) in c.colors" :key="i" :style="{ width: '180px' }">
<div :style="{ height: '120px', borderRadius: '4px', background: col[1], border: `1px solid ${t.border}` }" />
<div :style="{ marginTop: '12px', fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '15px', color: t.fg }">{{ col[0] }}</div>
<div :style="{ marginTop: '2px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgMuted }">{{ col[1] }}</div>
</div>
</div>
</div>
</LandingContainer>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { useTheme, useCopy } from '~/composables/useLanding'
definePageMeta({ layout: 'page' })
const t = useTheme()
const copy = useCopy()
const c = computed(() => copy.value.pages.changelog)
useHead({ title: () => `${copy.value.pages.changelog.label} · dezky` })
</script>
<template>
<LandingPageHeader :label="c.label" :title="c.title" :intro="c.intro" />
<LandingContainer pad="56px 64px 160px">
<div :style="{ maxWidth: '760px' }">
<div
v-for="(entry, i) in c.entries" :key="i"
:style="{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: '32px', padding: '32px 0', borderTop: `1px solid ${t.border}`, alignItems: 'start' }"
>
<div>
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '20px', color: t.fg, letterSpacing: '-0.015em' }">{{ entry[0] }}</div>
<div :style="{ marginTop: '4px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgDim }">{{ entry[1] }}</div>
</div>
<ul :style="{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '12px' }">
<li
v-for="(item, j) in entry[2]" :key="j"
:style="{ display: 'flex', gap: '12px', fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.5, color: t.fg }"
>
<span :style="{ color: t.fgDim, flexShrink: 0 }"></span>
<span>{{ item }}</span>
</li>
</ul>
</div>
</div>
</LandingContainer>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useTheme, useCopy, goToSection } from '~/composables/useLanding'
definePageMeta({ layout: 'page' })
const t = useTheme()
const copy = useCopy()
const route = useRoute()
const c = computed(() => copy.value.pages.contact)
useHead({ title: () => `${copy.value.pages.contact.label} · dezky` })
</script>
<template>
<LandingPageHeader :label="c.label" :title="c.title" :intro="c.intro" />
<LandingContainer pad="56px 64px 160px">
<div :style="{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '24px', maxWidth: '760px' }">
<a
:href="`mailto:${c.email}`"
:style="{ display: 'block', padding: '28px', border: `1px solid ${t.border}`, borderRadius: '4px', background: t.surface }"
>
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgDim, letterSpacing: '0.08em', textTransform: 'uppercase' }">{{ c.emailLabel }}</div>
<div :style="{ marginTop: '10px', fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '20px', color: t.fg }">{{ c.email }}</div>
</a>
<div :style="{ padding: '28px', border: `1px solid ${t.border}`, borderRadius: '4px', background: t.surface }">
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgDim, letterSpacing: '0.08em', textTransform: 'uppercase' }">{{ c.addressLabel }}</div>
<div :style="{ marginTop: '10px', fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.6, color: t.fg }">
<div>{{ copy.footer.legal.name }}</div>
<div>{{ copy.footer.legal.addr }}</div>
<div :style="{ marginTop: '6px', color: t.fgMuted }">{{ c.cvrLabel }}: {{ copy.footer.legal.cvr.replace('CVR ', '') }}</div>
</div>
</div>
</div>
<div :style="{ marginTop: '48px' }">
<LandingBtn variant="primary" size="lg" @click="goToSection('#final-cta', route.path)">{{ copy.pages.ctaDemo }} </LandingBtn>
</div>
</LandingContainer>
</template>
+10 -1
View File
@@ -3,14 +3,23 @@
// (Landing Page.html → landing-app.jsx + landing-sections.jsx). Light theme,
// Danish default, hero variant A — the production defaults the user landed on.
// Section order matches the design exactly.
import { useTheme, useCopy, useLang } from '~/composables/useLanding'
import { onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { useTheme, useCopy, useLang, scrollToAnchor } from '~/composables/useLanding'
const t = useTheme()
const copy = useCopy()
const lang = useLang()
const route = useRoute()
const description = computed(() => copy.value.hero.sub)
// Arriving via a section link from a sub-page (e.g. "/#suite") lands here with
// a hash — scroll to it once the page has painted.
onMounted(() => {
if (route.hash) nextTick(() => setTimeout(() => scrollToAnchor(route.hash), 60))
})
useHead({
title: 'dezky · suveræn produktivitet',
htmlAttrs: { lang },
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useTheme, useCopy, goToSection } from '~/composables/useLanding'
definePageMeta({ layout: 'page' })
const t = useTheme()
const copy = useCopy()
const route = useRoute()
const c = computed(() => copy.value.pages.migration)
useHead({ title: () => `${copy.value.pages.migration.label} · dezky` })
</script>
<template>
<LandingPageHeader :label="c.label" :title="c.title" :intro="c.intro" />
<LandingContainer pad="56px 64px 80px">
<div :style="{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '40px' }">
<div v-for="(step, i) in c.steps" :key="i">
<div :style="{ paddingTop: '20px', borderTop: `1px solid ${t.borderStrong}` }">
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgDim, letterSpacing: '0.06em' }">step {{ step[0] }}</div>
<div :style="{ marginTop: '20px', fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '24px', color: t.fg, letterSpacing: '-0.02em' }">{{ step[1] }}</div>
<p :style="{ marginTop: '12px', fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.6, color: t.fgMuted, textWrap: 'pretty' }">{{ step[2] }}</p>
</div>
</div>
</div>
<p :style="{ marginTop: '64px', fontFamily: '\'Inter\', sans-serif', fontSize: '16px', color: t.fg }">{{ c.note }}</p>
<div :style="{ marginTop: '32px' }">
<LandingBtn variant="primary" size="lg" @click="goToSection('#final-cta', route.path)">{{ copy.pages.ctaDemo }} </LandingBtn>
</div>
</LandingContainer>
</template>
+147
View File
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { useTheme, useCopy, goToSection } from '~/composables/useLanding'
definePageMeta({ layout: 'page' })
const t = useTheme()
const copy = useCopy()
const route = useRoute()
const c = computed(() => copy.value.pages.partners)
const openFaq = ref<number | null>(0)
function toggleFaq(i: number) {
openFaq.value = openFaq.value === i ? null : i
}
useHead({ title: () => `${copy.value.pages.partners.label} · dezky` })
</script>
<template>
<LandingPageHeader :label="c.label" :title="c.title" :intro="c.intro" />
<!-- What you get -->
<LandingContainer pad="56px 64px 0">
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgDim, letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: '20px' }">{{ c.benefitsLabel }}</div>
<div :style="{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '0', border: `1px solid ${t.border}`, borderRadius: '4px', overflow: 'hidden' }">
<div
v-for="(b, i) in c.benefits" :key="i"
:style="{
padding: '28px', background: t.surface,
borderTop: i > 1 ? `1px solid ${t.border}` : 'none',
borderLeft: i % 2 === 1 ? `1px solid ${t.border}` : 'none',
}"
>
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '20px', color: t.fg, letterSpacing: '-0.015em' }">{{ b[0] }}</div>
<p :style="{ marginTop: '10px', fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.6, color: t.fgMuted, margin: '10px 0 0', textWrap: 'pretty' }">{{ b[1] }}</p>
</div>
</div>
</LandingContainer>
<!-- Margin calculator -->
<LandingContainer pad="72px 64px 0">
<div :style="{ display: 'grid', gridTemplateColumns: '1.1fr 1fr', gap: '40px', alignItems: 'end', marginBottom: '32px' }">
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(30px, 3.6vw, 48px)', letterSpacing: '-0.03em', lineHeight: 1.0, margin: 0, color: t.fg }">{{ c.calc.heading }}</h2>
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgDim, letterSpacing: '0.08em', textTransform: 'uppercase' }">{{ c.calc.label }}</div>
</div>
<LandingPartnerCalculator />
<p :style="{ marginTop: '16px', fontFamily: '\'Inter\', sans-serif', fontSize: '13px', color: t.fgDim }">{{ c.calc.note }}</p>
</LandingContainer>
<!-- CSP vs Dezky comparison -->
<LandingContainer pad="72px 64px 0">
<div :style="{ display: 'grid', gridTemplateColumns: '1.1fr 1fr', gap: '40px', alignItems: 'end', marginBottom: '32px' }">
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(30px, 3.6vw, 48px)', letterSpacing: '-0.03em', lineHeight: 1.0, margin: 0, color: t.fg }">{{ c.compare.heading }}</h2>
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgDim, letterSpacing: '0.08em', textTransform: 'uppercase' }">{{ c.compare.label }}</div>
</div>
<div :style="{ border: `1px solid ${t.border}`, borderRadius: '4px', overflow: 'hidden' }">
<div :style="{ display: 'grid', gridTemplateColumns: '1.3fr 1fr 1fr', background: t.surface, borderBottom: `1px solid ${t.borderStrong}` }">
<div :style="{ padding: '18px 24px' }" />
<div :style="{ padding: '18px 24px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '13px', color: t.fgMuted }">{{ c.compare.cols[0] }}</div>
<div :style="{ padding: '18px 24px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '13px', fontWeight: 600, color: t.fg, background: `${t.signal}22` }">{{ c.compare.cols[1] }}</div>
</div>
<div
v-for="(row, i) in c.compare.rows" :key="i"
:style="{ display: 'grid', gridTemplateColumns: '1.3fr 1fr 1fr', borderTop: i === 0 ? 'none' : `1px solid ${t.border}`, fontFamily: '\'Inter\', sans-serif', fontSize: '15px' }"
>
<div :style="{ padding: '18px 24px', color: t.fgMuted, fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', letterSpacing: '0.04em', textTransform: 'uppercase', alignSelf: 'center' }">{{ row[0] }}</div>
<div :style="{ padding: '18px 24px', color: t.fgMuted }">{{ row[1] }}</div>
<div :style="{ padding: '18px 24px', color: t.fg, fontWeight: 600, background: `${t.signal}11` }">{{ row[2] }}</div>
</div>
</div>
</LandingContainer>
<!-- Partner tiers -->
<LandingContainer pad="72px 64px 0">
<div :style="{ display: 'grid', gridTemplateColumns: '1.1fr 1fr', gap: '40px', alignItems: 'end', marginBottom: '32px' }">
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(30px, 3.6vw, 48px)', letterSpacing: '-0.03em', lineHeight: 1.0, margin: 0, color: t.fg }">{{ c.tiers.heading }}</h2>
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgDim, letterSpacing: '0.08em', textTransform: 'uppercase' }">{{ c.tiers.label }}</div>
</div>
<div :style="{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0', border: `1px solid ${t.border}`, borderRadius: '4px', overflow: 'hidden' }">
<div
v-for="(tier, i) in c.tiers.items" :key="i"
:style="{ padding: '32px 28px', background: t.surface, borderLeft: i === 0 ? 'none' : `1px solid ${t.border}`, display: 'flex', flexDirection: 'column', gap: '20px' }"
>
<div>
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '20px', color: t.fg, letterSpacing: '-0.015em' }">{{ tier[0] }}</div>
<div :style="{ marginTop: '4px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgDim, letterSpacing: '0.04em' }">{{ tier[1] }}</div>
</div>
<div :style="{ display: 'flex', alignItems: 'baseline', gap: '8px' }">
<span :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '40px', letterSpacing: '-0.03em', color: t.fg }">{{ tier[2] }}</span>
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgMuted }">margin</span>
</div>
<div :style="{ display: 'flex', flexDirection: 'column', gap: '10px' }">
<div
v-for="(perk, j) in tier[3]" :key="j"
:style="{ display: 'flex', gap: '10px', fontFamily: '\'Inter\', sans-serif', fontSize: '14px', lineHeight: 1.45, color: t.fg }"
>
<span :style="{ color: t.signal, flexShrink: 0, fontWeight: 700 }"></span>
<span>{{ perk }}</span>
</div>
</div>
</div>
</div>
<p :style="{ marginTop: '16px', fontFamily: '\'Inter\', sans-serif', fontSize: '13px', color: t.fgDim }">{{ c.tiers.note }}</p>
</LandingContainer>
<!-- How to get started -->
<LandingContainer pad="72px 64px 0">
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgDim, letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: '32px' }">{{ c.stepsLabel }}</div>
<div :style="{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '40px' }">
<div v-for="(step, i) in c.steps" :key="i" :style="{ paddingTop: '20px', borderTop: `1px solid ${t.borderStrong}` }">
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgDim, letterSpacing: '0.06em' }">step {{ step[0] }}</div>
<div :style="{ marginTop: '20px', fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '24px', color: t.fg, letterSpacing: '-0.02em' }">{{ step[1] }}</div>
<p :style="{ marginTop: '12px', fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.6, color: t.fgMuted, textWrap: 'pretty' }">{{ step[2] }}</p>
</div>
</div>
</LandingContainer>
<!-- Partner FAQ -->
<LandingContainer pad="72px 64px 0">
<div :style="{ display: 'grid', gridTemplateColumns: '1.1fr 1fr', gap: '40px', alignItems: 'end', marginBottom: '32px' }">
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(30px, 3.6vw, 48px)', letterSpacing: '-0.03em', lineHeight: 1.0, margin: 0, color: t.fg }">{{ c.faq.heading }}</h2>
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgDim, letterSpacing: '0.08em', textTransform: 'uppercase' }">{{ c.faq.label }}</div>
</div>
<div>
<div
v-for="(item, i) in c.faq.items" :key="i"
:style="{ borderTop: `1px solid ${t.border}`, borderBottom: i === c.faq.items.length - 1 ? `1px solid ${t.border}` : 'none' }"
>
<button
:style="{ width: '100%', background: 'transparent', border: 'none', padding: '24px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '24px', cursor: 'pointer', textAlign: 'left' }"
@click="toggleFaq(i)"
>
<span :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '18px', color: t.fg, letterSpacing: '-0.015em' }">{{ item[0] }}</span>
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '20px', color: t.fgMuted, flexShrink: 0 }">{{ openFaq === i ? '' : '+' }}</span>
</button>
<p v-if="openFaq === i" :style="{ margin: '0', padding: '0 0 24px', maxWidth: '720px', fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.6, color: t.fgMuted, textWrap: 'pretty' }">{{ item[1] }}</p>
</div>
</div>
</LandingContainer>
<!-- CTA -->
<LandingContainer pad="72px 64px 160px">
<LandingBtn variant="primary" size="lg" @click="goToSection('#final-cta', route.path)">{{ c.cta }} </LandingBtn>
</LandingContainer>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { useTheme, useCopy } from '~/composables/useLanding'
definePageMeta({ layout: 'page' })
const t = useTheme()
const copy = useCopy()
const c = computed(() => copy.value.pages.roadmap)
useHead({ title: () => `${copy.value.pages.roadmap.label} · dezky` })
</script>
<template>
<LandingPageHeader :label="c.label" :title="c.title" :intro="c.intro" />
<LandingContainer pad="56px 64px 160px">
<div :style="{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0', border: `1px solid ${t.border}`, borderRadius: '4px', overflow: 'hidden' }">
<div
v-for="(col, i) in c.columns" :key="i"
:style="{ padding: '32px 28px', borderLeft: i === 0 ? 'none' : `1px solid ${t.border}`, background: t.surface, minHeight: '260px' }"
>
<div :style="{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '24px' }">
<span :style="{ width: '6px', height: '6px', borderRadius: '999px', background: i === 0 ? t.signal : t.fgDim }" />
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgMuted, letterSpacing: '0.08em', textTransform: 'uppercase' }">{{ col[0] }}</span>
</div>
<div :style="{ display: 'flex', flexDirection: 'column', gap: '14px' }">
<div
v-for="(item, j) in col[1]" :key="j"
:style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.5, color: t.fg }"
>{{ item }}</div>
</div>
</div>
</div>
</LandingContainer>
</template>
+360 -44
View File
@@ -9,7 +9,7 @@ export const COPY = {
nav: { product: 'produkt', security: 'sikkerhed', whitelabel: 'whitelabel', pricing: 'priser', docs: 'docs', login: 'log ind', cta: 'book en demo' },
hero: {
eyebrow: '// suveræn produktivitet · v1.0',
headlineA: ['Den produktivitetssuite,', { hl: 'dine data bliver i Danmark med.' }] as HeadlinePart[],
headlineA: ['Din digitale arbejdsplads.', { hl: 'Data der bliver i EU.' }] as HeadlinePart[],
headlineB: ['Værktøjerne du kender.', { hl: 'Suveræniteten du har brug for.' }] as HeadlinePart[],
sub: 'Mail, filer, video, chat og login — fuldt integreret, hostet i EU, uden lock-in. Bygget på licensren open source.',
cta: 'Book en demo',
@@ -26,12 +26,13 @@ export const COPY = {
label: '02 — suiten',
heading: 'Alt det du forventer. Intet du ikke vil have.',
lede: 'Fem moduler. Ét login. Bygget til at virke sammen — ikke bare leve i samme browser.',
soonLabel: 'kommer snart',
cards: [
{ name: 'Mail', tag: 'mail · kalender · kontakter', desc: 'Domæne-mail, kalender og kontakter med fuld kompatibilitet til Outlook og Apple Mail via IMAP, CalDAV og CardDAV.' },
{ name: 'Drev', tag: 'filer · deling · versioner', desc: 'Filer i skyen med deling, versionering og indbygget redigering i Office-formater. Synk-klient til Mac, Windows og Linux.' },
{ name: 'Møder', tag: 'video · skærmdeling', desc: 'Videomøder i browseren. Ingen download. Skærmdeling, optagelse og baggrundsudviskning out-of-the-box.' },
{ name: 'Chat', tag: 'kanaler · tråde · søgning', desc: 'Team-chat med tråde, kanaler og fuld historiksøgning. Designet til at læses asynkront, ikke til at afbryde.' },
{ name: 'Login & adgang', tag: 'sso · mfa · livscyklus', desc: 'Single sign-on, multifaktor og brugerstyring i ét panel. Tilføj én bruger — de får mail, drev, møder og chat med det samme.' },
{ name: 'Mail', tag: 'mail · kalender · kontakter', desc: 'Domæne-mail, kalender og kontakter med fuld kompatibilitet til Outlook og Apple Mail via IMAP, CalDAV og CardDAV.', soon: false },
{ name: 'Drev', tag: 'filer · deling · versioner', desc: 'Filer i skyen med deling, versionering og indbygget redigering i Office-formater. Synk-klient til Mac, Windows og Linux.', soon: false },
{ name: 'Møder', tag: 'video · skærmdeling', desc: 'Videomøder i browseren. Ingen download. Skærmdeling, optagelse og baggrundsudviskning out-of-the-box.', soon: true },
{ name: 'Chat', tag: 'kanaler · tråde · søgning', desc: 'Team-chat med tråde, kanaler og fuld historiksøgning. Designet til at læses asynkront, ikke til at afbryde.', soon: true },
{ name: 'Login & adgang', tag: 'sso · mfa · livscyklus', desc: 'Single sign-on, multifaktor og brugerstyring i ét panel. Tilføj én bruger — de får mail, drev, møder og chat med det samme.', soon: false },
],
},
how: {
@@ -77,21 +78,26 @@ export const COPY = {
bullets: [
'Fuldt whitelabel-tema · CSS og logo',
'Multi-tenant administration',
'Marginer på 3045 % afhængigt af volumen',
'Marginer på 1540 % afhængigt af volumen',
'Co-marketing og kundeleads via partnernetværk',
],
cta: 'Se partnerprogrammet',
partners: [
{ name: 'moltke it', subtitle: 'aalborg · 24 brugere' },
{ name: 'kraft & partners', subtitle: 'københavn · 112 brugere' },
{ name: 'dit firma her', subtitle: '—' },
],
},
stack: {
label: '07 — under motorhjelmen',
heading: 'Bygget på open source. Verificerbart.',
lede: 'Vi skjuler det ikke. Hver komponent er licensren open source — du kan inspicere koden, kompilere den selv, eller flytte din installation et andet sted hen.',
label: '07 — åbne standarder',
heading: 'Bygget på åbne standarder. Ingen lock-in.',
lede: 'Dine data taler velkendte, åbne protokoller — ikke proprietære formater. Eksportér alt når som helst og flyt til en anden udbyder.',
rows: [
['Mail', 'Stalwart Mail', 'AGPL-3.0', 'stalw.art'],
['Filer & drev', 'ownCloud Infinite Scale', 'Apache 2.0', 'owncloud.dev'],
['Videomøder', 'Jitsi', 'Apache 2.0', 'jitsi.org'],
['Team chat', 'Zulip', 'Apache 2.0', 'zulip.com'],
['Identitet & SSO', 'Authentik', 'MIT', 'goauthentik.io'],
['Mail & kalender', 'IMAP · SMTP · CalDAV · CardDAV', 'Eksportér til .mbox og .ics'],
['Filer & drev', 'WebDAV · S3', 'Hent alle filer — intet format-lock'],
['Videomøder', 'WebRTC · SIP', 'Åben browser-standard, ingen klient'],
['Team chat', 'Åben eksport-API', 'Tag hele historikken med'],
['Identitet & SSO', 'OIDC · SAML · SCIM', 'Kobl til din egen IdP'],
],
},
pricing: {
@@ -99,7 +105,7 @@ export const COPY = {
heading: 'Forudsigelig pris. Ingen overraskelser.',
lede: 'Vi er i et lukket beta-program indtil sommeren 2026. Prisen sættes sammen med vores første kunder — ikke imod dem.',
teaser: 'Starter fra',
price: '69',
price: '49',
unit: 'DKK / bruger / md.',
note: 'Endelig prissætning bekræftes ved demo. Volumenrabat fra 25 brugere.',
cta: 'Book en demo for priser',
@@ -112,7 +118,7 @@ export const COPY = {
['Kan jeg stadig bruge Outlook og Office?', 'Ja. Mail, kalender og kontakter virker via IMAP, CalDAV og CardDAV. Drev-filer åbnes med Office desktop via WebDAV. Vi anbefaler vores web- og mobil-apps som primært valg, men kravet er ikke at I skifter vaner.'],
['Hvor er data hosted?', 'Hos Hetzner i Tyskland. Tier III-certificerede datacentre, redundant strøm og netværk, ISO 27001-certificeret operatør. Ingen data forlader EU på noget tidspunkt — ikke for analytics, logs eller support.'],
['Hvad sker der hvis Dezky lukker?', 'Hele stakken er open source. I kan eksportere alt og flytte til en anden Dezky-partner. Vores forretningsmodel er drift, ikke gidseltagning.'],
['Hvad er jeres SLA?', '99,9 % uptime garanteret på alle planer. 99,95 % på Enterprise. Status-side med real-time data offentligt tilgængelig på status.dezky.com.'],
['Hvad er jeres SLA?', '99,9 % uptime garanteret på alle planer. 99,95 % på Enterprise. Status-side med real-time data offentligt tilgængelig på status.dezky.eu.'],
['Hvordan leveres support?', 'Dansk og engelsk. E-mail og chat på alle planer. Telefon-support på Business og Enterprise. Dedikeret onboarding-konsulent ved 50+ brugere.'],
],
},
@@ -121,14 +127,166 @@ export const COPY = {
sub: '30 minutters demo. Ingen salgspres. Ingen slides.',
cta: 'Book en demo',
},
pages: {
back: 'Tilbage til forsiden',
comingSoonKicker: 'Kommer snart',
comingSoonBody: 'Vi bygger denne side lige nu. Vil du vide mere allerede i dag, så book en demo — vi fortæller gerne mere.',
legalBody: 'Dette dokument er ved at blive færdiggjort sammen med vores rådgivere. Kontakt os på kontakt@dezky.eu for den gældende version.',
ctaDemo: 'Book en demo',
about: {
label: 'om os',
title: 'Bygget i Danmark. For europæisk suverænitet.',
intro: 'Dezky samler mail, filer, video, chat og login i én suite — hostet i EU og bygget på åbne standarder, så dine data aldrig forlader europæisk jurisdiktion.',
body: [
'Vi startede Dezky, fordi europæiske virksomheder fortjener produktivitetsværktøjer, der ikke er afhængige af amerikansk infrastruktur og skiftende licensvilkår. Schrems II og CLOUD Act gjorde det tydeligt: hvor data ligger, og hvem der kan tvinges til at udlevere dem, er ikke en teknisk detalje — det er strategi.',
'Vi driver platformen på europæisk infrastruktur, vi har ingen amerikansk moder, og vi bygger på licensren open source, så du altid kan eksportere dine data og flytte videre. Ingen lock-in, ingen overraskelser.',
],
principles: [
['Suverænitet', 'Dine data falder under europæisk lov — punktum.'],
['Åbenhed', 'Bygget på åbne standarder og open source. Ingen proprietære fælder.'],
['Forudsigelighed', 'Fast pris i kontraktperioden. Ingen ensidige ændringer.'],
],
},
contact: {
label: 'kontakt',
title: 'Lad os tale sammen.',
intro: 'Spørgsmål om migration, priser eller whitelabel? Skriv til os — vi svarer på dansk og engelsk.',
emailLabel: 'E-mail',
email: 'kontakt@dezky.eu',
addressLabel: 'Adresse',
cvrLabel: 'CVR',
},
brand: {
label: 'brand',
title: 'Brug vores brand korrekt.',
intro: 'Retningslinjer for navn, logo og farver. Skal du bruge logofiler, så kontakt os.',
rules: [
['Navn', 'Altid “Dezky” — ét ord, stort begyndelsesbogstav. Aldrig i versaler i løbende tekst.'],
['Logo', 'Brug node-mærket med tilstrækkelig luft omkring. Forvræng, rotér eller omfarv det ikke.'],
['Tone', 'Direkte, teknisk, uden hype. Vi sælger ikke med frygt — vi forklarer.'],
],
colorsLabel: 'Farver',
colors: [
['Signal', '#D4FF3A'],
['Carbon', '#0A0A0A'],
['Bone', '#F4F3EE'],
],
},
roadmap: {
label: 'roadmap',
title: 'Hvor vi er på vej hen.',
intro: 'Vi udvikler i det åbne. Her er, hvad der er live, hvad der er på vej, og hvad vi planlægger.',
columns: [
['Live nu', ['Mail, kalender & kontakter', 'Filer & drev', 'Single sign-on & brugerstyring']],
['Næste', ['Videomøder i browseren', 'Team chat med tråde', 'Mobil-apps til iOS & Android']],
['Senere', ['Kundekontrollerede nøgler (BYOK)', 'Avanceret compliance-rapportering', 'Flere EU-regioner']],
],
},
changelog: {
label: 'changelog',
title: 'Hvad der er nyt.',
intro: 'Større ændringer og forbedringer. Mindre rettelser ruller løbende.',
entries: [
['v1.0.4', '2026', ['Ny prismodel og opdateret prisside', 'Forbedret onboarding-flow', 'Hurtigere indlæsning af drev']],
['v1.0.0', '2026', ['Første offentlige beta', 'Mail, drev og SSO live', 'Whitelabel for partnere']],
],
},
migration: {
label: 'migrationsguide',
title: 'Skift uden nedetid.',
intro: 'Vi flytter mail, kalender, kontakter og filer i baggrunden, mens dit team arbejder videre. Selve skiftet er en DNS-opdatering.',
steps: [
['01', 'Kortlægning', 'Vi gennemgår dine domæner, postkasser og data og lægger en plan. Typisk forløb er 24 uger for 50 brugere.'],
['02', 'Parallel kopiering', 'Vi kopierer mail, kalender, kontakter og OneDrive/Drev-filer til Dezky i baggrunden — uden at afbryde noget.'],
['03', 'Skiftedagen', 'Vi opdaterer DNS, og dine brugere logger ind i Dezky. Velkendte web- og mobil-apps fra dag ét.'],
],
note: 'Migration fra Microsoft 365 og Google Workspace er inkluderet i alle planer.',
},
partners: {
label: 'partnerprogram',
title: 'Byg din forretning på Dezky.',
intro: 'White-label hele suiten under dit eget brand. Du ejer kunderelationen og prissætningen — vi driver platformen, EU-hostet og licensren.',
benefitsLabel: 'Hvad du får',
benefits: [
['Fuldt whitelabel', 'Dit domæne, dit logo, dine farver. Ingen Dezky-branding mod slutkunden.'],
['Multi-tenant konsol', 'Administrér alle dine kunder fra ét panel — provisionering, brugere og fakturering.'],
['1540 % margin', 'Sund margin, der vokser med volumen. Forudsigelig prissætning, ingen skjulte gebyrer.'],
['Co-marketing & leads', 'Fælles kampagner og kundeleads via partnernetværket.'],
],
stepsLabel: 'Sådan kommer du i gang',
steps: [
['01', 'Ansøg', 'Book en samtale, så vi forstår din forretning og dine kunder.'],
['02', 'Onboarding', 'Vi sætter dit white-label-miljø op og træner dit team.'],
['03', 'Lancér', 'Sælg under dit eget brand med os som motoren bagved.'],
],
cta: 'Book en partnersamtale',
calc: {
label: 'Regn din margin ud',
heading: 'Se hvad partnerskabet er værd.',
seatsLabel: 'Antal brugere',
marginLabel: 'Din margin',
monthlyLabel: 'Din månedlige margin',
annualLabel: 'Svarer til årligt',
note: 'Marginen beregnes progressivt pr. trin, ud fra listeprisen på 49 kr./bruger/md. Endelige wholesale-vilkår aftales ved onboarding.',
},
compare: {
label: 'Hvorfor skifte',
heading: 'CSP-videresalg vs. Dezky-partner.',
cols: ['Microsoft / Google CSP', 'Dezky-partner'],
rows: [
['Din margin', '515 %', '1540 %'],
['Kunderelationen', 'Deles med hyperscaleren', 'Ejer du 100 %'],
['White-label', 'Ikke muligt', 'Fuldt — dit brand'],
['Prissætning', 'Fastsat for dig', 'Du bestemmer selv'],
['Differentiering', 'Samme som alle andre', 'EU-suverænitet & open source'],
['Lock-in mod kunden', 'Proprietær', 'Åbne standarder, ingen lock-in'],
],
},
tiers: {
label: 'Partnerniveauer',
heading: 'Voks med os.',
note: 'Margin- og kravsatser er vejledende og bekræftes i partneraftalen.',
items: [
['Registreret', 'Fra første kunde', '15 %', ['White-label-miljø', 'Multi-tenant konsol', 'E-mail-support']],
['Certificeret', 'Fra 501 brugere', '30 %', ['Alt i Registreret', 'Prioriteret support', 'Co-marketing-materiale']],
['Premier', 'Fra 1.001 brugere', '40 %', ['Alt i Certificeret', 'Dedikeret partneransvarlig', 'Kundeleads & fælles kampagner']],
],
},
faq: {
label: 'Partner-FAQ',
heading: 'Det partnere spørger om.',
items: [
['Hvem fakturerer slutkunden?', 'Det gør du. Du ejer aftalen, prissætningen og fakturaen — vi fakturerer dig til wholesale-pris.'],
['Kan jeg sætte mine egne priser?', 'Ja. Du fastsætter frit din udsalgspris. Din margin er forskellen op til din wholesale-pris.'],
['Hvem ejer kundens data?', 'Kunden. Data ligger i EU under europæisk lov og kan altid eksporteres via åbne standarder.'],
['Hvilken support får jeg?', 'Partner-support på alle niveauer, med prioriteret kø og dedikeret ansvarlig på de højere niveauer.'],
['Er der bindings- eller mindstekøb?', 'Nej. Der er intet minimumskøb for at starte. De højere niveauer kræver et vist antal aktive brugere.'],
['Hvor hurtigt kan jeg være i gang?', 'Typisk inden for en uge: vi opsætter dit white-label-miljø og træner dit team.'],
],
},
},
stubs: {
customers: 'Kunder',
careers: 'Karriere',
press: 'Presse',
status: 'Systemstatus',
docs: 'Dokumentation',
blog: 'Blog',
privacy: 'Privatlivspolitik',
dpa: 'Databehandleraftale',
terms: 'Vilkår',
sla: 'SLA',
cookies: 'Cookiepolitik',
},
},
footer: {
tagline: 'Suveræn produktivitet til danske virksomheder.',
legal: { name: 'Dezky ApS', cvr: 'CVR 44 12 89 03', addr: 'Refshalevej 153A · 1432 København K' },
legal: { name: 'Dezky ApS', cvr: 'CVR 43 14 18 21', addr: 'Åtoften 33 · 6710 Esbjerg V' },
cols: [
['Produkt', [['Funktioner', '#suite'], ['Sikkerhed', '#sovereignty'], ['Roadmap', '#'], ['Status', '#'], ['Changelog', '#']]],
['Selskab', [['Om os', '#'], ['Kunder', '#'], ['Karriere', '#'], ['Presse', '#'], ['Kontakt', '#']]],
['Ressourcer', [['Docs', '#'], ['Migrationsguide', '#'], ['Partnere', '#whitelabel'], ['Blog', '#'], ['Brand', '#']]],
['Juridisk', [['Privatlivspolitik', '#'], ['Databehandler', '#'], ['Vilkår', '#'], ['SLA', '#'], ['Cookies', '#']]],
['Produkt', [['Funktioner', '/#suite'], ['Sikkerhed', '/#sovereignty'], ['Roadmap', '/roadmap'], ['Status', '/status'], ['Changelog', '/changelog']]],
['Selskab', [['Om os', '/about'], ['Kunder', '/customers'], ['Karriere', '/careers'], ['Presse', '/press'], ['Kontakt', '/contact']]],
['Ressourcer', [['Docs', '/docs'], ['Migrationsguide', '/migration'], ['Partnere', '/#whitelabel'], ['Blog', '/blog'], ['Brand', '/brand']]],
['Juridisk', [['Privatlivspolitik', '/privacy'], ['Databehandler', '/dpa'], ['Vilkår', '/terms'], ['SLA', '/sla'], ['Cookies', '/cookies']]],
] as [string, [string, string][]][],
copyright: '© 2026 Dezky ApS. Alle rettigheder forbeholdes.',
status: 'status · alle systemer kører',
@@ -138,7 +296,7 @@ export const COPY = {
nav: { product: 'product', security: 'security', whitelabel: 'whitelabel', pricing: 'pricing', docs: 'docs', login: 'log in', cta: 'book a demo' },
hero: {
eyebrow: '// sovereign productivity · v1.0',
headlineA: ['The productivity suite', { hl: 'your data stays in Denmark with.' }] as HeadlinePart[],
headlineA: ['Your digital workplace.', { hl: 'Data that stays in the EU.' }] as HeadlinePart[],
headlineB: ['Tools you already know.', { hl: 'Sovereignty you actually need.' }] as HeadlinePart[],
sub: 'Mail, files, video, chat and SSO — fully integrated, EU-hosted, no lock-in. Built on permissively licensed open source.',
cta: 'Book a demo',
@@ -155,12 +313,13 @@ export const COPY = {
label: '02 — the suite',
heading: 'Everything you expect. Nothing you don\'t want.',
lede: 'Five modules. One login. Built to work together — not just live in the same browser.',
soonLabel: 'coming soon',
cards: [
{ name: 'Mail', tag: 'mail · calendar · contacts', desc: 'Domain mail, calendar and contacts with full Outlook and Apple Mail compatibility via IMAP, CalDAV and CardDAV.' },
{ name: 'Drive', tag: 'files · sharing · versions', desc: 'Cloud files with sharing, versioning and built-in Office-format editing. Sync clients for Mac, Windows and Linux.' },
{ name: 'Meet', tag: 'video · screen share', desc: 'Video meetings in the browser. No download. Screen share, recording and background blur out of the box.' },
{ name: 'Chat', tag: 'channels · threads · search', desc: 'Team chat with threads, channels and full history search. Designed to be read async — not to interrupt.' },
{ name: 'Identity', tag: 'sso · mfa · lifecycle', desc: 'Single sign-on, multi-factor and user lifecycle in one panel. Add a user once — they get mail, drive, meet and chat instantly.' },
{ name: 'Mail', tag: 'mail · calendar · contacts', desc: 'Domain mail, calendar and contacts with full Outlook and Apple Mail compatibility via IMAP, CalDAV and CardDAV.', soon: false },
{ name: 'Drive', tag: 'files · sharing · versions', desc: 'Cloud files with sharing, versioning and built-in Office-format editing. Sync clients for Mac, Windows and Linux.', soon: false },
{ name: 'Meet', tag: 'video · screen share', desc: 'Video meetings in the browser. No download. Screen share, recording and background blur out of the box.', soon: true },
{ name: 'Chat', tag: 'channels · threads · search', desc: 'Team chat with threads, channels and full history search. Designed to be read async — not to interrupt.', soon: true },
{ name: 'Identity', tag: 'sso · mfa · lifecycle', desc: 'Single sign-on, multi-factor and user lifecycle in one panel. Add a user once — they get mail, drive, meet and chat instantly.', soon: false },
],
},
how: {
@@ -206,21 +365,26 @@ export const COPY = {
bullets: [
'Full whitelabel theme · CSS and logo',
'Multi-tenant administration',
'Margins of 3045% by volume',
'Margins of 1540% by volume',
'Co-marketing and leads via partner network',
],
cta: 'See the partner program',
partners: [
{ name: 'moltke it', subtitle: 'aalborg · 24 users' },
{ name: 'kraft & partners', subtitle: 'copenhagen · 112 users' },
{ name: 'your company here', subtitle: '—' },
],
},
stack: {
label: '07 — under the hood',
heading: 'Built on open source. Verifiable.',
lede: 'We don\'t hide it. Every component is permissively licensed — you can inspect the code, build it yourself, or move your installation elsewhere.',
label: '07 — open standards',
heading: 'Built on open standards. No lock-in.',
lede: 'Your data speaks well-known, open protocols — not proprietary formats. Export everything anytime and move to another provider.',
rows: [
['Mail', 'Stalwart Mail', 'AGPL-3.0', 'stalw.art'],
['Files & drive', 'ownCloud Infinite Scale', 'Apache 2.0', 'owncloud.dev'],
['Video meetings', 'Jitsi', 'Apache 2.0', 'jitsi.org'],
['Team chat', 'Zulip', 'Apache 2.0', 'zulip.com'],
['Identity & SSO', 'Authentik', 'MIT', 'goauthentik.io'],
['Mail & calendar', 'IMAP · SMTP · CalDAV · CardDAV', 'Export to .mbox and .ics'],
['Files & drive', 'WebDAV · S3', 'Download every file — no format lock'],
['Video meetings', 'WebRTC · SIP', 'Open browser standard, no client'],
['Team chat', 'Open export API', 'Take the full history with you'],
['Identity & SSO', 'OIDC · SAML · SCIM', 'Bring your own IdP'],
],
},
pricing: {
@@ -228,7 +392,7 @@ export const COPY = {
heading: 'Predictable pricing. No surprises.',
lede: 'We\'re in a closed beta until summer 2026. Pricing is set with our first customers — not against them.',
teaser: 'Starting at',
price: '69',
price: '49',
unit: 'DKK / user / mo.',
note: 'Final pricing confirmed at demo. Volume discount from 25 users.',
cta: 'Book a demo for pricing',
@@ -241,7 +405,7 @@ export const COPY = {
['Can I still use Outlook and Office?', 'Yes. Mail, calendar and contacts work via IMAP, CalDAV and CardDAV. Drive files open with Office desktop via WebDAV. We recommend our web and mobile apps, but we don\'t require you to change habits.'],
['Where is data hosted?', 'With Hetzner in Germany. Tier III certified data centers, redundant power and network, ISO 27001 certified operator. No data leaves the EU at any time — not for analytics, logs or support.'],
['What happens if Dezky shuts down?', 'The whole stack is open source. You can export everything and move to another Dezky partner. Our business model is operations — not hostage-taking.'],
['What\'s your SLA?', '99.9% uptime guaranteed on all plans. 99.95% on Enterprise. Public real-time status page at status.dezky.com.'],
['What\'s your SLA?', '99.9% uptime guaranteed on all plans. 99.95% on Enterprise. Public real-time status page at status.dezky.eu.'],
['How is support delivered?', 'Danish and English. Email and chat on all plans. Phone support on Business and Enterprise. Dedicated onboarding consultant from 50 users up.'],
],
},
@@ -250,14 +414,166 @@ export const COPY = {
sub: '30-minute demo. No sales pressure. No slides.',
cta: 'Book a demo',
},
pages: {
back: 'Back to home',
comingSoonKicker: 'Coming soon',
comingSoonBody: 'We\'re building this page right now. Want to know more today? Book a demo and we\'ll walk you through it.',
legalBody: 'This document is being finalised with our advisors. Contact us at kontakt@dezky.eu for the current version.',
ctaDemo: 'Book a demo',
about: {
label: 'about',
title: 'Built in Denmark. For European sovereignty.',
intro: 'Dezky brings mail, files, video, chat and SSO into one suite — EU-hosted and built on open standards, so your data never leaves European jurisdiction.',
body: [
'We started Dezky because European businesses deserve productivity tools that don\'t depend on American infrastructure and shifting license terms. Schrems II and the CLOUD Act made it clear: where data lives, and who can be compelled to hand it over, isn\'t a technical detail — it\'s strategy.',
'We run the platform on European infrastructure, we have no US parent, and we build on permissively licensed open source so you can always export your data and move on. No lock-in, no surprises.',
],
principles: [
['Sovereignty', 'Your data lives under European law — full stop.'],
['Openness', 'Built on open standards and open source. No proprietary traps.'],
['Predictability', 'Fixed pricing for the contract term. No unilateral changes.'],
],
},
contact: {
label: 'contact',
title: 'Let\'s talk.',
intro: 'Questions about migration, pricing or whitelabel? Drop us a line — we reply in Danish and English.',
emailLabel: 'Email',
email: 'kontakt@dezky.eu',
addressLabel: 'Address',
cvrLabel: 'Company reg.',
},
brand: {
label: 'brand',
title: 'Use our brand correctly.',
intro: 'Guidelines for the name, logo and colours. Need logo files? Get in touch.',
rules: [
['Name', 'Always “Dezky” — one word, capital D. Never all caps in running text.'],
['Logo', 'Use the node mark with enough clear space. Don\'t distort, rotate or recolour it.'],
['Tone', 'Direct, technical, no hype. We don\'t sell with fear — we explain.'],
],
colorsLabel: 'Colours',
colors: [
['Signal', '#D4FF3A'],
['Carbon', '#0A0A0A'],
['Bone', '#F4F3EE'],
],
},
roadmap: {
label: 'roadmap',
title: 'Where we\'re headed.',
intro: 'We build in the open. Here\'s what\'s live, what\'s next, and what we\'re planning.',
columns: [
['Live now', ['Mail, calendar & contacts', 'Files & drive', 'Single sign-on & user management']],
['Next', ['Video meetings in the browser', 'Team chat with threads', 'Mobile apps for iOS & Android']],
['Later', ['Customer-held keys (BYOK)', 'Advanced compliance reporting', 'More EU regions']],
],
},
changelog: {
label: 'changelog',
title: 'What\'s new.',
intro: 'Major changes and improvements. Smaller fixes ship continuously.',
entries: [
['v1.0.4', '2026', ['New pricing model and updated pricing page', 'Improved onboarding flow', 'Faster drive loading']],
['v1.0.0', '2026', ['First public beta', 'Mail, drive and SSO live', 'Whitelabel for partners']],
],
},
migration: {
label: 'migration guide',
title: 'Switch with zero downtime.',
intro: 'We move mail, calendar, contacts and files in the background while your team keeps working. The cutover itself is a DNS update.',
steps: [
['01', 'Mapping', 'We review your domains, mailboxes and data and lay out a plan. Typical timeline is 24 weeks for 50 users.'],
['02', 'Parallel copy', 'We copy mail, calendar, contacts and OneDrive/Drive files to Dezky in the background — without interrupting anything.'],
['03', 'Cutover day', 'We update DNS and your users sign in to Dezky. Familiar web and mobile apps from day one.'],
],
note: 'Migration from Microsoft 365 and Google Workspace is included in every plan.',
},
partners: {
label: 'partner program',
title: 'Build your business on Dezky.',
intro: 'White-label the whole suite under your own brand. You own the customer relationship and the pricing — we run the platform, EU-hosted and permissively licensed.',
benefitsLabel: 'What you get',
benefits: [
['Full white-label', 'Your domain, your logo, your colours. No Dezky branding shown to the end customer.'],
['Multi-tenant console', 'Manage all your customers from one panel — provisioning, users and billing.'],
['1540% margin', 'Healthy margins that grow with volume. Predictable pricing, no hidden fees.'],
['Co-marketing & leads', 'Joint campaigns and customer leads via the partner network.'],
],
stepsLabel: 'How to get started',
steps: [
['01', 'Apply', 'Book a call so we understand your business and your customers.'],
['02', 'Onboarding', 'We set up your white-label environment and train your team.'],
['03', 'Launch', 'Sell under your own brand with us as the engine behind it.'],
],
cta: 'Book a partner call',
calc: {
label: 'Calculate your margin',
heading: 'See what the partnership is worth.',
seatsLabel: 'Number of users',
marginLabel: 'Your margin',
monthlyLabel: 'Your monthly margin',
annualLabel: 'Equals annually',
note: 'Margin is calculated progressively per tier, based on the 49 kr/user/mo list price. Final wholesale terms are agreed at onboarding.',
},
compare: {
label: 'Why switch',
heading: 'Reselling CSP vs. a Dezky partnership.',
cols: ['Microsoft / Google CSP', 'Dezky partner'],
rows: [
['Your margin', '515%', '1540%'],
['Customer relationship', 'Shared with the hyperscaler', 'You own it 100%'],
['White-label', 'Not possible', 'Full — your brand'],
['Pricing', 'Set for you', 'You decide'],
['Differentiation', 'Same as everyone else', 'EU sovereignty & open source'],
['Lock-in toward the customer', 'Proprietary', 'Open standards, no lock-in'],
],
},
tiers: {
label: 'Partner tiers',
heading: 'Grow with us.',
note: 'Margin and requirement figures are indicative and confirmed in the partner agreement.',
items: [
['Registered', 'From your first customer', '15%', ['White-label environment', 'Multi-tenant console', 'Email support']],
['Certified', 'From 501 users', '30%', ['Everything in Registered', 'Priority support', 'Co-marketing material']],
['Premier', 'From 1,001 users', '40%', ['Everything in Certified', 'Dedicated partner manager', 'Customer leads & joint campaigns']],
],
},
faq: {
label: 'Partner FAQ',
heading: 'What partners ask.',
items: [
['Who bills the end customer?', 'You do. You own the contract, the pricing and the invoice — we bill you at the wholesale price.'],
['Can I set my own prices?', 'Yes. You set your retail price freely. Your margin is the difference above your wholesale price.'],
['Who owns the customer\'s data?', 'The customer. Data sits in the EU under European law and can always be exported via open standards.'],
['What support do I get?', 'Partner support at every tier, with a priority queue and a dedicated manager at the higher tiers.'],
['Is there a lock-in or minimum purchase?', 'No minimum to start. The higher tiers require a certain number of active users.'],
['How fast can I be up and running?', 'Typically within a week: we set up your white-label environment and train your team.'],
],
},
},
stubs: {
customers: 'Customers',
careers: 'Careers',
press: 'Press',
status: 'System status',
docs: 'Documentation',
blog: 'Blog',
privacy: 'Privacy policy',
dpa: 'Data processing agreement',
terms: 'Terms of service',
sla: 'SLA',
cookies: 'Cookie policy',
},
},
footer: {
tagline: 'Sovereign productivity for Danish business.',
legal: { name: 'Dezky ApS', cvr: 'CVR 44 12 89 03', addr: 'Refshalevej 153A · 1432 Copenhagen K' },
legal: { name: 'Dezky ApS', cvr: 'CVR 43 14 18 21', addr: 'Åtoften 33 · 6710 Esbjerg V' },
cols: [
['Product', [['Features', '#suite'], ['Security', '#sovereignty'], ['Roadmap', '#'], ['Status', '#'], ['Changelog', '#']]],
['Company', [['About', '#'], ['Customers', '#'], ['Careers', '#'], ['Press', '#'], ['Contact', '#']]],
['Resources', [['Docs', '#'], ['Migration guide', '#'], ['Partners', '#whitelabel'], ['Blog', '#'], ['Brand', '#']]],
['Legal', [['Privacy', '#'], ['DPA', '#'], ['Terms', '#'], ['SLA', '#'], ['Cookies', '#']]],
['Product', [['Features', '/#suite'], ['Security', '/#sovereignty'], ['Roadmap', '/roadmap'], ['Status', '/status'], ['Changelog', '/changelog']]],
['Company', [['About', '/about'], ['Customers', '/customers'], ['Careers', '/careers'], ['Press', '/press'], ['Contact', '/contact']]],
['Resources', [['Docs', '/docs'], ['Migration guide', '/migration'], ['Partners', '/#whitelabel'], ['Blog', '/blog'], ['Brand', '/brand']]],
['Legal', [['Privacy', '/privacy'], ['DPA', '/dpa'], ['Terms', '/terms'], ['SLA', '/sla'], ['Cookies', '/cookies']]],
] as [string, [string, string][]][],
copyright: '© 2026 Dezky ApS. All rights reserved.',
status: 'status · all systems operational',
+1 -1
View File
@@ -74,6 +74,6 @@ export function makeTheme(dark: boolean): DezkyTheme {
}
}
// The destination the nav/login CTA points at. Production is app.dezky.com;
// The destination the nav/login CTA points at. Production is app.dezky.eu;
// locally the portal runs at app.dezky.local.
export const APP_URL = 'https://app.dezky.local'
+77 -10
View File
@@ -55,6 +55,7 @@ check_command() {
check_command docker "Install Docker Desktop or OrbStack from https://orbstack.dev"
check_command mkcert "brew install mkcert"
check_command openssl "Should be preinstalled on macOS"
check_command git "brew install git"
if ! docker compose version &> /dev/null; then
error "Docker Compose v2 not available."
@@ -73,9 +74,75 @@ ok "Docker daemon running"
echo ""
# ────────────────────────────────────────
# Step 2: Generate TLS certificates
# Step 2: Configure git remote
# ────────────────────────────────────────
info "Step 2: Setting up TLS certificates..."
info "Step 2: Configuring git remote..."
GIT_REMOTE_URL="git@git.lastcloud.io:ronnibaslund/dezky.git"
GIT_SSH_HOST="git.lastcloud.io"
GIT_SSH_PORT="22222"
if [[ -d "$PROJECT_ROOT/.git" ]]; then
CURRENT_URL="$(git -C "$PROJECT_ROOT" remote get-url origin 2>/dev/null || true)"
if [[ "$CURRENT_URL" == "$GIT_REMOTE_URL" ]]; then
ok "Git remote 'origin' already set to $GIT_REMOTE_URL"
elif [[ -n "$CURRENT_URL" ]]; then
git -C "$PROJECT_ROOT" remote set-url origin "$GIT_REMOTE_URL"
ok "Updated git remote 'origin' → $GIT_REMOTE_URL (was $CURRENT_URL)"
else
git -C "$PROJECT_ROOT" remote add origin "$GIT_REMOTE_URL"
ok "Added git remote 'origin' → $GIT_REMOTE_URL"
fi
# Gitea's git SSH listens on a non-standard port. Without an ssh config
# entry, git defaults to port 22 and the global "Host *" 1Password agent
# offers too many keys — the server rejects the connection before the right
# key is tried. Pin the host to port 22222 and the registered key only.
if [[ "$(ssh -G "$GIT_SSH_HOST" 2>/dev/null | awk '/^port /{print $2}')" == "$GIT_SSH_PORT" ]]; then
ok "SSH config already routes $GIT_SSH_HOST to port $GIT_SSH_PORT"
else
warn "$GIT_SSH_HOST is not pinned to port $GIT_SSH_PORT in your SSH config"
echo ""
echo "The following block is needed in ~/.ssh/config so git can reach Gitea:"
echo ""
echo " Host $GIT_SSH_HOST"
echo " HostName $GIT_SSH_HOST"
echo " Port $GIT_SSH_PORT"
echo " User git"
echo " IdentityFile ~/.ssh/id_ed25519"
echo " IdentitiesOnly yes"
echo ""
read -p "Append this block to ~/.ssh/config automatically? [y/N] " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
mkdir -p "$HOME/.ssh"
{
echo ""
echo "# Gitea (lastcloud) — Git SSH on port $GIT_SSH_PORT. Force the registered"
echo "# key only; the global \"Host *\" agent otherwise offers too many keys."
echo "Host $GIT_SSH_HOST"
echo " HostName $GIT_SSH_HOST"
echo " Port $GIT_SSH_PORT"
echo " User git"
echo " IdentityFile ~/.ssh/id_ed25519"
echo " IdentitiesOnly yes"
} >> "$HOME/.ssh/config"
chmod 600 "$HOME/.ssh/config"
ok "Appended SSH config block for $GIT_SSH_HOST"
else
warn "Skipping SSH config — pushes to $GIT_SSH_HOST may fail until you add it"
fi
fi
else
warn "No .git directory in $PROJECT_ROOT — skipping git remote setup"
fi
echo ""
# ────────────────────────────────────────
# Step 3: Generate TLS certificates
# ────────────────────────────────────────
info "Step 3: Setting up TLS certificates..."
mkdir -p "$CERTS_DIR"
cd "$CERTS_DIR"
@@ -103,9 +170,9 @@ cd "$PROJECT_ROOT"
echo ""
# ────────────────────────────────────────
# Step 3: Update /etc/hosts
# Step 4: Update /etc/hosts
# ────────────────────────────────────────
info "Step 3: Setting up /etc/hosts entries..."
info "Step 4: Setting up /etc/hosts entries..."
HOSTS_ENTRIES=(
"dezky.local"
@@ -151,9 +218,9 @@ fi
echo ""
# ────────────────────────────────────────
# Step 4: Generate .env file
# Step 5: Generate .env file
# ────────────────────────────────────────
info "Step 4: Setting up .env file..."
info "Step 5: Setting up .env file..."
if [[ -f "$PROJECT_ROOT/.env" ]]; then
ok ".env file already exists"
@@ -190,9 +257,9 @@ fi
echo ""
# ────────────────────────────────────────
# Step 5: Pull Docker images
# Step 6: Pull Docker images
# ────────────────────────────────────────
info "Step 5: Pulling Docker images (this may take a few minutes)..."
info "Step 6: Pulling Docker images (this may take a few minutes)..."
cd "$COMPOSE_DIR"
docker compose pull
@@ -201,9 +268,9 @@ ok "All images pulled"
echo ""
# ────────────────────────────────────────
# Step 6: Start the stack in stages
# Step 7: Start the stack in stages
# ────────────────────────────────────────
info "Step 6: Starting services..."
info "Step 7: Starting services..."
info "Starting database layer (postgres, mongo, redis)..."
docker compose up -d postgres mongo redis