Compare commits
12 Commits
c9911cc262
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bc0697c3e8 | |||
| 80c8a23688 | |||
| f447b13c83 | |||
| ed660b9a81 | |||
| 41af70d57b | |||
| bf183fce07 | |||
| 6d82502e7b | |||
| 2e400d86c5 | |||
| 0a35d9deb6 | |||
| 4c57d41350 | |||
| a0f79ab852 | |||
| 4c3c47cc87 |
@@ -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
|
||||||
@@ -7,6 +7,10 @@ const props = withDefaults(defineProps<{
|
|||||||
scale?: number
|
scale?: number
|
||||||
fg?: string
|
fg?: string
|
||||||
accent?: string
|
accent?: string
|
||||||
|
// Wordmark colour. Defaults to `fg`; pass separately when the squircle and
|
||||||
|
// the wordmark need different colours (e.g. on a dark surface the squircle is
|
||||||
|
// carbon — invisible — while the wordmark must stay light).
|
||||||
|
wordmark?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
scale: 1,
|
scale: 1,
|
||||||
fg: C.carbon,
|
fg: C.carbon,
|
||||||
@@ -22,7 +26,7 @@ const props = withDefaults(defineProps<{
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: `${56 * scale * 0.78}px`,
|
fontSize: `${56 * scale * 0.78}px`,
|
||||||
letterSpacing: '-0.04em',
|
letterSpacing: '-0.04em',
|
||||||
color: fg,
|
color: wordmark || fg,
|
||||||
lineHeight: 0.9,
|
lineHeight: 0.9,
|
||||||
}">dezky</span>
|
}">dezky</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Dezky "Node" mark — a lowercase d (donut style) inside a squircle, with a
|
// Dezky "Node" mark — a lowercase d inside a squircle, with a corner node-dot.
|
||||||
// corner node-dot. Geometry is the locked set from the brand handoff
|
// Geometry is the locked set from the brand handoff (logos.jsx NodeMark +
|
||||||
// (logos.jsx NodeMark + LOCKED). The squircle paints in `fg`; the letterform
|
// LOCKED). The squircle paints in `fg`; the letterform and dot paint in
|
||||||
// and dot paint in `accent` (electric chartreuse) — the design's intent.
|
// `accent` (electric chartreuse). `variant` controls the bowl rendering:
|
||||||
|
// donut/solid paint the filled letter (with counter); outline knocks out an
|
||||||
|
// eroded copy so only a uniform rim survives.
|
||||||
|
import { computed } from 'vue'
|
||||||
import { C, LOCKED } from '~/utils/landingTokens'
|
import { C, LOCKED } from '~/utils/landingTokens'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
size?: number
|
size?: number
|
||||||
fg?: string
|
fg?: string
|
||||||
accent?: string
|
accent?: string
|
||||||
|
variant?: 'donut' | 'solid' | 'outline'
|
||||||
}>(), {
|
}>(), {
|
||||||
size: 96,
|
size: 96,
|
||||||
fg: C.carbon,
|
fg: C.carbon,
|
||||||
accent: C.signal,
|
accent: C.signal,
|
||||||
|
variant: 'donut',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { bowlR, stemW, dotR, contR } = LOCKED
|
const { bowlR, stemW, dotR, contR } = LOCKED
|
||||||
@@ -40,12 +45,40 @@ const stemPath =
|
|||||||
`a ${capR} ${capR} 0 0 1 ${stemW} 0 ` +
|
`a ${capR} ${capR} 0 0 1 ${stemW} 0 ` +
|
||||||
`L ${stemRight} ${stemBottom} ` +
|
`L ${stemRight} ${stemBottom} ` +
|
||||||
`L ${stemX} ${stemBottom} Z`
|
`L ${stemX} ${stemBottom} Z`
|
||||||
|
|
||||||
|
// Eroded inner copy for the outline variant (a uniform rim survives).
|
||||||
|
const outline = computed(() => {
|
||||||
|
const maxOw = (overlap - 1.4) / 2
|
||||||
|
const ow = Math.max(1.1, Math.min(stemW * 0.32, maxOw))
|
||||||
|
const ibowlR = bowlR - ow
|
||||||
|
const iholeR = holeR + ow
|
||||||
|
const istemX = stemX + ow
|
||||||
|
const istemW = Math.max(1.2, stemW - 2 * ow)
|
||||||
|
const istemRight = istemX + istemW
|
||||||
|
const icapR = istemW / 2
|
||||||
|
const istemTop = stemTop + ow
|
||||||
|
return {
|
||||||
|
bowl: `M ${cx - ibowlR} ${cy} a ${ibowlR} ${ibowlR} 0 1 0 ${ibowlR * 2} 0 a ${ibowlR} ${ibowlR} 0 1 0 ${-ibowlR * 2} 0 Z`,
|
||||||
|
counter: `M ${cx - iholeR} ${cy} a ${iholeR} ${iholeR} 0 1 0 ${iholeR * 2} 0 a ${iholeR} ${iholeR} 0 1 0 ${-iholeR * 2} 0 Z`,
|
||||||
|
stem: `M ${istemX} ${istemTop + icapR} a ${icapR} ${icapR} 0 0 1 ${istemW} 0 L ${istemRight} ${stemBottom} L ${istemX} ${stemBottom} Z`,
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<svg :width="size" :height="size" viewBox="0 0 100 100" aria-label="dezky node mark">
|
<svg :width="size" :height="size" viewBox="0 0 100 100" aria-label="dezky node mark">
|
||||||
<rect x="8" y="8" width="84" height="84" :rx="contR" :fill="fg" />
|
<rect x="8" y="8" width="84" height="84" :rx="contR" :fill="fg" />
|
||||||
<g :fill="accent">
|
<template v-if="variant === 'outline'">
|
||||||
|
<path :d="`${bowlPath} ${counterPath}`" fill-rule="evenodd" :fill="accent" />
|
||||||
|
<path :d="stemPath" :fill="accent" />
|
||||||
|
<path :d="`${outline.bowl} ${outline.counter}`" fill-rule="evenodd" :fill="fg" />
|
||||||
|
<path :d="outline.stem" :fill="fg" />
|
||||||
|
</template>
|
||||||
|
<g v-else-if="variant === 'solid'" :fill="accent">
|
||||||
|
<path :d="bowlPath" />
|
||||||
|
<path :d="stemPath" />
|
||||||
|
</g>
|
||||||
|
<g v-else :fill="accent">
|
||||||
<path :d="`${bowlPath} ${counterPath}`" fill-rule="evenodd" />
|
<path :d="`${bowlPath} ${counterPath}`" fill-rule="evenodd" />
|
||||||
<path :d="stemPath" />
|
<path :d="stemPath" />
|
||||||
</g>
|
</g>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Footer — lockup + tagline + legal, four link columns, status row.
|
// Footer — lockup + tagline + legal, four link columns, status row.
|
||||||
// Ported from landing-sections.jsx Footer (light mode). Anchor links smooth-
|
// Ported from landing-sections.jsx Footer (light mode). Links are real routes
|
||||||
// scroll; "#" placeholders point at not-yet-built subpages.
|
// 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 { C } from '~/utils/landingTokens'
|
||||||
import { useTheme, useCopy, scrollToAnchor } from '~/composables/useLanding'
|
import { useTheme, useCopy, scrollToAnchor } from '~/composables/useLanding'
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const copy = useCopy()
|
const copy = useCopy()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
function onLink(e: MouseEvent, href: string) {
|
function onLink(e: MouseEvent, href: string) {
|
||||||
if (href.startsWith('#') && href.length > 1) {
|
// In-page section link ("/#suite"): smooth-scroll in place when already on
|
||||||
e.preventDefault()
|
// the homepage. Off-page, let NuxtLink route to "/#suite" — index.vue scrolls
|
||||||
scrollToAnchor(href)
|
// to the hash on mount.
|
||||||
} else if (href === '#') {
|
if (href.includes('#') && route.path === '/') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
scrollToAnchor(href.slice(href.indexOf('#')))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -33,11 +37,11 @@ function onLink(e: MouseEvent, href: string) {
|
|||||||
<div v-for="(col, i) in copy.footer.cols" :key="i">
|
<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="{ 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' }">
|
<div :style="{ display: 'flex', flexDirection: 'column', gap: '12px' }">
|
||||||
<a
|
<NuxtLink
|
||||||
v-for="(link, j) in col[1]" :key="j" :href="link[1]"
|
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)' }"
|
:style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '14px', color: 'rgba(10,10,10,0.78)' }"
|
||||||
@click="onLink($event, link[1])"
|
@click="onLink($event, link[1])"
|
||||||
>{{ link[0] }}</a>
|
>{{ link[0] }}</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,24 +2,37 @@
|
|||||||
// Sticky top nav with logo, anchor links, language toggle, login + demo CTA.
|
// Sticky top nav with logo, anchor links, language toggle, login + demo CTA.
|
||||||
// Ported from landing-sections.jsx Nav (light mode, production subset).
|
// Ported from landing-sections.jsx Nav (light mode, production subset).
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { APP_URL } from '~/utils/landingTokens'
|
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 t = useTheme()
|
||||||
const copy = useCopy()
|
const copy = useCopy()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const items = computed(() => [
|
const items = computed(() => [
|
||||||
{ label: copy.value.nav.product, href: '#suite' },
|
{ label: copy.value.nav.product, href: '/#suite' },
|
||||||
{ label: copy.value.nav.security, href: '#sovereignty' },
|
{ label: copy.value.nav.security, href: '/#sovereignty' },
|
||||||
{ label: copy.value.nav.whitelabel, href: '#whitelabel' },
|
{ label: copy.value.nav.whitelabel, href: '/#whitelabel' },
|
||||||
{ label: copy.value.nav.pricing, href: '#pricing' },
|
{ label: copy.value.nav.pricing, href: '/#pricing' },
|
||||||
{ label: copy.value.nav.docs, href: '#' },
|
{ 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' })
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -33,17 +46,17 @@ function onLogo() {
|
|||||||
maxWidth: '1280px', margin: '0 auto', padding: '18px 64px',
|
maxWidth: '1280px', margin: '0 auto', padding: '18px 64px',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
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" />
|
<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>
|
<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' }">
|
<nav :style="{ display: 'flex', alignItems: 'center', gap: '36px' }">
|
||||||
<a
|
<NuxtLink
|
||||||
v-for="(it, i) in items" :key="i" :href="it.href"
|
v-for="(it, i) in items" :key="i" :to="it.href"
|
||||||
:style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '14px', color: t.fgMuted, letterSpacing: '-0.005em' }"
|
:style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '14px', color: t.fgMuted, letterSpacing: '-0.005em' }"
|
||||||
@click.prevent="scrollToAnchor(it.href)"
|
@click="onNav($event, it.href)"
|
||||||
>{{ it.label }}</a>
|
>{{ it.label }}</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div :style="{ display: 'flex', alignItems: 'center', gap: '14px' }">
|
<div :style="{ display: 'flex', alignItems: 'center', gap: '14px' }">
|
||||||
@@ -57,7 +70,7 @@ function onLogo() {
|
|||||||
@click="toggleLang()"
|
@click="toggleLang()"
|
||||||
>{{ lang === 'da' ? 'da · en' : 'en · da' }}</button>
|
>{{ 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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 501–1000 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?: boolean
|
||||||
}>(), { placeholder: false })
|
}>(), { placeholder: false })
|
||||||
|
|
||||||
const initial = computed(() => props.name[0].toUpperCase())
|
const initial = computed(() => (props.name[0] ?? '').toUpperCase())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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: '#E23030' }" />
|
||||||
<span :style="{ width: '10px', height: '10px', borderRadius: '999px', background: '#E89A1F' }" />
|
<span :style="{ width: '10px', height: '10px', borderRadius: '999px', background: '#E89A1F' }" />
|
||||||
<span :style="{ width: '10px', height: '10px', borderRadius: '999px', background: '#1F8A5B' }" />
|
<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>
|
||||||
|
|
||||||
<div :style="{ display: 'grid', gridTemplateColumns: '220px 1fr', minHeight: '460px' }">
|
<div :style="{ display: 'grid', gridTemplateColumns: '220px 1fr', minHeight: '460px' }">
|
||||||
|
|||||||
@@ -18,18 +18,16 @@ const copy = useCopy()
|
|||||||
<div
|
<div
|
||||||
v-for="(row, i) in copy.stack.rows" :key="i"
|
v-for="(row, i) in copy.stack.rows" :key="i"
|
||||||
:style="{
|
: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',
|
gap: '24px', padding: '24px 0',
|
||||||
borderTop: i === 0 ? `1px solid ${t.borderStrong}` : 'none',
|
borderTop: i === 0 ? `1px solid ${t.borderStrong}` : 'none',
|
||||||
borderBottom: `1px solid ${t.border}`,
|
borderBottom: `1px solid ${t.border}`,
|
||||||
alignItems: 'baseline', fontFamily: '\'Inter\', sans-serif', fontSize: '15px',
|
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[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: '12.5px', color: t.fg, letterSpacing: '0.02em' }">{{ 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="{ color: t.fgMuted }">{{ 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</LandingContainer>
|
</LandingContainer>
|
||||||
|
|||||||
@@ -25,7 +25,20 @@ const copy = useCopy()
|
|||||||
minHeight: '280px',
|
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>
|
||||||
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '22px', color: t.fg, letterSpacing: '-0.02em' }">{{ card.name }}</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>
|
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '10.5px', color: t.fgDim, marginTop: '6px', letterSpacing: '0.08em', textTransform: 'lowercase' }">{{ card.tag }}</div>
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ const dark = useDark()
|
|||||||
|
|
||||||
const sectionBg = computed(() => (dark.value ? '#1A1A17' : '#EFEDE3'))
|
const sectionBg = computed(() => (dark.value ? '#1A1A17' : '#EFEDE3'))
|
||||||
const cardBg = computed(() => (dark.value ? '#0F0F0D' : '#FFFFFF'))
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -29,13 +45,21 @@ const cardBg = computed(() => (dark.value ? '#0F0F0D' : '#FFFFFF'))
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :style="{ marginTop: '40px' }">
|
<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>
|
</div>
|
||||||
<div :style="{ display: 'flex', flexDirection: 'column', gap: '16px' }">
|
<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
|
||||||
<LandingPartnerCard :fg="t.fg" :bg="cardBg" :border="t.border" accent="#3956C8" name="kraft & partners" subtitle="københavn · 112 brugere" />
|
v-for="(p, i) in partnerCards"
|
||||||
<LandingPartnerCard :fg="t.fg" :bg="cardBg" :border="t.border" :accent="t.signal" name="dit firma her" subtitle="—" placeholder />
|
:key="i"
|
||||||
|
:fg="t.fg"
|
||||||
|
:bg="cardBg"
|
||||||
|
:border="t.border"
|
||||||
|
:accent="p.accent"
|
||||||
|
:name="p.name"
|
||||||
|
:subtitle="p.subtitle"
|
||||||
|
:placeholder="p.placeholder"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</LandingContainer>
|
</LandingContainer>
|
||||||
|
|||||||
@@ -34,3 +34,18 @@ export function scrollToAnchor(hash: string) {
|
|||||||
window.scrollTo({ top, behavior: 'smooth' })
|
window.scrollTo({ top, behavior: 'smooth' })
|
||||||
history.replaceState(null, '', hash)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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
|
// Unlike apps/portal and apps/operator this surface is fully public — no
|
||||||
// OIDC, no sessions, no platform-api coupling. It can be statically
|
// OIDC, no sessions, no platform-api coupling. It can be statically
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
"name": "@dezky/website",
|
"name": "@dezky/website",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"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": {
|
"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",
|
"build": "nuxt build",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"lint": "eslint ."
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nuxt": "^4.4.6",
|
"nuxt": "^4.4.7",
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
"vue-router": "^4.4.0"
|
"vue-router": "^4.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<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 },
|
||||||
|
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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Dezky Brand Guide — ported from the Claude Design handoff (guide.jsx).
|
||||||
|
// Long-scroll document: Cover → Logo → Color → Typography → Voice →
|
||||||
|
// Applications. English-only by design; rendered inside the site layout (Nav +
|
||||||
|
// Footer). Illustrative copy adapted to Dezky's real EU-sovereign positioning.
|
||||||
|
import { C } from '~/utils/landingTokens'
|
||||||
|
|
||||||
|
definePageMeta({ layout: 'page' })
|
||||||
|
|
||||||
|
useHead({ title: 'dezky · brand guide v1.0' })
|
||||||
|
|
||||||
|
const mono = '"JetBrains Mono", ui-monospace, monospace'
|
||||||
|
const tight = '"Inter Tight", "Inter", sans-serif'
|
||||||
|
const inter = '"Inter", sans-serif'
|
||||||
|
|
||||||
|
function pStyle(max = 640, dim = false) {
|
||||||
|
return { fontFamily: inter, fontSize: '16px', lineHeight: 1.6, maxWidth: `${max}px`, color: dim ? 'rgba(0,0,0,0.65)' : 'currentColor', margin: '0 0 16px', textWrap: 'pretty' as const }
|
||||||
|
}
|
||||||
|
function h3Style(mt = 64) {
|
||||||
|
return { fontFamily: tight, fontWeight: 600, fontSize: '24px', letterSpacing: '-0.02em', lineHeight: 1.15, margin: `${mt}px 0 16px` }
|
||||||
|
}
|
||||||
|
function frame(bg: string, height?: number, fg?: string) {
|
||||||
|
return { background: bg, color: fg, height: height ? `${height}px` : '100%', borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' as const, overflow: 'hidden' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const eyebrow = { fontFamily: mono, fontSize: '11px', letterSpacing: '0.18em', textTransform: 'uppercase' as const, fontWeight: 500, opacity: 0.7 }
|
||||||
|
const h1 = { fontFamily: tight, fontWeight: 600, fontSize: 'clamp(48px, 6vw, 72px)', letterSpacing: '-0.035em', lineHeight: 0.95, margin: '24px 0 0', textWrap: 'balance' as const }
|
||||||
|
const h2 = { fontFamily: tight, fontWeight: 600, fontSize: 'clamp(36px, 4.5vw, 52px)', letterSpacing: '-0.03em', lineHeight: 1, margin: '20px 0 56px', textWrap: 'balance' as const }
|
||||||
|
const caption = { fontFamily: mono, fontSize: '10px', letterSpacing: '0.12em', textTransform: 'uppercase' as const, color: 'rgba(0,0,0,0.5)', marginTop: '10px' }
|
||||||
|
const page = { padding: '96px 80px', borderBottom: `1px solid ${C.fog}` }
|
||||||
|
|
||||||
|
const bigSwatches = [
|
||||||
|
{ name: 'Carbon', hex: C.carbon, rgb: '10 10 10', role: 'Foreground · containers · type', dark: true },
|
||||||
|
{ name: 'Signal', hex: C.signal, rgb: '212 255 58', role: 'Brand accent · CTAs · the node-dot', dark: false },
|
||||||
|
]
|
||||||
|
const surfaces = [
|
||||||
|
{ name: 'Bone', hex: C.bone, role: 'Primary surface' },
|
||||||
|
{ name: 'Paper', hex: C.paper, role: 'Elevated surface' },
|
||||||
|
{ name: 'Fog', hex: C.fog, role: 'Dividers · inputs' },
|
||||||
|
{ name: 'Slate', hex: C.slate, role: 'Secondary type' },
|
||||||
|
]
|
||||||
|
const semantic = [
|
||||||
|
{ name: 'Success', hex: C.ok, role: 'Positive states' },
|
||||||
|
{ name: 'Warning', hex: C.warn, role: 'Caution' },
|
||||||
|
{ name: 'Error', hex: C.bad, role: 'Errors · destructive' },
|
||||||
|
]
|
||||||
|
const specRows: [string, string][] = [
|
||||||
|
['Squircle', '84 × 84 unit · radius 22'],
|
||||||
|
['Bowl', 'full circle · r 14'],
|
||||||
|
['Stem', '7 wide · rounded ascender cap'],
|
||||||
|
['Counter', 'r 6 · centered in bowl'],
|
||||||
|
['Node-dot', 'r 4 · upper-right corner'],
|
||||||
|
['Clear space', '= squircle corner radius'],
|
||||||
|
]
|
||||||
|
const typeScale = [
|
||||||
|
{ token: 'display-xl', px: 96, weight: 600, label: 'Page hero · landings only' },
|
||||||
|
{ token: 'display', px: 64, weight: 600, label: 'Section heroes' },
|
||||||
|
{ token: 'h1', px: 40, weight: 600, label: 'Page titles' },
|
||||||
|
{ token: 'h2', px: 28, weight: 600, label: 'Section titles' },
|
||||||
|
{ token: 'h3', px: 20, weight: 600, label: 'Subsections' },
|
||||||
|
{ token: 'body-lg', px: 18, weight: 400, label: 'Intro paragraphs' },
|
||||||
|
{ token: 'body', px: 16, weight: 400, label: 'Default body' },
|
||||||
|
{ token: 'caption', px: 13, weight: 500, label: 'Metadata · labels' },
|
||||||
|
]
|
||||||
|
const soundLike = [
|
||||||
|
'Your data stays in the EU.',
|
||||||
|
'Mail, files, video, chat — one login.',
|
||||||
|
'No lock-in. Export everything, anytime.',
|
||||||
|
'Built in Denmark, under European law.',
|
||||||
|
'Switch from Microsoft 365 with zero downtime.',
|
||||||
|
]
|
||||||
|
const dontSound = [
|
||||||
|
'Revolutionize your workflow!',
|
||||||
|
'AI-powered, blazingly-fast platform.',
|
||||||
|
'We empower teams to unlock potential.',
|
||||||
|
'Synergy. Holistic. Solutions.',
|
||||||
|
'Let\'s make magic together ✨',
|
||||||
|
]
|
||||||
|
const toneRows: [string, string, string][] = [
|
||||||
|
['Marketing site', 'Confident, direct', 'Your digital workplace. Data that stays in the EU.'],
|
||||||
|
['Product UI', 'Plain, instructional', 'Add a user to get started.'],
|
||||||
|
['Errors', 'Honest, useful', 'Couldn\'t reach the server. We\'re retrying.'],
|
||||||
|
['Empty states', 'Helpful, slightly dry', 'No files yet. Drag one in or invite your team.'],
|
||||||
|
['Docs', 'Direct, practical', 'Point your domain\'s MX record at Dezky.'],
|
||||||
|
]
|
||||||
|
type DoDont = { ok: boolean, label: string, bg: string, fg: string, accent: string, variant?: 'donut' | 'solid' | 'outline', transform?: string }
|
||||||
|
const doDont: DoDont[] = [
|
||||||
|
{ ok: true, label: 'On carbon', bg: C.carbon, fg: C.carbon, accent: C.signal },
|
||||||
|
{ ok: true, label: 'On signal', bg: C.signal, fg: C.signal, accent: C.carbon },
|
||||||
|
{ ok: true, label: 'On bone', bg: C.bone, fg: C.carbon, accent: C.signal },
|
||||||
|
{ ok: true, label: 'Monochrome', bg: C.bone, fg: C.carbon, accent: C.bone },
|
||||||
|
{ ok: false, label: 'No gradients', bg: 'linear-gradient(135deg,#ff4dcb,#7a5aff)', fg: C.carbon, accent: C.signal },
|
||||||
|
{ ok: false, label: 'No rotation', bg: C.bone, fg: C.carbon, accent: C.signal, transform: 'rotate(-14deg)' },
|
||||||
|
{ ok: false, label: 'No stretching', bg: C.bone, fg: C.carbon, accent: C.signal, transform: 'scaleX(1.4)' },
|
||||||
|
{ ok: false, label: 'No off-brand color', bg: C.bone, fg: C.carbon, accent: '#ff4dcb' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :style="{ background: C.paper, color: C.carbon }">
|
||||||
|
<!-- 00 · COVER -->
|
||||||
|
<section :style="{ background: C.carbon, color: C.bone, padding: '80px 80px', minHeight: '86vh', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', position: 'relative', overflow: 'hidden' }">
|
||||||
|
<div :style="{ position: 'absolute', right: '-160px', bottom: '-160px', opacity: 0.06 }">
|
||||||
|
<BrandNodeMark :size="720" :fg="C.carbon" :accent="C.signal" />
|
||||||
|
</div>
|
||||||
|
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', position: 'relative' }">
|
||||||
|
<div :style="{ display: 'flex', alignItems: 'center', gap: '14px' }">
|
||||||
|
<div :style="{ width: '56px', height: '56px', background: C.bone, borderRadius: '14px', display: 'flex', alignItems: 'center', justifyContent: 'center' }">
|
||||||
|
<BrandNodeMark :size="44" :fg="C.carbon" :accent="C.signal" />
|
||||||
|
</div>
|
||||||
|
<div :style="{ fontFamily: mono, fontWeight: 600, fontSize: '18px', letterSpacing: '-0.02em', color: C.bone }">dezky</div>
|
||||||
|
</div>
|
||||||
|
<div :style="{ textAlign: 'right' }">
|
||||||
|
<div :style="eyebrow">Brand Guide · v1.0</div>
|
||||||
|
<div :style="{ fontFamily: mono, fontSize: '11px', marginTop: '8px', color: 'rgba(255,255,255,0.5)' }">June 2026</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :style="{ position: 'relative' }">
|
||||||
|
<div :style="eyebrow">The dezky brand</div>
|
||||||
|
<h1 :style="{ fontFamily: tight, fontWeight: 600, fontSize: 'clamp(64px, 11vw, 144px)', letterSpacing: '-0.045em', lineHeight: 0.9, margin: '32px 0 0', maxWidth: '90%' }">
|
||||||
|
Quiet software,<br>
|
||||||
|
<span :style="{ color: C.signal }">sovereign data.</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', color: 'rgba(255,255,255,0.55)', fontFamily: mono, fontSize: '11px', position: 'relative' }">
|
||||||
|
<div>dezky · brand system</div>
|
||||||
|
<div>00 / 06</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 01 · LOGO -->
|
||||||
|
<section :style="{ ...page, background: C.paper }">
|
||||||
|
<div :style="eyebrow">01 · Logo</div>
|
||||||
|
<h1 :style="h1">The mark</h1>
|
||||||
|
<p :style="pStyle(560, true)">A lowercase <i>d</i> built as a unified letterform — full bowl, rounded ascender, contained in a squircle. The node-dot in the upper-right is the one moving piece of the brand: a signal, a presence.</p>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '24px', marginTop: '64px' }">
|
||||||
|
<div :style="frame(C.carbon, 520)"><BrandNodeMark :size="360" :fg="C.carbon" :accent="C.signal" /></div>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateRows: '1fr 1fr', gap: '24px', height: '520px' }">
|
||||||
|
<div :style="frame(C.signal)"><BrandNodeMark :size="180" :fg="C.signal" :accent="C.carbon" /></div>
|
||||||
|
<div :style="frame(C.bone)"><BrandNodeMark :size="180" :fg="C.carbon" :accent="C.signal" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: '24px', marginTop: '8px' }">
|
||||||
|
<div :style="caption">Primary · on carbon</div>
|
||||||
|
<div :style="caption">Reversed · on signal</div>
|
||||||
|
<div :style="caption">Light · on bone</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 01.1 Anatomy -->
|
||||||
|
<section :style="{ ...page, background: C.bone }">
|
||||||
|
<div :style="eyebrow">01.1 · Anatomy</div>
|
||||||
|
<h2 :style="h2">Constructed, not drawn.</h2>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: '1.3fr 1fr', gap: '64px', alignItems: 'center' }">
|
||||||
|
<div :style="frame(C.paper, 520)">
|
||||||
|
<svg viewBox="0 0 360 360" width="380" height="380">
|
||||||
|
<defs>
|
||||||
|
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="rgba(0,0,0,0.05)" stroke-width="0.5" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="360" height="360" fill="url(#grid)" />
|
||||||
|
<g transform="translate(40, 40) scale(2.8)">
|
||||||
|
<rect x="8" y="8" width="84" height="84" rx="22" :fill="C.carbon" />
|
||||||
|
<g :fill="C.signal">
|
||||||
|
<path d="M 34.425 52 a 14 14 0 1 0 28 0 a 14 14 0 1 0 -28 0 Z M 41.925 52 a 6.5 6.5 0 1 0 13 0 a 6.5 6.5 0 1 0 -13 0 Z" fill-rule="evenodd" />
|
||||||
|
<path d="M 58.575 29.5 a 3.5 3.5 0 0 1 7 0 L 65.575 66 L 58.575 66 Z" />
|
||||||
|
<circle cx="74" cy="26" r="4" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g font-family="'JetBrains Mono', monospace" font-size="9" fill="rgba(0,0,0,0.6)">
|
||||||
|
<line x1="40" y1="240" x2="320" y2="240" stroke="rgba(0,0,0,0.25)" stroke-dasharray="3,3" />
|
||||||
|
<text x="324" y="244">baseline</text>
|
||||||
|
<line x1="40" y1="162" x2="320" y2="162" stroke="rgba(0,0,0,0.25)" stroke-dasharray="3,3" />
|
||||||
|
<text x="324" y="166">x-height</text>
|
||||||
|
<line x1="40" y1="106" x2="320" y2="106" stroke="rgba(0,0,0,0.25)" stroke-dasharray="3,3" />
|
||||||
|
<text x="324" y="110">ascender</text>
|
||||||
|
<text x="100" y="290" :fill="C.carbon" font-weight="500">r 14 · bowl</text>
|
||||||
|
<text x="100" y="304" fill="rgba(0,0,0,0.5)">7w · stem</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 :style="h3Style(0)">Geometry</h3>
|
||||||
|
<table :style="{ width: '100%', borderCollapse: 'collapse', fontFamily: inter, fontSize: '14px' }">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="([k, v], i) in specRows" :key="i" :style="{ borderBottom: `1px solid ${C.fog}` }">
|
||||||
|
<td :style="{ padding: '12px 0', color: 'rgba(0,0,0,0.55)', width: '40%' }">{{ k }}</td>
|
||||||
|
<td :style="{ padding: '12px 0', fontFamily: mono, fontSize: '13px' }">{{ v }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p :style="pStyle(520, true)">Every measurement is a multiple of the stem weight (7u). Don't redraw the mark — use the master SVG.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 01.2 Clear space / min size -->
|
||||||
|
<section :style="{ ...page, background: C.paper }">
|
||||||
|
<div :style="eyebrow">01.2 · Clear space · Minimum size</div>
|
||||||
|
<h2 :style="h2">Give it room.</h2>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '32px' }">
|
||||||
|
<div>
|
||||||
|
<div :style="frame(C.fog, 360)">
|
||||||
|
<div :style="{ position: 'relative' }">
|
||||||
|
<div :style="{ position: 'absolute', inset: '-44px', border: '1px dashed rgba(0,0,0,0.3)', borderRadius: '12px' }" />
|
||||||
|
<BrandNodeMark :size="140" :fg="C.carbon" :accent="C.signal" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :style="caption">Clear space</div>
|
||||||
|
<p :style="pStyle(520, true)">Maintain clear space equal to <b>half the squircle dimension</b> on every side. No type, edges, or other marks intrude.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div :style="frame(C.fog, 360)">
|
||||||
|
<div :style="{ display: 'flex', alignItems: 'flex-end', gap: '48px' }">
|
||||||
|
<div v-for="s in [16, 24, 48, 96]" :key="s" :style="{ textAlign: 'center' }">
|
||||||
|
<BrandNodeMark :size="s" :fg="C.carbon" :accent="C.signal" />
|
||||||
|
<div :style="{ fontFamily: mono, fontSize: '10px', color: 'rgba(0,0,0,0.5)', marginTop: '12px' }">{{ s }}px</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :style="caption">Minimum sizes</div>
|
||||||
|
<p :style="pStyle(520, true)"><b>24 px</b> minimum for digital · <b>16 px</b> only as a favicon (drop the node-dot if it disappears). <b>12 mm</b> minimum for print.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 01.3 Wordmark / lockup -->
|
||||||
|
<section :style="{ ...page, background: C.bone }">
|
||||||
|
<div :style="eyebrow">01.3 · Wordmark · Lockup</div>
|
||||||
|
<h2 :style="h2">Letters set in JetBrains Mono.</h2>
|
||||||
|
<div :style="frame(C.paper, 300)">
|
||||||
|
<div :style="{ fontFamily: mono, fontWeight: 600, fontSize: '140px', letterSpacing: '-0.04em', color: C.carbon, lineHeight: 1 }">dezky</div>
|
||||||
|
</div>
|
||||||
|
<div :style="caption">Wordmark · 100% scale</div>
|
||||||
|
<h3 :style="h3Style()">Horizontal lockup</h3>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }">
|
||||||
|
<div :style="frame(C.paper, 220)"><BrandNodeLockup :scale="1.4" :fg="C.carbon" :accent="C.signal" /></div>
|
||||||
|
<div :style="frame(C.carbon, 220)"><BrandNodeLockup :scale="1.4" :fg="C.carbon" :accent="C.signal" :wordmark="C.bone" /></div>
|
||||||
|
</div>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px', marginTop: '8px' }">
|
||||||
|
<div :style="caption">Light surface</div>
|
||||||
|
<div :style="caption">Dark surface</div>
|
||||||
|
</div>
|
||||||
|
<p :style="pStyle(520, true)">Gap between mark and wordmark is fixed at <b>0.25× the mark height</b>. Wordmark cap-height aligns with the squircle height — never larger.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 01.4 Do / Don't -->
|
||||||
|
<section :style="{ ...page, background: C.paper }">
|
||||||
|
<div :style="eyebrow">01.4 · Do · Don't</div>
|
||||||
|
<h2 :style="h2">How not to use it.</h2>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '20px' }">
|
||||||
|
<div v-for="(row, i) in doDont" :key="i">
|
||||||
|
<div :style="{ background: row.bg, borderRadius: '4px', height: '180px', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', overflow: 'hidden' }">
|
||||||
|
<div :style="{ transform: row.transform || 'none' }">
|
||||||
|
<BrandNodeMark :size="96" :fg="row.fg" :accent="row.accent" :variant="row.variant || 'donut'" />
|
||||||
|
</div>
|
||||||
|
<svg v-if="!row.ok" viewBox="0 0 100 100" preserveAspectRatio="none" :style="{ position: 'absolute', inset: 0, width: '100%', height: '100%' }">
|
||||||
|
<line x1="0" y1="100" x2="100" y2="0" :stroke="C.bad" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div :style="{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '10px' }">
|
||||||
|
<div :style="{ width: '16px', height: '16px', borderRadius: '999px', background: row.ok ? C.ok : C.bad, color: '#fff', fontSize: '10px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700 }">{{ row.ok ? '✓' : '✕' }}</div>
|
||||||
|
<div :style="{ fontSize: '12px', fontWeight: 500 }">{{ row.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 02 · COLOR -->
|
||||||
|
<section :style="{ ...page, background: C.paper }">
|
||||||
|
<div :style="eyebrow">02 · Color</div>
|
||||||
|
<h1 :style="h1">Two colors do the work.</h1>
|
||||||
|
<p :style="pStyle(560, true)">Carbon and Signal carry the brand. Everything else is supporting cast. Signal is loud — reserve it for the mark, the node-dot, and primary calls to action. Never tint, never gradient.</p>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px', marginTop: '80px' }">
|
||||||
|
<div v-for="c in bigSwatches" :key="c.name">
|
||||||
|
<div :style="{ background: c.hex, height: '280px', borderRadius: '4px', display: 'flex', alignItems: 'flex-end', padding: '24px', color: c.dark ? C.bone : C.carbon, boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.05)' }">
|
||||||
|
<div :style="{ fontFamily: tight, fontWeight: 600, fontSize: '36px', letterSpacing: '-0.02em' }">{{ c.name }}</div>
|
||||||
|
</div>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', marginTop: '16px', fontFamily: mono, fontSize: '11px', color: 'rgba(0,0,0,0.65)' }">
|
||||||
|
<div><div :style="{ color: 'rgba(0,0,0,0.4)' }">HEX</div>{{ c.hex }}</div>
|
||||||
|
<div><div :style="{ color: 'rgba(0,0,0,0.4)' }">RGB</div>{{ c.rgb }}</div>
|
||||||
|
<div><div :style="{ color: 'rgba(0,0,0,0.4)' }">ROLE</div>{{ c.role }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 :style="h3Style()">Surfaces & type</h3>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px' }">
|
||||||
|
<div v-for="c in surfaces" :key="c.name">
|
||||||
|
<div :style="{ background: c.hex, height: '140px', borderRadius: '4px', boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.05)' }" />
|
||||||
|
<div :style="{ marginTop: '12px' }">
|
||||||
|
<div :style="{ fontFamily: inter, fontWeight: 600, fontSize: '14px' }">{{ c.name }}</div>
|
||||||
|
<div :style="{ fontFamily: mono, fontSize: '11px', color: 'rgba(0,0,0,0.5)' }">{{ c.hex }}</div>
|
||||||
|
<div :style="{ fontFamily: inter, fontSize: '12px', color: 'rgba(0,0,0,0.55)', marginTop: '4px', lineHeight: 1.45 }">{{ c.role }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 :style="h3Style()">Semantic</h3>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px' }">
|
||||||
|
<div v-for="c in semantic" :key="c.name">
|
||||||
|
<div :style="{ background: c.hex, height: '140px', borderRadius: '4px', boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.05)' }" />
|
||||||
|
<div :style="{ marginTop: '12px' }">
|
||||||
|
<div :style="{ fontFamily: inter, fontWeight: 600, fontSize: '14px' }">{{ c.name }}</div>
|
||||||
|
<div :style="{ fontFamily: mono, fontSize: '11px', color: 'rgba(0,0,0,0.5)' }">{{ c.hex }}</div>
|
||||||
|
<div :style="{ fontFamily: inter, fontSize: '12px', color: 'rgba(0,0,0,0.55)', marginTop: '4px', lineHeight: 1.45 }">{{ c.role }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 02.1 Color in use -->
|
||||||
|
<section :style="{ ...page, background: C.bone }">
|
||||||
|
<div :style="eyebrow">02.1 · Color in use</div>
|
||||||
|
<h2 :style="h2">The 70 · 20 · 10 rule.</h2>
|
||||||
|
<p :style="pStyle(560, true)"><b>70%</b> Bone / Paper · <b>20%</b> Carbon · <b>10%</b> Signal. Surface stays calm; the brand interrupts only at moments of consequence.</p>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: '7fr 2fr 1fr', gap: '4px', height: '80px', marginTop: '32px' }">
|
||||||
|
<div :style="{ background: C.paper, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: mono, fontSize: '11px', color: 'rgba(0,0,0,0.5)' }">70 · paper / bone</div>
|
||||||
|
<div :style="{ background: C.carbon, color: C.bone, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: mono, fontSize: '11px' }">20 · carbon</div>
|
||||||
|
<div :style="{ background: C.signal, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: mono, fontSize: '11px', color: C.carbon }">10</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 03 · TYPOGRAPHY -->
|
||||||
|
<section :style="{ ...page, background: C.paper }">
|
||||||
|
<div :style="eyebrow">03 · Typography</div>
|
||||||
|
<h1 :style="h1">Inter Tight for voice. JetBrains Mono for evidence.</h1>
|
||||||
|
<p :style="pStyle(620, true)">Inter Tight carries the brand's confident, modern register — used for everything from hero copy to subheadings. JetBrains Mono is reserved for the wordmark, labels, code, data, and quantitative details — anywhere the brand wants to feel exact.</p>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '48px', marginTop: '80px' }">
|
||||||
|
<div>
|
||||||
|
<div :style="{ background: C.fog, borderRadius: '4px', padding: '48px 32px', fontFamily: tight, fontWeight: 500, fontSize: '220px', lineHeight: 0.9, letterSpacing: '-0.04em', color: C.carbon }">Aa</div>
|
||||||
|
<div :style="{ marginTop: '24px' }">
|
||||||
|
<div :style="{ fontFamily: tight, fontWeight: 600, fontSize: '24px', letterSpacing: '-0.02em' }">Inter Tight</div>
|
||||||
|
<div :style="{ fontFamily: mono, fontSize: '11px', color: 'rgba(0,0,0,0.5)', marginTop: '4px' }">Display · UI · prose · 400 · 500 · 600 · 700</div>
|
||||||
|
<div :style="{ fontFamily: tight, fontSize: '15px', marginTop: '16px', color: 'rgba(0,0,0,0.75)', lineHeight: 1.5 }">The quick brown fox jumps over the lazy dog. 0123456789</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div :style="{ background: C.fog, borderRadius: '4px', padding: '48px 32px', fontFamily: mono, fontWeight: 500, fontSize: '220px', lineHeight: 0.9, letterSpacing: '-0.04em', color: C.carbon }">Aa</div>
|
||||||
|
<div :style="{ marginTop: '24px' }">
|
||||||
|
<div :style="{ fontFamily: tight, fontWeight: 600, fontSize: '24px', letterSpacing: '-0.02em' }">JetBrains Mono</div>
|
||||||
|
<div :style="{ fontFamily: mono, fontSize: '11px', color: 'rgba(0,0,0,0.5)', marginTop: '4px' }">Wordmark · code · labels · 400 · 500 · 600</div>
|
||||||
|
<div :style="{ fontFamily: mono, fontSize: '15px', marginTop: '16px', color: 'rgba(0,0,0,0.75)', lineHeight: 1.5 }">data.stays_in_eu() // 0123456789</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 03.1 Scale -->
|
||||||
|
<section :style="{ ...page, background: C.bone }">
|
||||||
|
<div :style="eyebrow">03.1 · Scale</div>
|
||||||
|
<h2 :style="h2">One scale, ratio 1.25.</h2>
|
||||||
|
<div :style="{ display: 'flex', flexDirection: 'column', gap: '18px' }">
|
||||||
|
<div v-for="r in typeScale" :key="r.token" :style="{ display: 'grid', gridTemplateColumns: '120px 80px 1fr 1fr', gap: '24px', alignItems: 'baseline', borderBottom: `1px solid ${C.fog}`, paddingBottom: '14px' }">
|
||||||
|
<div :style="{ fontFamily: mono, fontSize: '11px', color: 'rgba(0,0,0,0.5)' }">{{ r.token }}</div>
|
||||||
|
<div :style="{ fontFamily: mono, fontSize: '11px', color: 'rgba(0,0,0,0.5)' }">{{ r.px }} / {{ Math.round(r.px * 1.25) }}</div>
|
||||||
|
<div :style="{ fontFamily: tight, fontWeight: r.weight, fontSize: `${Math.min(r.px, 40)}px`, letterSpacing: r.px > 40 ? '-0.025em' : '-0.01em', lineHeight: 1 }">dezky</div>
|
||||||
|
<div :style="{ fontFamily: inter, fontSize: '13px', color: 'rgba(0,0,0,0.55)' }">{{ r.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 04 · VOICE -->
|
||||||
|
<section :style="{ ...page, background: C.paper }">
|
||||||
|
<div :style="eyebrow">04 · Voice</div>
|
||||||
|
<h1 :style="h1">Direct. Lowercase. Earned.</h1>
|
||||||
|
<p :style="pStyle(620, true)">dezky doesn't shout — it ships. Sentences are short, verbs do the work, and we talk about sovereignty in plain terms. No exclamation points, no emoji, no AI metaphors, no fear-mongering.</p>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '32px', marginTop: '80px' }">
|
||||||
|
<div>
|
||||||
|
<div :style="{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '20px' }">
|
||||||
|
<div :style="{ width: '18px', height: '18px', borderRadius: '999px', background: C.ok, color: '#fff', fontSize: '11px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700 }">✓</div>
|
||||||
|
<div :style="{ fontFamily: tight, fontWeight: 600, fontSize: '16px' }">We sound like</div>
|
||||||
|
</div>
|
||||||
|
<div :style="{ display: 'flex', flexDirection: 'column', gap: '14px' }">
|
||||||
|
<div v-for="(s, i) in soundLike" :key="i" :style="{ padding: '14px 18px', background: 'rgba(31,138,91,0.06)', borderLeft: `2px solid ${C.ok}`, fontFamily: tight, fontSize: '16px', color: C.carbon, lineHeight: 1.4 }">{{ s }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div :style="{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '20px' }">
|
||||||
|
<div :style="{ width: '18px', height: '18px', borderRadius: '999px', background: C.bad, color: '#fff', fontSize: '11px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700 }">✕</div>
|
||||||
|
<div :style="{ fontFamily: tight, fontWeight: 600, fontSize: '16px' }">We don't sound like</div>
|
||||||
|
</div>
|
||||||
|
<div :style="{ display: 'flex', flexDirection: 'column', gap: '14px' }">
|
||||||
|
<div v-for="(s, i) in dontSound" :key="i" :style="{ padding: '14px 18px', background: 'rgba(226,48,48,0.05)', borderLeft: `2px solid ${C.bad}`, fontFamily: tight, fontSize: '16px', color: C.carbon, lineHeight: 1.4 }">{{ s }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 :style="h3Style()">Tone shifts by surface</h3>
|
||||||
|
<table :style="{ width: '100%', borderCollapse: 'collapse', fontFamily: inter }">
|
||||||
|
<thead>
|
||||||
|
<tr :style="{ borderBottom: `1px solid ${C.carbon}` }">
|
||||||
|
<th v-for="hh in ['Surface', 'Tone', 'Example']" :key="hh" :style="{ textAlign: 'left', padding: '12px 0', fontSize: '11px', fontFamily: mono, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(0,0,0,0.55)' }">{{ hh }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody :style="{ fontSize: '14px', color: 'rgba(0,0,0,0.75)' }">
|
||||||
|
<tr v-for="(row, i) in toneRows" :key="i" :style="{ borderBottom: `1px solid ${C.fog}` }">
|
||||||
|
<td :style="{ padding: '14px 0', width: '20%' }">{{ row[0] }}</td>
|
||||||
|
<td :style="{ padding: '14px 0', width: '30%' }">{{ row[1] }}</td>
|
||||||
|
<td :style="{ padding: '14px 0', fontFamily: inter, fontSize: '14px' }">{{ row[2] }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 05 · APPLICATIONS -->
|
||||||
|
<section :style="{ ...page, background: C.bone }">
|
||||||
|
<div :style="eyebrow">05 · Applications</div>
|
||||||
|
<h1 :style="h1">In the world.</h1>
|
||||||
|
<p :style="pStyle(560, true)">Reference renders across the surfaces dezky lives on. Treat them as the canonical reductions of the system.</p>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px', marginTop: '64px' }">
|
||||||
|
<div :style="frame(C.carbon, 420)">
|
||||||
|
<div :style="{ width: '220px', height: '220px', background: C.carbon, borderRadius: '50px', boxShadow: '0 24px 80px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.04)', display: 'flex', alignItems: 'center', justifyContent: 'center' }">
|
||||||
|
<BrandNodeMark :size="170" :fg="C.carbon" :accent="C.signal" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :style="frame(C.paper, 420)">
|
||||||
|
<div :style="{ display: 'flex', alignItems: 'center', gap: '48px' }">
|
||||||
|
<div v-for="s in [64, 32, 16]" :key="s" :style="{ textAlign: 'center' }">
|
||||||
|
<BrandNodeMark :size="s" :fg="C.carbon" :accent="C.signal" />
|
||||||
|
<div :style="{ fontFamily: mono, fontSize: '10px', color: 'rgba(0,0,0,0.5)', marginTop: '14px' }">{{ s }} × {{ s }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px', marginTop: '8px' }">
|
||||||
|
<div :style="caption">iOS app icon · 1024 master</div>
|
||||||
|
<div :style="caption">Favicon set</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 05.1 Web -->
|
||||||
|
<section :style="{ ...page, background: C.bone }">
|
||||||
|
<div :style="eyebrow">05.1 · Web</div>
|
||||||
|
<h2 :style="h2">Marketing hero.</h2>
|
||||||
|
<div :style="{ background: C.carbon, borderRadius: '8px', padding: '64px 56px', color: C.bone, boxShadow: '0 24px 80px rgba(0,0,0,0.2)' }">
|
||||||
|
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '80px' }">
|
||||||
|
<BrandNodeLockup :scale="0.7" :fg="C.carbon" :accent="C.signal" :wordmark="C.bone" />
|
||||||
|
<div :style="{ display: 'flex', gap: '28px', alignItems: 'center', fontFamily: mono, fontSize: '12px', color: 'rgba(255,255,255,0.65)' }">
|
||||||
|
<span>product</span><span>security</span><span>pricing</span><span>log in</span>
|
||||||
|
<span :style="{ background: C.signal, color: C.carbon, padding: '6px 14px', borderRadius: '4px', fontWeight: 600 }">book a demo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :style="{ fontFamily: mono, fontSize: '12px', color: C.signal, letterSpacing: '0.08em' }">// sovereign productivity · v1.0</div>
|
||||||
|
<h1 :style="{ fontFamily: tight, fontWeight: 600, fontSize: '92px', letterSpacing: '-0.035em', lineHeight: 0.95, margin: '24px 0', maxWidth: '1000px' }">
|
||||||
|
Your digital workplace.<br>
|
||||||
|
<span :style="{ color: C.signal }">Data that stays in the EU.</span>
|
||||||
|
</h1>
|
||||||
|
<p :style="{ fontFamily: inter, fontSize: '20px', color: 'rgba(255,255,255,0.7)', maxWidth: '620px', lineHeight: 1.5 }">Mail, files, video, chat and SSO — fully integrated, EU-hosted, no lock-in.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 05.3 Social -->
|
||||||
|
<section :style="{ ...page, background: C.paper }">
|
||||||
|
<div :style="eyebrow">05.2 · Social</div>
|
||||||
|
<h2 :style="h2">Avatars & headers.</h2>
|
||||||
|
<div :style="{ display: 'grid', gridTemplateColumns: '180px 1fr', gap: '24px', alignItems: 'start' }">
|
||||||
|
<div>
|
||||||
|
<div :style="{ width: '180px', height: '180px', borderRadius: '50%', background: C.carbon, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.05)' }">
|
||||||
|
<BrandNodeMark :size="130" :fg="C.carbon" :accent="C.signal" />
|
||||||
|
</div>
|
||||||
|
<div :style="caption">Avatar · circular crop</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div :style="{ height: '320px', background: C.signal, borderRadius: '8px', position: 'relative', overflow: 'hidden', display: 'flex', alignItems: 'center', padding: '0 64px' }">
|
||||||
|
<div :style="{ fontFamily: tight, fontWeight: 600, fontSize: '88px', letterSpacing: '-0.035em', lineHeight: 0.95, color: C.carbon, maxWidth: '60%' }">your data<br>stays home.</div>
|
||||||
|
<div :style="{ position: 'absolute', right: '64px', top: '50%', transform: 'translateY(-50%)' }">
|
||||||
|
<BrandNodeMark :size="220" :fg="C.signal" :accent="C.carbon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :style="caption">Header · 1500 × 500</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Closing -->
|
||||||
|
<section :style="{ background: C.carbon, color: C.bone, padding: '120px 80px', minHeight: '60vh', display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }">
|
||||||
|
<div>
|
||||||
|
<div :style="eyebrow">End</div>
|
||||||
|
<h1 :style="{ fontFamily: tight, fontWeight: 600, fontSize: '96px', letterSpacing: '-0.04em', lineHeight: 0.95, margin: '24px 0 0', maxWidth: '900px' }">
|
||||||
|
Use it well.<br>
|
||||||
|
<span :style="{ color: C.signal }">Don't redraw it.</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div :style="{ display: 'flex', justifyContent: 'flex-end', alignItems: 'flex-end', fontFamily: mono, fontSize: '11px', color: 'rgba(255,255,255,0.55)', marginTop: '64px' }">
|
||||||
|
<div>dezky · brand system · v1.0</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||||
|
import { C } from '~/utils/landingTokens'
|
||||||
|
|
||||||
|
definePageMeta({ layout: 'page' })
|
||||||
|
|
||||||
|
const t = useTheme()
|
||||||
|
const copy = useCopy()
|
||||||
|
const c = computed(() => copy.value.pages.dpa)
|
||||||
|
|
||||||
|
const headingStyle = computed(() => ({
|
||||||
|
fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '22px',
|
||||||
|
letterSpacing: '-0.015em', color: t.value.fg, margin: '0 0 14px',
|
||||||
|
}))
|
||||||
|
|
||||||
|
useHead({ title: () => `${copy.value.pages.dpa.title} · dezky` })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LandingPageHeader :label="c.label" :title="c.title" :intro="c.intro" />
|
||||||
|
|
||||||
|
<LandingContainer pad="48px 64px 160px">
|
||||||
|
<div :style="{ maxWidth: '760px' }">
|
||||||
|
<!-- Draft / legal-review banner -->
|
||||||
|
<div :style="{ padding: '18px 22px', borderRadius: '4px', border: `1px solid ${C.warn}55`, background: `${C.warn}14`, fontFamily: '\'Inter\', sans-serif', fontSize: '14px', lineHeight: 1.55, color: t.fg }">
|
||||||
|
{{ c.draftNote }}
|
||||||
|
</div>
|
||||||
|
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgDim, margin: '14px 0 48px' }">{{ c.updated }}</div>
|
||||||
|
|
||||||
|
<!-- Clauses -->
|
||||||
|
<section v-for="(s, i) in c.sections" :key="i" :style="{ marginBottom: '36px' }">
|
||||||
|
<h2 :style="headingStyle">{{ s.h }}</h2>
|
||||||
|
<p
|
||||||
|
v-for="(para, j) in s.p" :key="j"
|
||||||
|
:style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.65, color: t.fgMuted, margin: '0 0 12px', textWrap: 'pretty' }"
|
||||||
|
>{{ para }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Sub-processors -->
|
||||||
|
<h2 :style="headingStyle">{{ c.subprocessorsHeading }}</h2>
|
||||||
|
<div :style="{ border: `1px solid ${t.border}`, borderRadius: '4px', overflow: 'hidden', margin: '0 0 40px' }">
|
||||||
|
<div
|
||||||
|
v-for="(row, i) in c.subprocessors" :key="i"
|
||||||
|
:style="{ display: 'grid', gridTemplateColumns: '1.2fr 1.5fr 1fr', gap: '16px', padding: '16px 20px', borderTop: i === 0 ? 'none' : `1px solid ${t.border}`, background: t.surface, fontFamily: '\'Inter\', sans-serif', fontSize: '14px', alignItems: 'baseline' }"
|
||||||
|
>
|
||||||
|
<span :style="{ color: t.fg, fontWeight: 600 }">{{ row[0] }}</span>
|
||||||
|
<span :style="{ color: t.fgMuted }">{{ row[1] }}</span>
|
||||||
|
<span :style="{ color: t.fgMuted, fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px' }">{{ row[2] }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Technical & organisational measures -->
|
||||||
|
<h2 :style="headingStyle">{{ c.tomsHeading }}</h2>
|
||||||
|
<ul :style="{ margin: '0 0 40px', padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '10px' }">
|
||||||
|
<li
|
||||||
|
v-for="(m, i) in c.toms" :key="i"
|
||||||
|
:style="{ display: 'flex', gap: '12px', fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.5, color: t.fg }"
|
||||||
|
>
|
||||||
|
<span :style="{ color: t.signal, flexShrink: 0, fontWeight: 700 }">✓</span>
|
||||||
|
<span>{{ m }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Contact -->
|
||||||
|
<h2 :style="headingStyle">{{ c.contactHeading }}</h2>
|
||||||
|
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.6, color: t.fgMuted, margin: 0 }">{{ c.contactBody }}</p>
|
||||||
|
</div>
|
||||||
|
</LandingContainer>
|
||||||
|
</template>
|
||||||
@@ -3,14 +3,23 @@
|
|||||||
// (Landing Page.html → landing-app.jsx + landing-sections.jsx). Light theme,
|
// (Landing Page.html → landing-app.jsx + landing-sections.jsx). Light theme,
|
||||||
// Danish default, hero variant A — the production defaults the user landed on.
|
// Danish default, hero variant A — the production defaults the user landed on.
|
||||||
// Section order matches the design exactly.
|
// 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 t = useTheme()
|
||||||
const copy = useCopy()
|
const copy = useCopy()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const description = computed(() => copy.value.hero.sub)
|
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({
|
useHead({
|
||||||
title: 'dezky · suveræn produktivitet',
|
title: 'dezky · suveræn produktivitet',
|
||||||
htmlAttrs: { lang },
|
htmlAttrs: { lang },
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||||
|
import { C } from '~/utils/landingTokens'
|
||||||
|
|
||||||
|
definePageMeta({ layout: 'page' })
|
||||||
|
|
||||||
|
const t = useTheme()
|
||||||
|
const copy = useCopy()
|
||||||
|
const c = computed(() => copy.value.pages.privacy)
|
||||||
|
|
||||||
|
const headingStyle = computed(() => ({
|
||||||
|
fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '22px',
|
||||||
|
letterSpacing: '-0.015em', color: t.value.fg, margin: '0 0 14px',
|
||||||
|
}))
|
||||||
|
|
||||||
|
useHead({ title: () => `${copy.value.pages.privacy.title} · dezky` })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LandingPageHeader :label="c.label" :title="c.title" :intro="c.intro" />
|
||||||
|
|
||||||
|
<LandingContainer pad="48px 64px 160px">
|
||||||
|
<div :style="{ maxWidth: '760px' }">
|
||||||
|
<!-- Draft / legal-review banner -->
|
||||||
|
<div :style="{ padding: '18px 22px', borderRadius: '4px', border: `1px solid ${C.warn}55`, background: `${C.warn}14`, fontFamily: '\'Inter\', sans-serif', fontSize: '14px', lineHeight: 1.55, color: t.fg }">
|
||||||
|
{{ c.draftNote }}
|
||||||
|
</div>
|
||||||
|
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgDim, margin: '14px 0 48px' }">{{ c.updated }}</div>
|
||||||
|
|
||||||
|
<!-- Clauses -->
|
||||||
|
<section v-for="(s, i) in c.sections" :key="i" :style="{ marginBottom: '36px' }">
|
||||||
|
<h2 :style="headingStyle">{{ s.h }}</h2>
|
||||||
|
<p
|
||||||
|
v-for="(para, j) in s.p" :key="j"
|
||||||
|
:style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.65, color: t.fgMuted, margin: '0 0 12px', textWrap: 'pretty' }"
|
||||||
|
>{{ para }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recipients -->
|
||||||
|
<h2 :style="headingStyle">{{ c.recipientsHeading }}</h2>
|
||||||
|
<div :style="{ border: `1px solid ${t.border}`, borderRadius: '4px', overflow: 'hidden', margin: '0 0 40px' }">
|
||||||
|
<div
|
||||||
|
v-for="(row, i) in c.recipients" :key="i"
|
||||||
|
:style="{ display: 'grid', gridTemplateColumns: '1.2fr 1.5fr 1fr', gap: '16px', padding: '16px 20px', borderTop: i === 0 ? 'none' : `1px solid ${t.border}`, background: t.surface, fontFamily: '\'Inter\', sans-serif', fontSize: '14px', alignItems: 'baseline' }"
|
||||||
|
>
|
||||||
|
<span :style="{ color: t.fg, fontWeight: 600 }">{{ row[0] }}</span>
|
||||||
|
<span :style="{ color: t.fgMuted }">{{ row[1] }}</span>
|
||||||
|
<span :style="{ color: t.fgMuted, fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px' }">{{ row[2] }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rights -->
|
||||||
|
<h2 :style="headingStyle">{{ c.rightsHeading }}</h2>
|
||||||
|
<ul :style="{ margin: '0 0 40px', padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '10px' }">
|
||||||
|
<li
|
||||||
|
v-for="(r, i) in c.rights" :key="i"
|
||||||
|
:style="{ display: 'flex', gap: '12px', fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.5, color: t.fg }"
|
||||||
|
>
|
||||||
|
<span :style="{ color: t.signal, flexShrink: 0, fontWeight: 700 }">✓</span>
|
||||||
|
<span>{{ r }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Contact -->
|
||||||
|
<h2 :style="headingStyle">{{ c.contactHeading }}</h2>
|
||||||
|
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.6, color: t.fgMuted, margin: 0 }">{{ c.contactBody }}</p>
|
||||||
|
</div>
|
||||||
|
</LandingContainer>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -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' },
|
nav: { product: 'produkt', security: 'sikkerhed', whitelabel: 'whitelabel', pricing: 'priser', docs: 'docs', login: 'log ind', cta: 'book en demo' },
|
||||||
hero: {
|
hero: {
|
||||||
eyebrow: '// suveræn produktivitet · v1.0',
|
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[],
|
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.',
|
sub: 'Mail, filer, video, chat og login — fuldt integreret, hostet i EU, uden lock-in. Bygget på licensren open source.',
|
||||||
cta: 'Book en demo',
|
cta: 'Book en demo',
|
||||||
@@ -26,12 +26,13 @@ export const COPY = {
|
|||||||
label: '02 — suiten',
|
label: '02 — suiten',
|
||||||
heading: 'Alt det du forventer. Intet du ikke vil have.',
|
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.',
|
lede: 'Fem moduler. Ét login. Bygget til at virke sammen — ikke bare leve i samme browser.',
|
||||||
|
soonLabel: 'kommer snart',
|
||||||
cards: [
|
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: '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.' },
|
{ 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.' },
|
{ 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.' },
|
{ 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.' },
|
{ 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: {
|
how: {
|
||||||
@@ -77,21 +78,26 @@ export const COPY = {
|
|||||||
bullets: [
|
bullets: [
|
||||||
'Fuldt whitelabel-tema · CSS og logo',
|
'Fuldt whitelabel-tema · CSS og logo',
|
||||||
'Multi-tenant administration',
|
'Multi-tenant administration',
|
||||||
'Marginer på 30–45 % afhængigt af volumen',
|
'Marginer på 15–40 % afhængigt af volumen',
|
||||||
'Co-marketing og kundeleads via partnernetværk',
|
'Co-marketing og kundeleads via partnernetværk',
|
||||||
],
|
],
|
||||||
cta: 'Se partnerprogrammet',
|
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: {
|
stack: {
|
||||||
label: '07 — under motorhjelmen',
|
label: '07 — åbne standarder',
|
||||||
heading: 'Bygget på open source. Verificerbart.',
|
heading: 'Bygget på åbne standarder. Ingen lock-in.',
|
||||||
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.',
|
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: [
|
rows: [
|
||||||
['Mail', 'Stalwart Mail', 'AGPL-3.0', 'stalw.art'],
|
['Mail & kalender', 'IMAP · SMTP · CalDAV · CardDAV', 'Eksportér til .mbox og .ics'],
|
||||||
['Filer & drev', 'ownCloud Infinite Scale', 'Apache 2.0', 'owncloud.dev'],
|
['Filer & drev', 'WebDAV · S3', 'Hent alle filer — intet format-lock'],
|
||||||
['Videomøder', 'Jitsi', 'Apache 2.0', 'jitsi.org'],
|
['Videomøder', 'WebRTC · SIP', 'Åben browser-standard, ingen klient'],
|
||||||
['Team chat', 'Zulip', 'Apache 2.0', 'zulip.com'],
|
['Team chat', 'Åben eksport-API', 'Tag hele historikken med'],
|
||||||
['Identitet & SSO', 'Authentik', 'MIT', 'goauthentik.io'],
|
['Identitet & SSO', 'OIDC · SAML · SCIM', 'Kobl til din egen IdP'],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
pricing: {
|
pricing: {
|
||||||
@@ -99,7 +105,7 @@ export const COPY = {
|
|||||||
heading: 'Forudsigelig pris. Ingen overraskelser.',
|
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.',
|
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',
|
teaser: 'Starter fra',
|
||||||
price: '69',
|
price: '49',
|
||||||
unit: 'DKK / bruger / md.',
|
unit: 'DKK / bruger / md.',
|
||||||
note: 'Endelig prissætning bekræftes ved demo. Volumenrabat fra 25 brugere.',
|
note: 'Endelig prissætning bekræftes ved demo. Volumenrabat fra 25 brugere.',
|
||||||
cta: 'Book en demo for priser',
|
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.'],
|
['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.'],
|
['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 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.'],
|
['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,217 @@ export const COPY = {
|
|||||||
sub: '30 minutters demo. Ingen salgspres. Ingen slides.',
|
sub: '30 minutters demo. Ingen salgspres. Ingen slides.',
|
||||||
cta: 'Book en demo',
|
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',
|
||||||
|
},
|
||||||
|
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 2–4 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.'],
|
||||||
|
['15–40 % 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', '5–15 %', '15–40 %'],
|
||||||
|
['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: 'Skalér 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.'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
label: 'privatlivspolitik',
|
||||||
|
title: 'Privatlivspolitik',
|
||||||
|
intro: 'Denne privatlivspolitik beskriver, hvordan Dezky ApS som dataansvarlig indsamler og behandler personoplysninger om dig — som besøgende på dezky.eu, ved henvendelser og som kunde.',
|
||||||
|
updated: 'Senest opdateret: 5. juni 2026 · version 0.1 (udkast)',
|
||||||
|
draftNote: 'Udkast under juridisk gennemgang — kontakt privacy@dezky.eu, hvis du har brug for den gældende version.',
|
||||||
|
sections: [
|
||||||
|
{ h: '1. Dataansvarlig', p: ['Dezky ApS (CVR 43 14 18 21, Åtoften 33, 6710 Esbjerg V) er dataansvarlig for de personoplysninger, vi behandler om dig. Spørgsmål om databeskyttelse: privacy@dezky.eu.'] },
|
||||||
|
{ h: '2. Hvilke oplysninger vi indsamler', p: ['Henvendelser og demo: når du kontakter os eller booker en demo, indsamler vi de oplysninger, du giver os — typisk navn, e-mail, virksomhed og din besked.', 'Kunde og fakturering: for kunder behandler vi konto- og kontaktoplysninger samt fakturerings- og betalingsoplysninger.', 'Brug af websitet: vi bruger Umami, et privatlivsvenligt analyseværktøj, der er cookiefrit og kun indsamler aggregeret, anonym statistik — ingen personoplysninger og ingen sporing på tværs af sites.', 'Tekniske logs: vores servere logger tekniske oplysninger (fx IP-adresse) i kort tid af hensyn til drift og sikkerhed.'] },
|
||||||
|
{ h: '3. Formål og retsgrundlag', p: ['At besvare henvendelser og levere demoer — vores legitime interesse og forberedelse af en aftale (GDPR art. 6, stk. 1, litra b og f).', 'At levere, drifte og fakturere tjenesten — opfyldelse af aftale (litra b).', 'At sikre drift og forebygge misbrug — legitim interesse (litra f).', 'At overholde lovkrav, herunder bogføring — retlig forpligtelse (litra c).'] },
|
||||||
|
{ h: '4. Cookies og analyse', p: ['Vi sætter kun strengt nødvendige cookies (fx til at huske dit sprogvalg). Vores analyse via Umami er cookiefri og kræver derfor ikke samtykke. Vi bruger ikke reklame- eller tredjeparts-sporing, og vi sender kun transaktionelle e-mails — ingen markedsføring uden dit samtykke.'] },
|
||||||
|
{ h: '5. Opbevaring', p: ['Vi opbevarer kun oplysninger, så længe det er nødvendigt. Henvendelser slettes, når de er afsluttet, medmindre de bliver til et kundeforhold. Regnskabsmateriale opbevares i 5 år jf. bogføringsloven. Tekniske logs opbevares i kort tid.'] },
|
||||||
|
{ h: '6. Sikkerhed og placering', p: ['Dine oplysninger hostes i EU og forlader ikke EU/EØS. De beskyttes med kryptering (AES-256 i hvile, TLS 1.3 i transit), adgangsstyring og løbende overvågning.'] },
|
||||||
|
{ h: '7. Ændringer', p: ['Vi kan opdatere denne politik. Væsentlige ændringer varsles på denne side med ny dato.'] },
|
||||||
|
],
|
||||||
|
recipientsHeading: 'Hvem vi deler med',
|
||||||
|
recipients: [
|
||||||
|
['Hetzner Online GmbH', 'Hosting og infrastruktur', 'Tyskland (EU)'],
|
||||||
|
['Stripe Payments Europe, Ltd.', 'Betaling og fakturering', 'Irland (EU)'],
|
||||||
|
],
|
||||||
|
rightsHeading: 'Dine rettigheder',
|
||||||
|
rights: [
|
||||||
|
'Indsigt i de oplysninger, vi behandler om dig',
|
||||||
|
'Berigtigelse af forkerte oplysninger',
|
||||||
|
'Sletning ("retten til at blive glemt")',
|
||||||
|
'Begrænsning af behandlingen',
|
||||||
|
'Dataportabilitet',
|
||||||
|
'Indsigelse mod behandlingen',
|
||||||
|
'Klage til Datatilsynet (datatilsynet.dk)',
|
||||||
|
],
|
||||||
|
contactHeading: 'Kontakt',
|
||||||
|
contactBody: 'Vil du udøve dine rettigheder eller har du spørgsmål, så skriv til privacy@dezky.eu. Du kan også klage til Datatilsynet.',
|
||||||
|
},
|
||||||
|
dpa: {
|
||||||
|
label: 'databehandleraftale',
|
||||||
|
title: 'Databehandleraftale',
|
||||||
|
intro: 'Denne databehandleraftale (DPA) regulerer, hvordan Dezky ApS behandler personoplysninger på vegne af kunden ved brug af Dezky-platformen. Den er et bilag til hovedaftalen mellem parterne.',
|
||||||
|
updated: 'Senest opdateret: 5. juni 2026 · version 0.1 (udkast)',
|
||||||
|
draftNote: 'Udkast under juridisk gennemgang — endnu ikke en endelig, underskrevet aftale. Kontakt privacy@dezky.eu for den gældende, underskrevne version.',
|
||||||
|
sections: [
|
||||||
|
{ h: '1. Parter og roller', p: ['Kunden er dataansvarlig, og Dezky ApS (CVR 43 14 18 21, Åtoften 33, 6710 Esbjerg V) er databehandler.', 'Dezky behandler kun personoplysninger efter kundens dokumenterede instruks — herunder denne aftale og kundens brug af platformen — og underretter kunden, hvis en instruks efter Dezkys vurdering strider mod gældende databeskyttelseslovgivning.'] },
|
||||||
|
{ h: '2. Genstand, varighed og formål', p: ['Genstanden er de personoplysninger, kunden og kundens brugere lægger ind i modulerne (mail, kalender, kontakter, filer, video, chat og identitet/SSO).', 'Formålet er at levere og drifte platformen. Behandlingen varer, så længe hovedaftalen er i kraft, hvorefter oplysningerne slettes eller returneres, jf. punkt 9.'] },
|
||||||
|
{ h: '3. Kategorier af registrerede og oplysninger', p: ['Registrerede: kundens medarbejdere, kontakter og øvrige personer, hvis oplysninger kunden vælger at behandle i platformen.', 'Oplysninger: navne, e-mailadresser, kontaktoplysninger, kalender- og mødedata, filindhold, beskeder samt login- og brugeradministrationsdata. Kunden bestemmer selv, hvilke oplysninger der lægges i platformen.'] },
|
||||||
|
{ h: '4. Dezkys forpligtelser', p: ['Fortrolighed: alle med adgang til personoplysninger er underlagt tavshedspligt.', 'Sikkerhed: Dezky gennemfører passende tekniske og organisatoriske foranstaltninger jf. GDPR art. 32 (se nedenfor).', 'Bistand: Dezky hjælper kunden med anmodninger fra registrerede samt med art. 32–36 (sikkerhed, brud og konsekvensanalyser).'] },
|
||||||
|
{ h: '5. Underdatabehandlere', p: ['Kunden giver Dezky generel tilladelse til at anvende underdatabehandlere; de aktuelle fremgår af listen nedenfor.', 'Dezky pålægger underdatabehandlere de samme forpligtelser som i denne aftale og varsler kunden i rimelig tid ved ændringer, så kunden kan gøre indsigelse.'] },
|
||||||
|
{ h: '6. Overførsel til tredjelande', p: ['Indhold og driftsdata hostes i EU (Tyskland) og overføres ikke uden for EU/EØS. Dezky har ingen amerikansk moder eller datterselskab.', 'Betalingsbehandling sker via en EU-baseret underdatabehandler. Sker overførsel undtagelsesvis til et tredjeland, kræver det et gyldigt grundlag jf. GDPR kapitel V.'] },
|
||||||
|
{ h: '7. Brud på persondatasikkerheden', p: ['Dezky underretter kunden uden unødig forsinkelse og senest 72 timer efter at være blevet bekendt med et brud, med de oplysninger kunden skal bruge for at overholde sine egne pligter.'] },
|
||||||
|
{ h: '8. Revision og tilsyn', p: ['Dezky stiller dokumentation til rådighed for at påvise overholdelse, herunder relevante certificeringer og audit-logs, og giver mulighed for tilsyn efter rimeligt varsel og uden unødig forstyrrelse af driften.'] },
|
||||||
|
{ h: '9. Sletning og tilbagelevering', p: ['Ved ophør sletter eller returnerer Dezky efter kundens valg alle personoplysninger og sletter eksisterende kopier, medmindre lovgivning kræver fortsat opbevaring. Kunden kan til enhver tid eksportere sine data via åbne standarder.'] },
|
||||||
|
{ h: '10. Lovvalg og værneting', p: ['Aftalen er underlagt dansk ret, og tvister afgøres ved de danske domstole.'] },
|
||||||
|
],
|
||||||
|
subprocessorsHeading: 'Underdatabehandlere',
|
||||||
|
subprocessors: [
|
||||||
|
['Hetzner Online GmbH', 'Hosting, objektlagring og backup', 'Tyskland (EU)'],
|
||||||
|
['Stripe Payments Europe, Ltd.', 'Betalings- og faktureringsdata', 'Irland (EU)'],
|
||||||
|
],
|
||||||
|
tomsHeading: 'Tekniske og organisatoriske foranstaltninger',
|
||||||
|
toms: [
|
||||||
|
'Kryptering: AES-256 i hvile, TLS 1.3 i transit',
|
||||||
|
'Adgangsstyring med single sign-on og multifaktor',
|
||||||
|
'ISO 27001-certificeret driftsoperatør, Tier III-datacentre',
|
||||||
|
'Audit-log med 13 måneders opbevaring',
|
||||||
|
'Redundante, krypterede backups',
|
||||||
|
'Kundekontrollerede krypteringsnøgler (BYOK) på Enterprise',
|
||||||
|
'Mindste privilegium og adskilte miljøer',
|
||||||
|
],
|
||||||
|
contactHeading: 'Kontakt',
|
||||||
|
contactBody: 'Spørgsmål om databehandling eller anmodning om den underskrevne aftale: privacy@dezky.eu',
|
||||||
|
},
|
||||||
|
stubs: {
|
||||||
|
customers: 'Kunder',
|
||||||
|
careers: 'Karriere',
|
||||||
|
press: 'Presse',
|
||||||
|
status: 'Systemstatus',
|
||||||
|
docs: 'Dokumentation',
|
||||||
|
blog: 'Blog',
|
||||||
|
terms: 'Vilkår',
|
||||||
|
sla: 'SLA',
|
||||||
|
cookies: 'Cookiepolitik',
|
||||||
|
},
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
tagline: 'Suveræn produktivitet til danske virksomheder.',
|
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: [
|
cols: [
|
||||||
['Produkt', [['Funktioner', '#suite'], ['Sikkerhed', '#sovereignty'], ['Roadmap', '#'], ['Status', '#'], ['Changelog', '#']]],
|
['Produkt', [['Funktioner', '/#suite'], ['Sikkerhed', '/#sovereignty'], ['Roadmap', '/roadmap'], ['Status', '/status'], ['Changelog', '/changelog']]],
|
||||||
['Selskab', [['Om os', '#'], ['Kunder', '#'], ['Karriere', '#'], ['Presse', '#'], ['Kontakt', '#']]],
|
['Selskab', [['Om os', '/about'], ['Kunder', '/customers'], ['Karriere', '/careers'], ['Presse', '/press'], ['Kontakt', '/contact']]],
|
||||||
['Ressourcer', [['Docs', '#'], ['Migrationsguide', '#'], ['Partnere', '#whitelabel'], ['Blog', '#'], ['Brand', '#']]],
|
['Ressourcer', [['Docs', '/docs'], ['Migrationsguide', '/migration'], ['Partnere', '/#whitelabel'], ['Blog', '/blog'], ['Brand', '/brand']]],
|
||||||
['Juridisk', [['Privatlivspolitik', '#'], ['Databehandler', '#'], ['Vilkår', '#'], ['SLA', '#'], ['Cookies', '#']]],
|
['Juridisk', [['Privatlivspolitik', '/privacy'], ['Databehandler', '/dpa'], ['Vilkår', '/terms'], ['SLA', '/sla'], ['Cookies', '/cookies']]],
|
||||||
] as [string, [string, string][]][],
|
] as [string, [string, string][]][],
|
||||||
copyright: '© 2026 Dezky ApS. Alle rettigheder forbeholdes.',
|
copyright: '© 2026 Dezky ApS. Alle rettigheder forbeholdes.',
|
||||||
status: 'status · alle systemer kører',
|
status: 'status · alle systemer kører',
|
||||||
@@ -138,7 +347,7 @@ export const COPY = {
|
|||||||
nav: { product: 'product', security: 'security', whitelabel: 'whitelabel', pricing: 'pricing', docs: 'docs', login: 'log in', cta: 'book a demo' },
|
nav: { product: 'product', security: 'security', whitelabel: 'whitelabel', pricing: 'pricing', docs: 'docs', login: 'log in', cta: 'book a demo' },
|
||||||
hero: {
|
hero: {
|
||||||
eyebrow: '// sovereign productivity · v1.0',
|
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[],
|
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.',
|
sub: 'Mail, files, video, chat and SSO — fully integrated, EU-hosted, no lock-in. Built on permissively licensed open source.',
|
||||||
cta: 'Book a demo',
|
cta: 'Book a demo',
|
||||||
@@ -155,12 +364,13 @@ export const COPY = {
|
|||||||
label: '02 — the suite',
|
label: '02 — the suite',
|
||||||
heading: 'Everything you expect. Nothing you don\'t want.',
|
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.',
|
lede: 'Five modules. One login. Built to work together — not just live in the same browser.',
|
||||||
|
soonLabel: 'coming soon',
|
||||||
cards: [
|
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: '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.' },
|
{ 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.' },
|
{ 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.' },
|
{ 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.' },
|
{ 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: {
|
how: {
|
||||||
@@ -206,21 +416,26 @@ export const COPY = {
|
|||||||
bullets: [
|
bullets: [
|
||||||
'Full whitelabel theme · CSS and logo',
|
'Full whitelabel theme · CSS and logo',
|
||||||
'Multi-tenant administration',
|
'Multi-tenant administration',
|
||||||
'Margins of 30–45% by volume',
|
'Margins of 15–40% by volume',
|
||||||
'Co-marketing and leads via partner network',
|
'Co-marketing and leads via partner network',
|
||||||
],
|
],
|
||||||
cta: 'See the partner program',
|
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: {
|
stack: {
|
||||||
label: '07 — under the hood',
|
label: '07 — open standards',
|
||||||
heading: 'Built on open source. Verifiable.',
|
heading: 'Built on open standards. No lock-in.',
|
||||||
lede: 'We don\'t hide it. Every component is permissively licensed — you can inspect the code, build it yourself, or move your installation elsewhere.',
|
lede: 'Your data speaks well-known, open protocols — not proprietary formats. Export everything anytime and move to another provider.',
|
||||||
rows: [
|
rows: [
|
||||||
['Mail', 'Stalwart Mail', 'AGPL-3.0', 'stalw.art'],
|
['Mail & calendar', 'IMAP · SMTP · CalDAV · CardDAV', 'Export to .mbox and .ics'],
|
||||||
['Files & drive', 'ownCloud Infinite Scale', 'Apache 2.0', 'owncloud.dev'],
|
['Files & drive', 'WebDAV · S3', 'Download every file — no format lock'],
|
||||||
['Video meetings', 'Jitsi', 'Apache 2.0', 'jitsi.org'],
|
['Video meetings', 'WebRTC · SIP', 'Open browser standard, no client'],
|
||||||
['Team chat', 'Zulip', 'Apache 2.0', 'zulip.com'],
|
['Team chat', 'Open export API', 'Take the full history with you'],
|
||||||
['Identity & SSO', 'Authentik', 'MIT', 'goauthentik.io'],
|
['Identity & SSO', 'OIDC · SAML · SCIM', 'Bring your own IdP'],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
pricing: {
|
pricing: {
|
||||||
@@ -228,7 +443,7 @@ export const COPY = {
|
|||||||
heading: 'Predictable pricing. No surprises.',
|
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.',
|
lede: 'We\'re in a closed beta until summer 2026. Pricing is set with our first customers — not against them.',
|
||||||
teaser: 'Starting at',
|
teaser: 'Starting at',
|
||||||
price: '69',
|
price: '49',
|
||||||
unit: 'DKK / user / mo.',
|
unit: 'DKK / user / mo.',
|
||||||
note: 'Final pricing confirmed at demo. Volume discount from 25 users.',
|
note: 'Final pricing confirmed at demo. Volume discount from 25 users.',
|
||||||
cta: 'Book a demo for pricing',
|
cta: 'Book a demo for pricing',
|
||||||
@@ -241,7 +456,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.'],
|
['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.'],
|
['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 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.'],
|
['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 +465,217 @@ export const COPY = {
|
|||||||
sub: '30-minute demo. No sales pressure. No slides.',
|
sub: '30-minute demo. No sales pressure. No slides.',
|
||||||
cta: 'Book a demo',
|
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.',
|
||||||
|
},
|
||||||
|
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 2–4 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.'],
|
||||||
|
['15–40% 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', '5–15%', '15–40%'],
|
||||||
|
['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.'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
label: 'privacy policy',
|
||||||
|
title: 'Privacy Policy',
|
||||||
|
intro: 'This privacy policy explains how Dezky ApS, as data controller, collects and processes personal data about you — as a visitor to dezky.eu, when you get in touch, and as a customer.',
|
||||||
|
updated: 'Last updated: 5 June 2026 · version 0.1 (draft)',
|
||||||
|
draftNote: 'Draft under legal review — contact privacy@dezky.eu if you need the current version.',
|
||||||
|
sections: [
|
||||||
|
{ h: '1. Data controller', p: ['Dezky ApS (company reg. 43 14 18 21, Åtoften 33, 6710 Esbjerg V, Denmark) is the controller for the personal data we process about you. Data-protection questions: privacy@dezky.eu.'] },
|
||||||
|
{ h: '2. What we collect', p: ['Enquiries and demos: when you contact us or book a demo, we collect the details you provide — typically name, email, company and your message.', 'Customer and billing: for customers we process account and contact details plus billing and payment data.', 'Site usage: we use Umami, a privacy-friendly analytics tool that is cookieless and collects only aggregated, anonymous statistics — no personal data and no cross-site tracking.', 'Technical logs: our servers log technical data (e.g. IP address) briefly for operations and security.'] },
|
||||||
|
{ h: '3. Purposes and legal basis', p: ['Answering enquiries and running demos — our legitimate interest and pre-contract steps (GDPR Art. 6(1)(b) and (f)).', 'Providing, operating and billing the service — performance of a contract (Art. 6(1)(b)).', 'Securing operations and preventing abuse — legitimate interest (Art. 6(1)(f)).', 'Complying with legal obligations, including bookkeeping — legal obligation (Art. 6(1)(c)).'] },
|
||||||
|
{ h: '4. Cookies and analytics', p: ['We set only strictly necessary cookies (e.g. to remember your language choice). Our Umami analytics is cookieless and therefore needs no consent. We use no advertising or third-party tracking, and we send only transactional email — no marketing without your consent.'] },
|
||||||
|
{ h: '5. Retention', p: ['We keep data only as long as necessary. Enquiries are deleted once closed unless they become a customer relationship. Accounting records are kept for 5 years under the Danish Bookkeeping Act. Technical logs are kept briefly.'] },
|
||||||
|
{ h: '6. Security and location', p: ['Your data is hosted in the EU and does not leave the EU/EEA. It is protected with encryption (AES-256 at rest, TLS 1.3 in transit), access control and continuous monitoring.'] },
|
||||||
|
{ h: '7. Changes', p: ['We may update this policy. Material changes are flagged on this page with a new date.'] },
|
||||||
|
],
|
||||||
|
recipientsHeading: 'Who we share with',
|
||||||
|
recipients: [
|
||||||
|
['Hetzner Online GmbH', 'Hosting and infrastructure', 'Germany (EU)'],
|
||||||
|
['Stripe Payments Europe, Ltd.', 'Payment and billing', 'Ireland (EU)'],
|
||||||
|
],
|
||||||
|
rightsHeading: 'Your rights',
|
||||||
|
rights: [
|
||||||
|
'Access to the data we process about you',
|
||||||
|
'Rectification of inaccurate data',
|
||||||
|
'Erasure ("right to be forgotten")',
|
||||||
|
'Restriction of processing',
|
||||||
|
'Data portability',
|
||||||
|
'Objection to processing',
|
||||||
|
'Complaint to the Danish DPA (datatilsynet.dk)',
|
||||||
|
],
|
||||||
|
contactHeading: 'Contact',
|
||||||
|
contactBody: 'To exercise your rights or ask a question, email privacy@dezky.eu. You may also lodge a complaint with the Danish Data Protection Agency.',
|
||||||
|
},
|
||||||
|
dpa: {
|
||||||
|
label: 'data processing agreement',
|
||||||
|
title: 'Data Processing Agreement',
|
||||||
|
intro: 'This Data Processing Agreement (DPA) governs how Dezky ApS processes personal data on behalf of the customer when using the Dezky platform. It forms an annex to the main agreement between the parties.',
|
||||||
|
updated: 'Last updated: 5 June 2026 · version 0.1 (draft)',
|
||||||
|
draftNote: 'Draft under legal review — not yet a final, signed agreement. Contact privacy@dezky.eu for the current signed version.',
|
||||||
|
sections: [
|
||||||
|
{ h: '1. Parties and roles', p: ['The customer is the data controller, and Dezky ApS (company reg. 43 14 18 21, Åtoften 33, 6710 Esbjerg V, Denmark) is the data processor.', 'Dezky processes personal data only on the customer\'s documented instructions — including this agreement and the customer\'s use of the platform — and will inform the customer if, in Dezky\'s opinion, an instruction infringes applicable data-protection law.'] },
|
||||||
|
{ h: '2. Subject matter, duration and purpose', p: ['The subject matter is the personal data the customer and its users place into the modules (mail, calendar, contacts, files, video, chat and identity/SSO).', 'The purpose is to provide and operate the platform. Processing lasts for the term of the main agreement, after which data is deleted or returned per section 9.'] },
|
||||||
|
{ h: '3. Categories of data subjects and data', p: ['Data subjects: the customer\'s employees, contacts and any other individuals whose data the customer chooses to process in the platform.', 'Data: names, email addresses, contact details, calendar and meeting data, file content, messages, and login/user-administration data. The customer decides what data is placed into the platform.'] },
|
||||||
|
{ h: '4. Processor obligations', p: ['Confidentiality: everyone with access to personal data is bound by confidentiality.', 'Security: Dezky implements appropriate technical and organisational measures under GDPR Art. 32 (see below).', 'Assistance: Dezky helps the customer respond to data-subject requests and comply with Arts. 32–36 (security, breaches and impact assessments).'] },
|
||||||
|
{ h: '5. Sub-processors', p: ['The customer grants Dezky general authorisation to use sub-processors; the current ones are listed below.', 'Dezky imposes the same obligations on sub-processors as in this agreement and gives the customer reasonable notice of changes so the customer can object.'] },
|
||||||
|
{ h: '6. International transfers', p: ['Content and operational data is hosted in the EU (Germany) and is not transferred outside the EU/EEA. Dezky has no US parent or subsidiary.', 'Payment processing is handled by an EU-based sub-processor. Any exceptional transfer to a third country requires a valid basis under GDPR Chapter V.'] },
|
||||||
|
{ h: '7. Personal-data breaches', p: ['Dezky notifies the customer without undue delay and no later than 72 hours after becoming aware of a breach, with the information the customer needs to meet its own obligations.'] },
|
||||||
|
{ h: '8. Audits and inspections', p: ['Dezky makes available documentation to demonstrate compliance, including relevant certifications and audit logs, and allows audits on reasonable notice without unduly disrupting operations.'] },
|
||||||
|
{ h: '9. Deletion and return', p: ['On termination, Dezky deletes or returns all personal data at the customer\'s choice and deletes existing copies, unless law requires continued storage. The customer can export its data at any time via open standards.'] },
|
||||||
|
{ h: '10. Governing law and venue', p: ['This agreement is governed by Danish law, and disputes are settled by the Danish courts.'] },
|
||||||
|
],
|
||||||
|
subprocessorsHeading: 'Sub-processors',
|
||||||
|
subprocessors: [
|
||||||
|
['Hetzner Online GmbH', 'Hosting, object storage and backup', 'Germany (EU)'],
|
||||||
|
['Stripe Payments Europe, Ltd.', 'Billing and payment data', 'Ireland (EU)'],
|
||||||
|
],
|
||||||
|
tomsHeading: 'Technical and organisational measures',
|
||||||
|
toms: [
|
||||||
|
'Encryption: AES-256 at rest, TLS 1.3 in transit',
|
||||||
|
'Access control with single sign-on and multi-factor',
|
||||||
|
'ISO 27001-certified operator, Tier III data centres',
|
||||||
|
'Audit log with 13-month retention',
|
||||||
|
'Redundant, encrypted backups',
|
||||||
|
'Customer-held encryption keys (BYOK) on Enterprise',
|
||||||
|
'Least privilege and segregated environments',
|
||||||
|
],
|
||||||
|
contactHeading: 'Contact',
|
||||||
|
contactBody: 'Questions about data processing or to request the signed agreement: privacy@dezky.eu',
|
||||||
|
},
|
||||||
|
stubs: {
|
||||||
|
customers: 'Customers',
|
||||||
|
careers: 'Careers',
|
||||||
|
press: 'Press',
|
||||||
|
status: 'System status',
|
||||||
|
docs: 'Documentation',
|
||||||
|
blog: 'Blog',
|
||||||
|
terms: 'Terms of service',
|
||||||
|
sla: 'SLA',
|
||||||
|
cookies: 'Cookie policy',
|
||||||
|
},
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
tagline: 'Sovereign productivity for Danish business.',
|
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: [
|
cols: [
|
||||||
['Product', [['Features', '#suite'], ['Security', '#sovereignty'], ['Roadmap', '#'], ['Status', '#'], ['Changelog', '#']]],
|
['Product', [['Features', '/#suite'], ['Security', '/#sovereignty'], ['Roadmap', '/roadmap'], ['Status', '/status'], ['Changelog', '/changelog']]],
|
||||||
['Company', [['About', '#'], ['Customers', '#'], ['Careers', '#'], ['Press', '#'], ['Contact', '#']]],
|
['Company', [['About', '/about'], ['Customers', '/customers'], ['Careers', '/careers'], ['Press', '/press'], ['Contact', '/contact']]],
|
||||||
['Resources', [['Docs', '#'], ['Migration guide', '#'], ['Partners', '#whitelabel'], ['Blog', '#'], ['Brand', '#']]],
|
['Resources', [['Docs', '/docs'], ['Migration guide', '/migration'], ['Partners', '/#whitelabel'], ['Blog', '/blog'], ['Brand', '/brand']]],
|
||||||
['Legal', [['Privacy', '#'], ['DPA', '#'], ['Terms', '#'], ['SLA', '#'], ['Cookies', '#']]],
|
['Legal', [['Privacy', '/privacy'], ['DPA', '/dpa'], ['Terms', '/terms'], ['SLA', '/sla'], ['Cookies', '/cookies']]],
|
||||||
] as [string, [string, string][]][],
|
] as [string, [string, string][]][],
|
||||||
copyright: '© 2026 Dezky ApS. All rights reserved.',
|
copyright: '© 2026 Dezky ApS. All rights reserved.',
|
||||||
status: 'status · all systems operational',
|
status: 'status · all systems operational',
|
||||||
|
|||||||
@@ -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.
|
// locally the portal runs at app.dezky.local.
|
||||||
export const APP_URL = 'https://app.dezky.local'
|
export const APP_URL = 'https://app.dezky.local'
|
||||||
|
|||||||
+77
-10
@@ -55,6 +55,7 @@ check_command() {
|
|||||||
check_command docker "Install Docker Desktop or OrbStack from https://orbstack.dev"
|
check_command docker "Install Docker Desktop or OrbStack from https://orbstack.dev"
|
||||||
check_command mkcert "brew install mkcert"
|
check_command mkcert "brew install mkcert"
|
||||||
check_command openssl "Should be preinstalled on macOS"
|
check_command openssl "Should be preinstalled on macOS"
|
||||||
|
check_command git "brew install git"
|
||||||
|
|
||||||
if ! docker compose version &> /dev/null; then
|
if ! docker compose version &> /dev/null; then
|
||||||
error "Docker Compose v2 not available."
|
error "Docker Compose v2 not available."
|
||||||
@@ -73,9 +74,75 @@ ok "Docker daemon running"
|
|||||||
echo ""
|
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"
|
mkdir -p "$CERTS_DIR"
|
||||||
cd "$CERTS_DIR"
|
cd "$CERTS_DIR"
|
||||||
@@ -103,9 +170,9 @@ cd "$PROJECT_ROOT"
|
|||||||
echo ""
|
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=(
|
HOSTS_ENTRIES=(
|
||||||
"dezky.local"
|
"dezky.local"
|
||||||
@@ -151,9 +218,9 @@ fi
|
|||||||
echo ""
|
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
|
if [[ -f "$PROJECT_ROOT/.env" ]]; then
|
||||||
ok ".env file already exists"
|
ok ".env file already exists"
|
||||||
@@ -190,9 +257,9 @@ fi
|
|||||||
echo ""
|
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"
|
cd "$COMPOSE_DIR"
|
||||||
docker compose pull
|
docker compose pull
|
||||||
@@ -201,9 +268,9 @@ ok "All images pulled"
|
|||||||
echo ""
|
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)..."
|
info "Starting database layer (postgres, mongo, redis)..."
|
||||||
docker compose up -d postgres mongo redis
|
docker compose up -d postgres mongo redis
|
||||||
|
|||||||
Reference in New Issue
Block a user