feat(website): add Nuxt 4 marketing landing page
New standalone apps/website (Nuxt 4) serving the public marketing site at dezky.local / www.dezky.local. The customer portal moves off the root domain to app.dezky.local only. Landing page ported from the Dezky design handoff: light theme, Danish default, hero variant A, with a working da/en toggle. Self-contained colour system threaded through components (utils/landingTokens.ts), full bilingual copy (utils/landingCopy.ts), and shared state (composables/useLanding.ts). Sections live under components/landing/* with the Node logo under components/brand/*. Wired into docker-compose (website service, volume, Traefik labels, network aliases) and bootstrap.sh (hosts list + service URLs).
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
/* Dezky marketing site — global styles. Ported from the design handoff's
|
||||
<style> block in Landing Page.html. The landing page is self-contained on
|
||||
colour (it threads a theme object through components), so the base just sets
|
||||
the page surface, resets, and the FAQ accordion marker animation. */
|
||||
|
||||
html,
|
||||
body,
|
||||
#__nuxt {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
background: #FAFAF7;
|
||||
color: #0A0A0A;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
summary::marker {
|
||||
content: '';
|
||||
}
|
||||
|
||||
/* FAQ accordion plus → rotates to an "open" state. Consumed by Faq.vue's
|
||||
.faq-plus span. */
|
||||
details[open] .faq-plus::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.faq-plus::before,
|
||||
.faq-plus::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: currentColor;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.faq-plus::before {
|
||||
width: 2px;
|
||||
height: 14px;
|
||||
left: 6px;
|
||||
top: 0;
|
||||
}
|
||||
.faq-plus::after {
|
||||
width: 14px;
|
||||
height: 2px;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(128, 128, 128, 0.25);
|
||||
border-radius: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/* Dezky design tokens — shared brand palette (carbon dark by default).
|
||||
Identical variable set to apps/portal and apps/operator so the marketing
|
||||
site, the customer portal, and the operator console all speak the same
|
||||
design language. A Claude design handoff for the landing page should build
|
||||
on these tokens rather than introducing parallel hex values. */
|
||||
|
||||
:root {
|
||||
--bg: #0A0A0A;
|
||||
--surface: #141413;
|
||||
--elevated: #1C1C1A;
|
||||
--border: #262622;
|
||||
--border-hi: #33332E;
|
||||
|
||||
--text: #F4F3EE;
|
||||
--text-dim: rgba(244, 243, 238, 0.72);
|
||||
--text-mute: rgba(244, 243, 238, 0.45);
|
||||
|
||||
--side-bg: #0A0A0A;
|
||||
--side-surf: #141413;
|
||||
--side-border: #1F1F1C;
|
||||
--side-text: #F4F3EE;
|
||||
--side-dim: rgba(244, 243, 238, 0.55);
|
||||
--side-mute: rgba(244, 243, 238, 0.35);
|
||||
--side-hover: rgba(244, 243, 238, 0.06);
|
||||
--side-active: rgba(244, 243, 238, 0.1);
|
||||
|
||||
--accent: #D4FF3A;
|
||||
--accent-fg: #0A0A0A;
|
||||
--signal: #D4FF3A;
|
||||
|
||||
--ok: #34C77B;
|
||||
--warn: #F0B14A;
|
||||
--bad: #F05858;
|
||||
--info: #4D8BE8;
|
||||
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-display: 'Inter Tight', 'Inter', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'Menlo', monospace;
|
||||
|
||||
--input-bg: rgba(244, 243, 238, 0.04);
|
||||
|
||||
/* Cosmetic density. comfy=1, compact≈0.78. */
|
||||
--density-scale: 1;
|
||||
}
|
||||
|
||||
/* Tweaks: density overrides */
|
||||
:root[data-density='compact'] { --density-scale: 0.78; }
|
||||
|
||||
/* Tweaks: light theme (warm cream, charcoal text). Overrides every surface
|
||||
token so any component that uses var(--bg / --surface / --text / ...) flips
|
||||
without code changes. */
|
||||
:root[data-theme='light'] {
|
||||
--bg: #F6F4EF;
|
||||
--surface: #FAF8F2;
|
||||
--elevated: #FFFFFF;
|
||||
--border: #E2DED2;
|
||||
--border-hi: #D0CBBC;
|
||||
|
||||
--text: #1C1B17;
|
||||
--text-dim: rgba(28, 27, 23, 0.72);
|
||||
--text-mute: rgba(28, 27, 23, 0.50);
|
||||
|
||||
--side-bg: #F0EDE4;
|
||||
--side-surf: #FAF8F2;
|
||||
--side-border: #E2DED2;
|
||||
--side-text: #1C1B17;
|
||||
--side-dim: rgba(28, 27, 23, 0.62);
|
||||
--side-mute: rgba(28, 27, 23, 0.42);
|
||||
--side-hover: rgba(28, 27, 23, 0.05);
|
||||
--side-active: rgba(28, 27, 23, 0.08);
|
||||
|
||||
--accent: #1F8A5B;
|
||||
--accent-fg: #FAF8F2;
|
||||
--signal: #1F8A5B;
|
||||
|
||||
--ok: #1F8A5B;
|
||||
--warn: #C97F1F;
|
||||
--bad: #C03A3A;
|
||||
--info: #2A6FDB;
|
||||
|
||||
--input-bg: rgba(28, 27, 23, 0.04);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
// Node mark + "dezky" wordmark (JetBrains Mono, tracked tight). Ported from
|
||||
// logos.jsx NodeLockup + NodeWordmark.
|
||||
import { C } from '~/utils/landingTokens'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
scale?: number
|
||||
fg?: string
|
||||
accent?: string
|
||||
}>(), {
|
||||
scale: 1,
|
||||
fg: C.carbon,
|
||||
accent: C.signal,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{ display: 'flex', alignItems: 'center', gap: `${18 * scale}px` }">
|
||||
<BrandNodeMark :size="72 * scale" :fg="fg" :accent="accent" />
|
||||
<span :style="{
|
||||
fontFamily: '\'JetBrains Mono\', \'IBM Plex Mono\', ui-monospace, monospace',
|
||||
fontWeight: 600,
|
||||
fontSize: `${56 * scale * 0.78}px`,
|
||||
letterSpacing: '-0.04em',
|
||||
color: fg,
|
||||
lineHeight: 0.9,
|
||||
}">dezky</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
// Dezky "Node" mark — a lowercase d (donut style) inside a squircle, with a
|
||||
// corner node-dot. Geometry is the locked set from the brand handoff
|
||||
// (logos.jsx NodeMark + LOCKED). The squircle paints in `fg`; the letterform
|
||||
// and dot paint in `accent` (electric chartreuse) — the design's intent.
|
||||
import { C, LOCKED } from '~/utils/landingTokens'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
size?: number
|
||||
fg?: string
|
||||
accent?: string
|
||||
}>(), {
|
||||
size: 96,
|
||||
fg: C.carbon,
|
||||
accent: C.signal,
|
||||
})
|
||||
|
||||
const { bowlR, stemW, dotR, contR } = LOCKED
|
||||
|
||||
const overlap = stemW * 0.55
|
||||
const cy = 52
|
||||
const cx = 50 - stemW / 2 + overlap / 2
|
||||
const stemX = cx + bowlR - overlap
|
||||
const stemRight = stemX + stemW
|
||||
const capR = stemW / 2
|
||||
const stemTop = 26
|
||||
const stemBottom = cy + bowlR
|
||||
const holeR = Math.max(2.5, bowlR - stemW - 0.5)
|
||||
|
||||
const bowlPath =
|
||||
`M ${cx - bowlR} ${cy} ` +
|
||||
`a ${bowlR} ${bowlR} 0 1 0 ${bowlR * 2} 0 ` +
|
||||
`a ${bowlR} ${bowlR} 0 1 0 ${-bowlR * 2} 0 Z`
|
||||
const counterPath =
|
||||
`M ${cx - holeR} ${cy} ` +
|
||||
`a ${holeR} ${holeR} 0 1 0 ${holeR * 2} 0 ` +
|
||||
`a ${holeR} ${holeR} 0 1 0 ${-holeR * 2} 0 Z`
|
||||
const stemPath =
|
||||
`M ${stemX} ${stemTop + capR} ` +
|
||||
`a ${capR} ${capR} 0 0 1 ${stemW} 0 ` +
|
||||
`L ${stemRight} ${stemBottom} ` +
|
||||
`L ${stemX} ${stemBottom} Z`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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" />
|
||||
<g :fill="accent">
|
||||
<path :d="`${bowlPath} ${counterPath}`" fill-rule="evenodd" />
|
||||
<path :d="stemPath" />
|
||||
</g>
|
||||
<circle cx="74" cy="26" :r="dotR" :fill="accent" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
// Landing button. Variants: primary (signal fill), secondary (outline), ghost.
|
||||
// Ported from landing-sections.jsx Button. Named Btn.vue to avoid colliding
|
||||
// with any shared <Button> in @dezky/ui.
|
||||
import { computed } from 'vue'
|
||||
import { C } from '~/utils/landingTokens'
|
||||
import { useTheme } from '~/composables/useLanding'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
variant?: 'primary' | 'secondary' | 'ghost'
|
||||
size?: 'md' | 'lg'
|
||||
full?: boolean
|
||||
}>(), {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
full: false,
|
||||
})
|
||||
|
||||
const t = useTheme()
|
||||
|
||||
const style = computed(() => {
|
||||
const base = {
|
||||
primary: { background: C.signal, color: C.carbon, border: `1px solid ${C.signal}` },
|
||||
secondary: { background: 'transparent', color: t.value.fg, border: `1px solid ${t.value.borderStrong}` },
|
||||
ghost: { background: 'transparent', color: t.value.fg, border: '1px solid transparent' },
|
||||
}[props.variant]
|
||||
return {
|
||||
...base,
|
||||
padding: props.size === 'lg' ? '18px 28px' : '14px 22px',
|
||||
fontSize: props.size === 'lg' ? '15px' : '14px',
|
||||
fontWeight: 600,
|
||||
fontFamily: '\'Inter\', sans-serif',
|
||||
borderRadius: '4px',
|
||||
letterSpacing: '-0.005em',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '10px',
|
||||
width: props.full ? '100%' : 'auto',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'transform 0.1s ease, box-shadow 0.15s ease',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :style="style"><slot /></button>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
// Section 05 — comparison table (Dezky vs US hyperscaler).
|
||||
// Ported from landing-sections.jsx Compare.
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :style="{ background: t.bg, color: t.fg }">
|
||||
<LandingContainer pad="140px 64px">
|
||||
<LandingSectionLabel :label="copy.compare.label" />
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1.1fr 1fr', gap: '80px', alignItems: 'end', marginBottom: '56px' }">
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.fg }">{{ copy.compare.heading }}</h2>
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '17px', lineHeight: 1.6, maxWidth: '460px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.compare.lede }}</p>
|
||||
</div>
|
||||
|
||||
<div :style="{ border: `1px solid ${t.borderStrong}` }">
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1.2fr 1fr 1fr', background: t.fg, color: t.bg }">
|
||||
<div :style="{ padding: '20px 28px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', letterSpacing: '0.12em', textTransform: 'uppercase', opacity: 0.6 }">kategori</div>
|
||||
<div :style="{ padding: '20px 28px', display: 'flex', alignItems: 'center', gap: '10px', borderLeft: `1px solid ${t.fgDim}` }">
|
||||
<BrandNodeMark :size="18" :fg="t.bg" :accent="t.signal" />
|
||||
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '14px', fontWeight: 600 }">{{ copy.compare.cols[0] }}</span>
|
||||
</div>
|
||||
<div :style="{ padding: '20px 28px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '14px', fontWeight: 500, opacity: 0.7, borderLeft: `1px solid ${t.fgDim}` }">{{ copy.compare.cols[1] }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, i) in copy.compare.rows" :key="i"
|
||||
:style="{
|
||||
display: 'grid', gridTemplateColumns: '1.2fr 1fr 1fr',
|
||||
borderTop: `1px solid ${t.border}`,
|
||||
background: i % 2 === 0 ? t.bg : t.bgAlt,
|
||||
fontFamily: '\'Inter\', sans-serif', fontSize: '15px',
|
||||
}"
|
||||
>
|
||||
<div :style="{ padding: '22px 28px', color: t.fgMuted }">{{ row[0] }}</div>
|
||||
<div :style="{ padding: '22px 28px', color: t.fg, fontWeight: 600, borderLeft: `1px solid ${t.border}`, display: 'flex', alignItems: 'center', gap: '10px' }">
|
||||
<span :style="{ width: '4px', height: '4px', background: t.signal, borderRadius: '999px', flexShrink: 0 }" />
|
||||
{{ row[1] }}
|
||||
</div>
|
||||
<div :style="{ padding: '22px 28px', color: t.fgMuted, borderLeft: `1px solid ${t.border}` }">{{ row[2] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
// Padded, max-width-centred section wrapper. Ported from landing-sections.jsx Container.
|
||||
withDefaults(defineProps<{
|
||||
pad?: string
|
||||
max?: number
|
||||
}>(), {
|
||||
pad: '120px 64px',
|
||||
max: 1280,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{ padding: pad }">
|
||||
<div :style="{ maxWidth: `${max}px`, margin: '0 auto' }">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
// Section 09 — FAQ. Native <details> accordion with an animated plus marker
|
||||
// (.faq-plus styles live in assets/styles/base.css). Ported from
|
||||
// landing-sections.jsx FAQ.
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :style="{ background: t.bg, color: t.fg }">
|
||||
<LandingContainer pad="140px 64px" :max="960">
|
||||
<LandingSectionLabel :label="copy.faq.label" />
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.fg }">{{ copy.faq.heading }}</h2>
|
||||
<div :style="{ marginTop: '56px' }">
|
||||
<details
|
||||
v-for="(item, i) in copy.faq.items" :key="i"
|
||||
:style="{
|
||||
borderTop: `1px solid ${t.borderStrong}`,
|
||||
borderBottom: i === copy.faq.items.length - 1 ? `1px solid ${t.borderStrong}` : 'none',
|
||||
}"
|
||||
>
|
||||
<summary :style="{ padding: '24px 0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '24px', listStyle: 'none' }">
|
||||
<span :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 500, fontSize: '19px', color: t.fg, letterSpacing: '-0.015em' }">{{ item[0] }}</span>
|
||||
<span class="faq-plus" :style="{ width: '14px', height: '14px', position: 'relative', color: t.fgMuted, flexShrink: 0 }" />
|
||||
</summary>
|
||||
<div :style="{ paddingBottom: '28px', paddingRight: '60px' }">
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.6, maxWidth: '720px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ item[1] }}</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
// Final CTA — carbon panel with a faint Node-mark watermark.
|
||||
// Ported from landing-sections.jsx FinalCTA.
|
||||
import { C } from '~/utils/landingTokens'
|
||||
import { useCopy } from '~/composables/useLanding'
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="final-cta" :style="{ background: C.carbon, color: C.bone, position: 'relative', overflow: 'hidden', scrollMarginTop: '72px' }">
|
||||
<div :style="{ position: 'absolute', right: '-180px', bottom: '-180px', opacity: 0.05 }">
|
||||
<BrandNodeMark :size="640" :fg="C.carbon" :accent="C.signal" />
|
||||
</div>
|
||||
<LandingContainer pad="140px 64px">
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(48px, 6vw, 96px)', letterSpacing: '-0.04em', lineHeight: 0.98, margin: 0, color: C.bone, textWrap: 'balance', maxWidth: '900px' }">
|
||||
<template v-for="(part, i) in copy.finalCta.heading" :key="i">
|
||||
<template v-if="typeof part === 'string'">{{ part }} </template>
|
||||
<span v-else :style="{ color: C.signal }">{{ part.hl }}</span>
|
||||
</template>
|
||||
</h2>
|
||||
<div :style="{ marginTop: '28px', maxWidth: '520px', fontFamily: '\'Inter\', sans-serif', fontSize: '19px', color: 'rgba(244,243,238,0.7)' }">{{ copy.finalCta.sub }}</div>
|
||||
<div :style="{ marginTop: '40px' }">
|
||||
<button :style="{
|
||||
background: C.signal, color: C.carbon, border: 'none',
|
||||
padding: '20px 32px', fontFamily: '\'Inter\', sans-serif',
|
||||
fontSize: '16px', fontWeight: 600, borderRadius: '4px', cursor: 'pointer',
|
||||
letterSpacing: '-0.005em',
|
||||
}">{{ copy.finalCta.cta }} →</button>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
// Footer — lockup + tagline + legal, four link columns, status row.
|
||||
// Ported from landing-sections.jsx Footer (light mode). Anchor links smooth-
|
||||
// scroll; "#" placeholders point at not-yet-built subpages.
|
||||
import { C } from '~/utils/landingTokens'
|
||||
import { useTheme, useCopy, scrollToAnchor } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
|
||||
function onLink(e: MouseEvent, href: string) {
|
||||
if (href.startsWith('#') && href.length > 1) {
|
||||
e.preventDefault()
|
||||
scrollToAnchor(href)
|
||||
} else if (href === '#') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer :style="{ background: C.bone, color: C.carbon, borderTop: `1px solid ${t.border}` }">
|
||||
<LandingContainer pad="80px 64px 40px">
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1.4fr repeat(4, 1fr)', gap: '48px' }">
|
||||
<div>
|
||||
<BrandNodeLockup :scale="0.75" :fg="C.carbon" :accent="C.signal" />
|
||||
<div :style="{ marginTop: '20px', fontFamily: '\'Inter\', sans-serif', fontSize: '14px', color: 'rgba(10,10,10,0.6)', maxWidth: '280px', lineHeight: 1.5 }">{{ copy.footer.tagline }}</div>
|
||||
<div :style="{ marginTop: '28px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: 'rgba(10,10,10,0.45)', lineHeight: 1.7 }">
|
||||
<div>{{ copy.footer.legal.name }}</div>
|
||||
<div>{{ copy.footer.legal.cvr }}</div>
|
||||
<div>{{ copy.footer.legal.addr }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="(col, i) in copy.footer.cols" :key="i">
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: 'rgba(10,10,10,0.45)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: '18px' }">{{ col[0] }}</div>
|
||||
<div :style="{ display: 'flex', flexDirection: 'column', gap: '12px' }">
|
||||
<a
|
||||
v-for="(link, j) in col[1]" :key="j" :href="link[1]"
|
||||
:style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '14px', color: 'rgba(10,10,10,0.78)' }"
|
||||
@click="onLink($event, link[1])"
|
||||
>{{ link[0] }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{
|
||||
marginTop: '64px', paddingTop: '28px', borderTop: `1px solid ${t.border}`,
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '24px',
|
||||
fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: 'rgba(10,10,10,0.45)',
|
||||
}">
|
||||
<div>{{ copy.footer.copyright }}</div>
|
||||
<div :style="{ display: 'flex', alignItems: 'center', gap: '24px' }">
|
||||
<span :style="{ display: 'flex', alignItems: 'center', gap: '8px' }">
|
||||
<span :style="{ width: '6px', height: '6px', background: C.ok, borderRadius: '999px', boxShadow: `0 0 0 3px ${C.ok}33` }" />
|
||||
{{ copy.footer.status }}
|
||||
</span>
|
||||
<span>v1.0.4</span>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
// Hero — eyebrow, big headline with signal-yellow highlighter brush, sub,
|
||||
// dual CTAs, trust strip, and the product viewport mockup below.
|
||||
// Ported from landing-sections.jsx Hero (variant A, light mode).
|
||||
import { computed } from 'vue'
|
||||
import { C } from '~/utils/landingTokens'
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
|
||||
// Variant A is the production default the user landed on.
|
||||
const headline = computed(() => copy.value.hero.headlineA)
|
||||
|
||||
// Light-mode highlight: a yellow marker swipe sitting low behind the phrase
|
||||
// (signal-yellow text on bone fails contrast, so we brush instead).
|
||||
const brush = {
|
||||
backgroundImage: `linear-gradient(180deg, transparent 0%, transparent 56%, ${C.signal} 56%, ${C.signal} 96%, transparent 96%)`,
|
||||
padding: '0 0.06em',
|
||||
boxDecorationBreak: 'clone',
|
||||
WebkitBoxDecorationBreak: 'clone',
|
||||
} as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :style="{ background: t.bg, color: t.fg, paddingTop: '80px' }">
|
||||
<LandingContainer pad="60px 64px 0">
|
||||
<div :style="{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '56px' }">
|
||||
<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.04em', whiteSpace: 'nowrap' }">{{ copy.hero.eyebrow }}</span>
|
||||
</div>
|
||||
|
||||
<h1 :style="{
|
||||
fontFamily: '\'Inter Tight\', \'Inter\', sans-serif',
|
||||
fontWeight: 600, fontSize: 'clamp(56px, 7.2vw, 112px)', letterSpacing: '-0.04em',
|
||||
lineHeight: 0.96, margin: 0, textWrap: 'balance', color: t.fg,
|
||||
}">
|
||||
<template v-for="(part, i) in headline" :key="i">
|
||||
<template v-if="typeof part === 'string'">{{ part }} </template>
|
||||
<span v-else :style="brush">{{ part.hl }} </span>
|
||||
</template>
|
||||
</h1>
|
||||
|
||||
<div :style="{ marginTop: '40px', maxWidth: '620px' }">
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '20px', lineHeight: 1.5, maxWidth: '620px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.hero.sub }}</p>
|
||||
</div>
|
||||
|
||||
<div :style="{ display: 'flex', gap: '12px', marginTop: '40px', flexWrap: 'wrap' }">
|
||||
<LandingBtn variant="primary" size="lg">{{ copy.hero.cta }} →</LandingBtn>
|
||||
<LandingBtn variant="secondary" size="lg">{{ copy.hero.sub_cta }}</LandingBtn>
|
||||
</div>
|
||||
|
||||
<div :style="{
|
||||
marginTop: '64px', paddingTop: '28px', borderTop: `1px solid ${t.border}`,
|
||||
display: 'flex', gap: '56px', flexWrap: 'wrap',
|
||||
}">
|
||||
<div v-for="(s, i) in copy.hero.trust" :key="i" :style="{ display: 'flex', alignItems: 'center', gap: '10px' }">
|
||||
<span :style="{ width: '4px', height: '4px', background: t.signal, borderRadius: '999px' }" />
|
||||
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgMuted, letterSpacing: '0.04em' }">{{ s }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
|
||||
<LandingContainer pad="80px 64px 120px">
|
||||
<LandingProductMockup />
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
// Section 03 — setup. Three numbered steps on a top rule with node markers.
|
||||
// Ported from landing-sections.jsx HowItWorks.
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :style="{ background: t.bg, color: t.fg }">
|
||||
<LandingContainer pad="120px 64px">
|
||||
<LandingSectionLabel :label="copy.how.label" />
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.fg }">{{ copy.how.heading }}</h2>
|
||||
<div :style="{ marginTop: '80px', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0', position: 'relative' }">
|
||||
<div
|
||||
v-for="(step, i) in copy.how.steps" :key="i"
|
||||
:style="{ padding: '40px 36px 0 0', borderTop: `1px solid ${t.borderStrong}`, position: 'relative' }"
|
||||
>
|
||||
<div :style="{
|
||||
position: 'absolute', top: '-7px', left: '0', width: '14px', height: '14px',
|
||||
background: i === 0 ? t.signal : t.bg,
|
||||
border: `1px solid ${t.borderStrong}`, borderRadius: '999px',
|
||||
}" />
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgDim, letterSpacing: '0.08em' }">step {{ step.n }}</div>
|
||||
<h3 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '28px', letterSpacing: '-0.025em', lineHeight: 1.1, margin: '20px 0 16px 0', color: t.fg }">{{ step.title }}</h3>
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '15px', lineHeight: 1.6, maxWidth: '300px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ step.body }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
// Tiny abstract per-module glyph (soft geometric, not literal icons).
|
||||
// Ported from landing-sections.jsx ModuleGlyph.
|
||||
import { computed } from 'vue'
|
||||
import { useTheme } from '~/composables/useLanding'
|
||||
|
||||
const props = defineProps<{ name: string }>()
|
||||
const t = useTheme()
|
||||
const stroke = 1.5
|
||||
|
||||
const kind = computed(() => {
|
||||
const n = props.name
|
||||
if (n === 'Mail' || n === 'mail') return 'mail'
|
||||
if (n === 'Drev' || n === 'Drive') return 'drive'
|
||||
if (n === 'Møder' || n === 'Meet') return 'meet'
|
||||
if (n === 'Chat') return 'chat'
|
||||
return 'identity'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg width="36" height="36" viewBox="0 0 36 36">
|
||||
<template v-if="kind === 'mail'">
|
||||
<rect x="3" y="9" width="30" height="20" rx="2" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<path d="M3 11 L18 22 L33 11" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<circle cx="30" cy="9" r="3" :fill="t.signal" />
|
||||
</template>
|
||||
<template v-else-if="kind === 'drive'">
|
||||
<rect x="4" y="10" width="28" height="18" rx="2" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<path d="M4 14 L13 14 L16 10 L32 10" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<circle cx="18" cy="20" r="2.5" :fill="t.signal" />
|
||||
</template>
|
||||
<template v-else-if="kind === 'meet'">
|
||||
<rect x="3" y="10" width="22" height="16" rx="2" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<path d="M25 14 L33 10 L33 26 L25 22 Z" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<circle cx="14" cy="18" r="3" :fill="t.signal" />
|
||||
</template>
|
||||
<template v-else-if="kind === 'chat'">
|
||||
<path d="M5 6 L31 6 Q33 6 33 8 L33 22 Q33 24 31 24 L16 24 L9 30 L9 24 L7 24 Q5 24 5 22 Z" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<circle cx="14" cy="15" r="1.6" :fill="t.signal" />
|
||||
<circle cx="19" cy="15" r="1.6" :fill="t.fg" />
|
||||
<circle cx="24" cy="15" r="1.6" :fill="t.fg" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<rect x="8" y="14" width="20" height="16" rx="2" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<path d="M12 14 V10 Q12 5 18 5 Q24 5 24 10 V14" fill="none" :stroke="t.fg" :stroke-width="stroke" />
|
||||
<circle cx="18" cy="22" r="2.5" :fill="t.signal" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
// Sticky top nav with logo, anchor links, language toggle, login + demo CTA.
|
||||
// Ported from landing-sections.jsx Nav (light mode, production subset).
|
||||
import { computed } from 'vue'
|
||||
import { APP_URL } from '~/utils/landingTokens'
|
||||
import { useTheme, useCopy, useLang, toggleLang, scrollToAnchor } from '~/composables/useLanding'
|
||||
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
const lang = useLang()
|
||||
|
||||
const items = computed(() => [
|
||||
{ label: copy.value.nav.product, href: '#suite' },
|
||||
{ label: copy.value.nav.security, href: '#sovereignty' },
|
||||
{ label: copy.value.nav.whitelabel, href: '#whitelabel' },
|
||||
{ label: copy.value.nav.pricing, href: '#pricing' },
|
||||
{ label: copy.value.nav.docs, href: '#' },
|
||||
])
|
||||
|
||||
function onLogo() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header :style="{
|
||||
position: 'sticky', top: '0', zIndex: 100,
|
||||
background: 'rgba(250,250,247,0.84)',
|
||||
backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)',
|
||||
borderBottom: `1px solid ${t.border}`,
|
||||
}">
|
||||
<div :style="{
|
||||
maxWidth: '1280px', margin: '0 auto', padding: '18px 64px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}">
|
||||
<a href="#" :style="{ display: 'flex', alignItems: 'center', gap: '12px', cursor: 'pointer' }" @click.prevent="onLogo">
|
||||
<BrandNodeMark :size="32" :fg="t.fg" :accent="t.signal" />
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontWeight: 600, fontSize: '16px', letterSpacing: '-0.02em', color: t.fg }">dezky</div>
|
||||
</a>
|
||||
|
||||
<nav :style="{ display: 'flex', alignItems: 'center', gap: '36px' }">
|
||||
<a
|
||||
v-for="(it, i) in items" :key="i" :href="it.href"
|
||||
:style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '14px', color: t.fgMuted, letterSpacing: '-0.005em' }"
|
||||
@click.prevent="scrollToAnchor(it.href)"
|
||||
>{{ it.label }}</a>
|
||||
</nav>
|
||||
|
||||
<div :style="{ display: 'flex', alignItems: 'center', gap: '14px' }">
|
||||
<button
|
||||
:style="{
|
||||
background: 'transparent', border: `1px solid ${t.border}`,
|
||||
color: t.fgMuted, fontSize: '11px', padding: '6px 10px', borderRadius: '4px',
|
||||
fontFamily: '\'JetBrains Mono\', monospace', letterSpacing: '0.06em', textTransform: 'uppercase',
|
||||
whiteSpace: 'nowrap',
|
||||
}"
|
||||
@click="toggleLang()"
|
||||
>{{ lang === 'da' ? 'da · en' : 'en · da' }}</button>
|
||||
<a :href="APP_URL" :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '14px', color: t.fgMuted }">{{ copy.nav.login }}</a>
|
||||
<LandingBtn variant="primary" @click="scrollToAnchor('#final-cta')">{{ copy.nav.cta }} →</LandingBtn>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
// A single "partner brand" row in the whitelabel demo.
|
||||
// Ported from landing-sections.jsx PartnerCard.
|
||||
import { computed } from 'vue'
|
||||
const props = withDefaults(defineProps<{
|
||||
fg: string
|
||||
bg: string
|
||||
border: string
|
||||
accent: string
|
||||
name: string
|
||||
subtitle: string
|
||||
placeholder?: boolean
|
||||
}>(), { placeholder: false })
|
||||
|
||||
const initial = computed(() => props.name[0].toUpperCase())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{
|
||||
background: bg, border: `1px solid ${border}`, borderRadius: '4px',
|
||||
padding: '20px 22px', display: 'flex', alignItems: 'center', gap: '16px',
|
||||
opacity: placeholder ? 0.55 : 1,
|
||||
borderStyle: placeholder ? 'dashed' : 'solid',
|
||||
}">
|
||||
<div :style="{
|
||||
width: '44px', height: '44px', borderRadius: '4px', background: accent,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 700,
|
||||
fontSize: '22px', color: '#FFF', letterSpacing: '-0.02em', flexShrink: 0,
|
||||
}">{{ initial }}</div>
|
||||
<div :style="{ flex: 1 }">
|
||||
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '17px', color: fg, letterSpacing: '-0.02em' }">{{ name }}</div>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: 'rgba(0,0,0,0.5)', marginTop: '2px' }">{{ subtitle }}</div>
|
||||
</div>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '10px', color: 'rgba(0,0,0,0.4)', letterSpacing: '0.1em', textTransform: 'uppercase' }">powered by dezky</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
// Section 08 — pricing. Pitch column + a single price card.
|
||||
// Ported from landing-sections.jsx Pricing.
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="pricing" :style="{ background: t.bgAlt, color: t.fg, borderTop: `1px solid ${t.border}`, borderBottom: `1px solid ${t.border}`, scrollMarginTop: '72px' }">
|
||||
<LandingContainer pad="120px 64px">
|
||||
<LandingSectionLabel :label="copy.pricing.label" />
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '80px', alignItems: 'center' }">
|
||||
<div>
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.fg }">{{ copy.pricing.heading }}</h2>
|
||||
<div :style="{ marginTop: '28px', maxWidth: '520px' }">
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '20px', lineHeight: 1.5, maxWidth: '640px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.pricing.lede }}</p>
|
||||
</div>
|
||||
<div :style="{ marginTop: '36px', display: 'flex', alignItems: 'center', gap: '16px' }">
|
||||
<LandingBtn variant="primary" size="lg">{{ copy.pricing.cta }} →</LandingBtn>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{ background: t.surface, border: `1px solid ${t.border}`, borderRadius: '6px', padding: '36px 36px' }">
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgDim, letterSpacing: '0.1em', textTransform: 'uppercase' }">{{ copy.pricing.teaser }}</div>
|
||||
<div :style="{ display: 'flex', alignItems: 'baseline', gap: '12px', marginTop: '12px' }">
|
||||
<span :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontSize: '96px', fontWeight: 600, letterSpacing: '-0.045em', lineHeight: 1, color: t.fg }">{{ copy.pricing.price }}</span>
|
||||
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '13px', color: t.fgMuted }">{{ copy.pricing.unit }}</span>
|
||||
</div>
|
||||
<div :style="{ height: '1px', background: t.border, margin: '28px 0' }" />
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '13px', lineHeight: 1.6, maxWidth: '640px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.pricing.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
// Section 01 — the problem. Ported from landing-sections.jsx Problem.
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :style="{ background: t.bg, color: t.fg }">
|
||||
<LandingContainer pad="120px 64px">
|
||||
<LandingSectionLabel :label="copy.problem.label" />
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1fr 1.4fr', gap: '80px' }">
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.fg }">{{ copy.problem.heading }}</h2>
|
||||
<div :style="{ display: 'flex', flexDirection: 'column', gap: '18px', paddingTop: '12px' }">
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '18px', lineHeight: 1.6, maxWidth: '620px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.problem.p1 }}</p>
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '18px', lineHeight: 1.6, maxWidth: '620px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.problem.p2 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
// Stylized customer-portal dashboard shown under the hero. Illustrative only —
|
||||
// labels are intentionally Danish in both languages. Ported from
|
||||
// landing-sections.jsx ProductMockup (light mode).
|
||||
import { computed } from 'vue'
|
||||
import { C } from '~/utils/landingTokens'
|
||||
import { useTheme, useDark } from '~/composables/useLanding'
|
||||
|
||||
const t = useTheme()
|
||||
const dark = useDark()
|
||||
|
||||
const m = computed(() => ({
|
||||
bg: dark.value ? '#171715' : '#FFFFFF',
|
||||
border: dark.value ? 'rgba(255,255,255,0.08)' : 'rgba(10,10,10,0.08)',
|
||||
fg: dark.value ? C.bone : C.carbon,
|
||||
muted: dark.value ? 'rgba(244,243,238,0.55)' : 'rgba(10,10,10,0.55)',
|
||||
subtle: dark.value ? 'rgba(244,243,238,0.08)' : 'rgba(10,10,10,0.04)',
|
||||
}))
|
||||
|
||||
const apps = [
|
||||
{ name: 'mail', badge: '12', active: true },
|
||||
{ name: 'drev' },
|
||||
{ name: 'møder' },
|
||||
{ name: 'chat', pill: '3' },
|
||||
{ name: 'admin' },
|
||||
]
|
||||
|
||||
const stats: [string, string, string][] = [
|
||||
['Ulæste', '12', 'mail'],
|
||||
['Møder i dag', '3', 'møder'],
|
||||
['Delte filer', '47', 'drev'],
|
||||
]
|
||||
|
||||
const recent: [string, string, string][] = [
|
||||
['Lone Frederiksen', 'Tilbud — Q3 retainer', '09:42'],
|
||||
['ops@stalwart.io', 'Sikkerhedsopdatering 1.12 udrullet', '08:30'],
|
||||
['Mads Holm', 'Re: Onboarding for nye seniors', 'i går'],
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{
|
||||
background: m.bg, border: `1px solid ${m.border}`, borderRadius: '8px',
|
||||
boxShadow: dark ? '0 40px 80px rgba(0,0,0,0.5)' : '0 30px 80px rgba(10,10,10,0.08)',
|
||||
overflow: 'hidden',
|
||||
}">
|
||||
<!-- Window chrome -->
|
||||
<div :style="{ display: 'flex', alignItems: 'center', gap: '8px', padding: '14px 18px', borderBottom: `1px solid ${m.border}` }">
|
||||
<span :style="{ width: '10px', height: '10px', borderRadius: '999px', background: '#E23030' }" />
|
||||
<span :style="{ width: '10px', height: '10px', borderRadius: '999px', background: '#E89A1F' }" />
|
||||
<span :style="{ width: '10px', height: '10px', borderRadius: '999px', background: '#1F8A5B' }" />
|
||||
<div :style="{ marginLeft: '16px', padding: '4px 12px', background: m.subtle, borderRadius: '4px', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: m.muted }">app.dezky.com / dashboard</div>
|
||||
</div>
|
||||
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '220px 1fr', minHeight: '460px' }">
|
||||
<!-- Sidebar -->
|
||||
<div :style="{ borderRight: `1px solid ${m.border}`, padding: '20px 0' }">
|
||||
<div :style="{ display: 'flex', alignItems: 'center', gap: '8px', padding: '0 20px', marginBottom: '24px' }">
|
||||
<BrandNodeMark :size="20" :fg="m.fg" :accent="t.signal" />
|
||||
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', fontWeight: 600, color: m.fg }">dezky</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(a, i) in apps" :key="a.name"
|
||||
:style="{
|
||||
padding: '9px 20px',
|
||||
background: a.active ? m.subtle : 'transparent',
|
||||
borderLeft: `2px solid ${a.active ? t.signal : 'transparent'}`,
|
||||
fontFamily: '\'Inter\', sans-serif', fontSize: '13px', fontWeight: a.active ? 600 : 500,
|
||||
color: a.active ? m.fg : m.muted,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}"
|
||||
>
|
||||
<span>{{ a.name }}</span>
|
||||
<span v-if="a.badge" :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '10px', color: m.muted }">{{ a.badge }}</span>
|
||||
<span v-else-if="a.pill" :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '10px', background: t.signal, padding: '1px 6px', borderRadius: '2px', color: C.carbon }">{{ a.pill }}</span>
|
||||
</div>
|
||||
<div :style="{ margin: '32px 20px 0', padding: '14px', borderRadius: '4px', background: m.subtle, fontFamily: '\'Inter\', sans-serif' }">
|
||||
<div :style="{ fontSize: '10px', fontFamily: '\'JetBrains Mono\', monospace', color: m.muted, letterSpacing: '0.1em', textTransform: 'uppercase' }">lagring</div>
|
||||
<div :style="{ fontSize: '16px', fontWeight: 600, color: m.fg, marginTop: '6px' }">184 GB / 500</div>
|
||||
<div :style="{ height: '4px', background: m.border, borderRadius: '999px', marginTop: '8px', overflow: 'hidden' }">
|
||||
<div :style="{ height: '100%', width: '37%', background: t.signal }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main -->
|
||||
<div :style="{ padding: '28px 32px' }">
|
||||
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }">
|
||||
<div>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: m.muted, letterSpacing: '0.1em', textTransform: 'uppercase' }">indbakke</div>
|
||||
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: '28px', color: m.fg, marginTop: '6px', letterSpacing: '-0.02em' }">god morgen, anne</div>
|
||||
</div>
|
||||
<div :style="{ display: 'flex', gap: '8px' }">
|
||||
<div :style="{ width: '32px', height: '32px', borderRadius: '999px', background: m.subtle, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: m.fg, fontWeight: 600 }">AB</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :style="{ marginTop: '24px', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px' }">
|
||||
<div v-for="(s, i) in stats" :key="i" :style="{ padding: '16px 18px', background: m.subtle, borderRadius: '4px' }">
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '10px', color: m.muted, letterSpacing: '0.1em', textTransform: 'uppercase' }">{{ s[2] }}</div>
|
||||
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontSize: '28px', fontWeight: 600, color: m.fg, marginTop: '4px' }">{{ s[1] }}</div>
|
||||
<div :style="{ fontSize: '12px', color: m.muted, marginTop: '2px' }">{{ s[0] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :style="{ marginTop: '24px' }">
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '10px', color: m.muted, letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: '12px' }">seneste · indbakke</div>
|
||||
<div
|
||||
v-for="(r, i) in recent" :key="i"
|
||||
:style="{
|
||||
display: 'grid', gridTemplateColumns: '200px 1fr 60px',
|
||||
padding: '12px 0', borderTop: i === 0 ? `1px solid ${m.border}` : 'none',
|
||||
borderBottom: `1px solid ${m.border}`,
|
||||
fontFamily: '\'Inter\', sans-serif', fontSize: '13px', alignItems: 'center',
|
||||
}"
|
||||
>
|
||||
<span :style="{ fontWeight: 600, color: m.fg }">{{ r[0] }}</span>
|
||||
<span :style="{ color: m.muted }">{{ r[1] }}</span>
|
||||
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: m.muted, textAlign: 'right' }">{{ r[2] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
// Monospace "NN — label" section header with an underline. Ported from
|
||||
// landing-sections.jsx SectionLabel. Pass the full "01 — udfordringen" string.
|
||||
import { useTheme } from '~/composables/useLanding'
|
||||
|
||||
const props = defineProps<{ label: string }>()
|
||||
const t = useTheme()
|
||||
|
||||
const parts = computed(() => {
|
||||
const [index, ...rest] = props.label.split(' — ')
|
||||
return { index, text: rest.join(' — ') }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{
|
||||
display: 'flex', alignItems: 'center', gap: '14px',
|
||||
paddingBottom: '24px', marginBottom: '56px',
|
||||
borderBottom: `1px solid ${t.border}`,
|
||||
fontFamily: '\'JetBrains Mono\', monospace',
|
||||
fontSize: '11px', letterSpacing: '0.16em', textTransform: 'uppercase',
|
||||
color: t.fgMuted,
|
||||
}">
|
||||
<span :style="{ color: t.fgDim }">{{ parts.index }}</span>
|
||||
<span>—</span>
|
||||
<span>{{ parts.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
// Section 04 — sovereignty. Inverted (carbon) panel with a heading/body column
|
||||
// and a spec table. Ported from landing-sections.jsx Sovereignty.
|
||||
import { computed } from 'vue'
|
||||
import { C } from '~/utils/landingTokens'
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
|
||||
// Borders/muted derive from whether the inverted foreground is bone (light
|
||||
// page → carbon panel → bone text) or carbon (dark page → bone panel).
|
||||
const onBone = computed(() => t.value.invertFg === C.bone)
|
||||
const rule14 = computed(() => (onBone.value ? 'rgba(244,243,238,0.14)' : 'rgba(10,10,10,0.14)'))
|
||||
const rule18 = computed(() => (onBone.value ? 'rgba(244,243,238,0.18)' : 'rgba(10,10,10,0.18)'))
|
||||
const rule10 = computed(() => (onBone.value ? 'rgba(244,243,238,0.1)' : 'rgba(10,10,10,0.1)'))
|
||||
const muted55 = computed(() => (onBone.value ? 'rgba(244,243,238,0.55)' : 'rgba(10,10,10,0.55)'))
|
||||
const muted70 = computed(() => (onBone.value ? 'rgba(244,243,238,0.7)' : 'rgba(10,10,10,0.7)'))
|
||||
|
||||
const labelParts = computed(() => {
|
||||
const [index, ...rest] = copy.value.sovereignty.label.split(' — ')
|
||||
return { index, text: rest.join(' — ') }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="sovereignty" :style="{ background: t.invert, color: t.invertFg, scrollMarginTop: '72px' }">
|
||||
<LandingContainer pad="140px 64px">
|
||||
<div :style="{
|
||||
display: 'flex', alignItems: 'center', gap: '14px',
|
||||
paddingBottom: '24px', marginBottom: '56px',
|
||||
borderBottom: `1px solid ${rule14}`,
|
||||
fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px',
|
||||
letterSpacing: '0.16em', textTransform: 'uppercase', color: muted55,
|
||||
}">
|
||||
<span :style="{ opacity: 0.6 }">{{ labelParts.index }}</span>
|
||||
<span>—</span>
|
||||
<span>{{ labelParts.text }}</span>
|
||||
</div>
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1.1fr 1fr', gap: '96px' }">
|
||||
<div>
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.invertFg }">{{ copy.sovereignty.heading }}</h2>
|
||||
<div :style="{ marginTop: '36px', display: 'flex', flexDirection: 'column', gap: '18px', maxWidth: '540px' }">
|
||||
<p v-for="(p, i) in copy.sovereignty.body" :key="i" :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '17px', lineHeight: 1.55, color: muted70, margin: 0, textWrap: 'pretty' }">{{ p }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{ paddingTop: '8px' }">
|
||||
<div
|
||||
v-for="(row, i) in copy.sovereignty.checks" :key="i"
|
||||
:style="{
|
||||
display: 'grid', gridTemplateColumns: '1fr 1.4fr', padding: '20px 0',
|
||||
borderTop: i === 0 ? `1px solid ${rule18}` : 'none',
|
||||
borderBottom: `1px solid ${rule10}`,
|
||||
fontFamily: '\'Inter\', sans-serif', fontSize: '14px', alignItems: 'baseline',
|
||||
}"
|
||||
>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: muted55, letterSpacing: '0.06em', textTransform: 'uppercase' }">{{ row[0] }}</div>
|
||||
<div :style="{ color: t.invertFg, fontWeight: 500 }">{{ row[1] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
// Section 07 — under the hood. Open-source component table.
|
||||
// Ported from landing-sections.jsx Stack.
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="stack" :style="{ background: t.bg, color: t.fg, scrollMarginTop: '72px' }">
|
||||
<LandingContainer pad="140px 64px">
|
||||
<LandingSectionLabel :label="copy.stack.label" />
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1.1fr 1fr', gap: '80px', alignItems: 'end', marginBottom: '56px' }">
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.fg }">{{ copy.stack.heading }}</h2>
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '20px', lineHeight: 1.5, maxWidth: '480px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.stack.lede }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-for="(row, i) in copy.stack.rows" :key="i"
|
||||
:style="{
|
||||
display: 'grid', gridTemplateColumns: '1fr 1.4fr 0.8fr 1fr 40px',
|
||||
gap: '24px', padding: '24px 0',
|
||||
borderTop: i === 0 ? `1px solid ${t.borderStrong}` : 'none',
|
||||
borderBottom: `1px solid ${t.border}`,
|
||||
alignItems: 'baseline', fontFamily: '\'Inter\', sans-serif', fontSize: '15px',
|
||||
}"
|
||||
>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: t.fgDim, letterSpacing: '0.08em', textTransform: 'uppercase' }">{{ row[0] }}</div>
|
||||
<div :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontSize: '20px', fontWeight: 600, color: t.fg, letterSpacing: '-0.015em' }">{{ row[1] }}</div>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fg, padding: '4px 10px', background: t.bgAlt, borderRadius: '3px', display: 'inline-block', justifySelf: 'start' }">{{ row[2] }}</div>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', color: t.fgMuted }">{{ row[3] }}</div>
|
||||
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '14px', color: t.fgDim, textAlign: 'right' }">↗</div>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
// Section 02 — the suite. Five module cards in a bordered row.
|
||||
// Ported from landing-sections.jsx Suite.
|
||||
import { useTheme, useCopy } from '~/composables/useLanding'
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="suite" :style="{ background: t.bgAlt, color: t.fg, borderTop: `1px solid ${t.border}`, borderBottom: `1px solid ${t.border}`, scrollMarginTop: '72px' }">
|
||||
<LandingContainer pad="120px 64px">
|
||||
<LandingSectionLabel :label="copy.suite.label" />
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1.1fr 1fr', gap: '80px', alignItems: 'end', marginBottom: '64px' }">
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.fg }">{{ copy.suite.heading }}</h2>
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '20px', lineHeight: 1.5, maxWidth: '520px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.suite.lede }}</p>
|
||||
</div>
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '0', border: `1px solid ${t.border}` }">
|
||||
<div
|
||||
v-for="(card, i) in copy.suite.cards" :key="i"
|
||||
:style="{
|
||||
padding: '32px 28px',
|
||||
borderRight: i < 4 ? `1px solid ${t.border}` : 'none',
|
||||
background: t.surface,
|
||||
display: 'flex', flexDirection: 'column', gap: '20px',
|
||||
minHeight: '280px',
|
||||
}"
|
||||
>
|
||||
<LandingModuleGlyph :name="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>
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '13.5px', lineHeight: 1.6, maxWidth: '260px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ card.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
// Section 06 — for partners (whitelabel). Pitch column + partner-brand demo.
|
||||
// Ported from landing-sections.jsx Whitelabel (light mode).
|
||||
import { computed } from 'vue'
|
||||
import { useTheme, useCopy, useDark } from '~/composables/useLanding'
|
||||
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
const dark = useDark()
|
||||
|
||||
const sectionBg = computed(() => (dark.value ? '#1A1A17' : '#EFEDE3'))
|
||||
const cardBg = computed(() => (dark.value ? '#0F0F0D' : '#FFFFFF'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="whitelabel" :style="{ background: sectionBg, color: t.fg, borderTop: `1px solid ${t.border}`, borderBottom: `1px solid ${t.border}`, scrollMarginTop: '72px' }">
|
||||
<LandingContainer pad="120px 64px">
|
||||
<LandingSectionLabel :label="copy.whitelabel.label" />
|
||||
<div :style="{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: '80px', alignItems: 'start' }">
|
||||
<div>
|
||||
<h2 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(36px, 4.4vw, 64px)', letterSpacing: '-0.032em', lineHeight: 1.0, margin: 0, textWrap: 'balance', color: t.fg }">{{ copy.whitelabel.heading }}</h2>
|
||||
<div :style="{ marginTop: '32px', maxWidth: '520px' }">
|
||||
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '20px', lineHeight: 1.5, maxWidth: '640px', color: t.fgMuted, margin: 0, textWrap: 'pretty' }">{{ copy.whitelabel.lede }}</p>
|
||||
</div>
|
||||
<div :style="{ marginTop: '32px', display: 'flex', flexDirection: 'column', gap: '12px' }">
|
||||
<div v-for="(b, i) in copy.whitelabel.bullets" :key="i" :style="{ display: 'flex', alignItems: 'center', gap: '14px', fontFamily: '\'Inter\', sans-serif', fontSize: '15px', color: t.fg }">
|
||||
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '10px', color: t.fgDim, width: '24px' }">0{{ i + 1 }}</span>
|
||||
<span>{{ b }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{ marginTop: '40px' }">
|
||||
<LandingBtn variant="secondary" size="lg">{{ copy.whitelabel.cta }} →</LandingBtn>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{ display: 'flex', flexDirection: 'column', gap: '16px' }">
|
||||
<LandingPartnerCard :fg="t.fg" :bg="cardBg" :border="t.border" accent="#D6502A" name="moltke it" subtitle="aalborg · 24 brugere" />
|
||||
<LandingPartnerCard :fg="t.fg" :bg="cardBg" :border="t.border" accent="#3956C8" name="kraft & partners" subtitle="københavn · 112 brugere" />
|
||||
<LandingPartnerCard :fg="t.fg" :bg="cardBg" :border="t.border" :accent="t.signal" name="dit firma her" subtitle="—" placeholder />
|
||||
</div>
|
||||
</div>
|
||||
</LandingContainer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { computed } from 'vue'
|
||||
import { COPY, type Lang } from '~/utils/landingCopy'
|
||||
import { makeTheme } from '~/utils/landingTokens'
|
||||
|
||||
// Shared landing state. `lang` is a real production toggle (da/en, both fully
|
||||
// translated). `dark` is kept as machinery from the design's Tweaks panel but
|
||||
// defaults to light — the primary theme the user landed on — and no toggle is
|
||||
// surfaced. Flip the default (or add a control) to enable dark later.
|
||||
export const useLang = () => useState<Lang>('dz-lang', () => 'da')
|
||||
export const useDark = () => useState<boolean>('dz-dark', () => false)
|
||||
|
||||
export const useTheme = () => {
|
||||
const dark = useDark()
|
||||
return computed(() => makeTheme(dark.value))
|
||||
}
|
||||
|
||||
export const useCopy = () => {
|
||||
const lang = useLang()
|
||||
return computed(() => COPY[lang.value === 'en' ? 'en' : 'da'])
|
||||
}
|
||||
|
||||
export function toggleLang() {
|
||||
const lang = useLang()
|
||||
lang.value = lang.value === 'da' ? 'en' : 'da'
|
||||
}
|
||||
|
||||
// Smooth-scroll to an in-page anchor, accounting for the sticky 72px nav.
|
||||
// Non-anchor / placeholder links (#) are ignored.
|
||||
export function scrollToAnchor(hash: string) {
|
||||
if (!hash || hash === '#' || !hash.startsWith('#')) return
|
||||
const el = document.getElementById(hash.slice(1))
|
||||
if (!el) return
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - 72
|
||||
window.scrollTo({ top, behavior: 'smooth' })
|
||||
history.replaceState(null, '', hash)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="site">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
Minimal marketing-site layout. Intentionally bare — the Claude design
|
||||
handoff for the landing page will introduce the real header/footer chrome.
|
||||
-->
|
||||
<style scoped>
|
||||
.site {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
// Nuxt 4 configuration for the Dezky public marketing site (dezky.com).
|
||||
//
|
||||
// Unlike apps/portal and apps/operator this surface is fully public — no
|
||||
// OIDC, no sessions, no platform-api coupling. It can be statically
|
||||
// generated (`pnpm generate`) and served from a CDN/edge independently of
|
||||
// the Docker app stack. Locally it runs behind Traefik at dezky.local /
|
||||
// www.dezky.local with the same mkcert TLS as the rest of the platform.
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2026-01-01',
|
||||
devtools: { enabled: true },
|
||||
|
||||
css: ['~/assets/styles/tokens.css', '~/assets/styles/base.css'],
|
||||
|
||||
// Auto-import from the shared packages/ui workspace in addition to the
|
||||
// app's own components/. /shared-packages is bind-mounted in
|
||||
// docker-compose.yml — outside containers the same files live at
|
||||
// <repo>/packages/ui/components/. The local dir keeps the default
|
||||
// directory-based prefix; the shared dir uses no prefix so a shared
|
||||
// CountrySelect.vue is just <CountrySelect>. Mirrors portal/operator.
|
||||
components: [
|
||||
'~/components',
|
||||
{ path: '/shared-packages/ui/components', pathPrefix: false },
|
||||
],
|
||||
|
||||
app: {
|
||||
head: {
|
||||
// Marketing site is light by default (the design's primary theme). The
|
||||
// page sets <html lang> reactively based on the da/en toggle.
|
||||
htmlAttrs: { lang: 'da' },
|
||||
link: [
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
vite: {
|
||||
server: {
|
||||
// Vite 7 added a strict host check; allow the Traefik-fronted hostnames
|
||||
// this site is served on in dev.
|
||||
allowedHosts: ['dezky.local', 'www.dezky.local'],
|
||||
hmr: {
|
||||
protocol: 'wss',
|
||||
clientPort: 443,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@dezky/website",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Dezky public marketing site — dezky.com landing pages (Nuxt 4)",
|
||||
"scripts": {
|
||||
"dev": "nuxt dev --host 0.0.0.0 --port 3000",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"typecheck": "nuxt typecheck",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"nuxt": "^4.4.6",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vue-tsc": "^3.2.6"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.0"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
// Dezky marketing landing page. Ported from the Claude Design handoff
|
||||
// (Landing Page.html → landing-app.jsx + landing-sections.jsx). Light theme,
|
||||
// Danish default, hero variant A — the production defaults the user landed on.
|
||||
// Section order matches the design exactly.
|
||||
import { useTheme, useCopy, useLang } from '~/composables/useLanding'
|
||||
|
||||
const t = useTheme()
|
||||
const copy = useCopy()
|
||||
const lang = useLang()
|
||||
|
||||
const description = computed(() => copy.value.hero.sub)
|
||||
|
||||
useHead({
|
||||
title: 'dezky · suveræn produktivitet',
|
||||
htmlAttrs: { lang },
|
||||
meta: [
|
||||
{ name: 'description', content: description },
|
||||
],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{ background: t.bg, color: t.fg, minHeight: '100vh' }">
|
||||
<LandingNav />
|
||||
<LandingHero />
|
||||
<LandingProblem />
|
||||
<LandingSuite />
|
||||
<LandingHowItWorks />
|
||||
<LandingSovereignty />
|
||||
<LandingCompare />
|
||||
<LandingWhitelabel />
|
||||
<LandingStack />
|
||||
<LandingPricing />
|
||||
<LandingFaq />
|
||||
<LandingFinalCta />
|
||||
<LandingFooter />
|
||||
</div>
|
||||
</template>
|
||||
Generated
+7070
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Dezky landing — all copy, da + en. Ported verbatim from the Claude Design
|
||||
// handoff (landing-sections.jsx COPY). Headline arrays mix plain strings with
|
||||
// { hl } objects: the hl phrase gets the signal-yellow highlighter brush.
|
||||
|
||||
export type HeadlinePart = string | { hl: string }
|
||||
|
||||
export const COPY = {
|
||||
da: {
|
||||
nav: { product: 'produkt', security: 'sikkerhed', whitelabel: 'whitelabel', pricing: 'priser', docs: 'docs', login: 'log ind', cta: 'book en demo' },
|
||||
hero: {
|
||||
eyebrow: '// suveræn produktivitet · v1.0',
|
||||
headlineA: ['Den produktivitetssuite,', { hl: 'dine data bliver i Danmark med.' }] as HeadlinePart[],
|
||||
headlineB: ['Værktøjerne du kender.', { hl: 'Suveræniteten du har brug for.' }] as HeadlinePart[],
|
||||
sub: 'Mail, filer, video, chat og login — fuldt integreret, hostet i EU, uden lock-in. Bygget på licensren open source.',
|
||||
cta: 'Book en demo',
|
||||
sub_cta: 'Se hvordan det virker',
|
||||
trust: ['Drevet af open source', 'Hostet i EU', 'Bygget i Danmark'],
|
||||
},
|
||||
problem: {
|
||||
label: '01 — udfordringen',
|
||||
heading: 'Det er blevet juridisk svært at lade som om, at amerikansk cloud er neutral.',
|
||||
p1: 'Schrems II gjorde transatlantiske dataoverførsler juridisk skrøbelige. CLOUD Act betyder, at amerikanske udbydere kan tvinges til at udlevere data, uanset hvor det er hostet.',
|
||||
p2: 'M365 og Workspace ændrer priser og licensvilkår uden varsel. Lock-in er en strategisk risiko, de fleste SMB\'er først opdager, når de prøver at flytte.',
|
||||
},
|
||||
suite: {
|
||||
label: '02 — suiten',
|
||||
heading: 'Alt det du forventer. Intet du ikke vil have.',
|
||||
lede: 'Fem moduler. Ét login. Bygget til at virke sammen — ikke bare leve i samme browser.',
|
||||
cards: [
|
||||
{ name: 'Mail', tag: 'mail · kalender · kontakter', desc: 'Domæne-mail, kalender og kontakter med fuld kompatibilitet til Outlook og Apple Mail via IMAP, CalDAV og CardDAV.' },
|
||||
{ name: 'Drev', tag: 'filer · deling · versioner', desc: 'Filer i skyen med deling, versionering og indbygget redigering i Office-formater. Synk-klient til Mac, Windows og Linux.' },
|
||||
{ name: 'Møder', tag: 'video · skærmdeling', desc: 'Videomøder i browseren. Ingen download. Skærmdeling, optagelse og baggrundsudviskning out-of-the-box.' },
|
||||
{ name: 'Chat', tag: 'kanaler · tråde · søgning', desc: 'Team-chat med tråde, kanaler og fuld historiksøgning. Designet til at læses asynkront, ikke til at afbryde.' },
|
||||
{ name: 'Login & adgang', tag: 'sso · mfa · livscyklus', desc: 'Single sign-on, multifaktor og brugerstyring i ét panel. Tilføj én bruger — de får mail, drev, møder og chat med det samme.' },
|
||||
],
|
||||
},
|
||||
how: {
|
||||
label: '03 — opsætning',
|
||||
heading: 'Tre skridt. Ingen migrationskonsulent.',
|
||||
steps: [
|
||||
{ n: '01', title: 'Tag dit domæne med.', body: 'Vi hjælper med DNS-opsætning. Dine brugere beholder deres @ditfirma.dk-adresser.' },
|
||||
{ n: '02', title: 'Oprett dit team.', body: 'Tilføj brugere én gang — de får mail, filer, chat og video bag single sign-on.' },
|
||||
{ n: '03', title: 'Brug det som du plejer.', body: 'Velkendte web- og mobil-apps. Migration fra M365 og Google Workspace er inkluderet.' },
|
||||
],
|
||||
},
|
||||
sovereignty: {
|
||||
label: '04 — suverænitet',
|
||||
heading: 'Dine data falder under dansk lov. Punktum.',
|
||||
body: ['Dezky kører i EU-datacentre med Tier III-certificering. Krypteret i hvile og i transit. Vi har ingen amerikansk moder, ingen amerikansk datterselskab, og ingen forretningsmæssig grund til at lade os tvinge af en udenlandsk dommer.', 'For virksomheder i regulerede sektorer kan vi tilbyde kundekontrollerede krypteringsnøgler (BYOK), så selv vi ikke kan læse jeres data.'],
|
||||
checks: [
|
||||
['Datajurisdiktion', 'EU · Tyskland'],
|
||||
['Datacentre', 'Hetzner · Tyskland'],
|
||||
['Kryptering', 'AES-256 i hvile · TLS 1.3 i transit'],
|
||||
['BYOK', 'Tilgængelig på Enterprise'],
|
||||
['Audit-log', 'Indbygget · 13 mdr. retention'],
|
||||
['Compliance', 'GDPR · NIS2-readiness · ISO 27001 (på vej)'],
|
||||
],
|
||||
},
|
||||
compare: {
|
||||
label: '05 — sammenligning',
|
||||
heading: 'Dezky vs. den amerikanske standard.',
|
||||
lede: 'Vi er ikke billigere fordi vi er værre. Vi har bare færre forpligtelser overfor amerikanske aktieanalytikere.',
|
||||
cols: ['Dezky', 'Amerikansk hyperscaler'],
|
||||
rows: [
|
||||
['Datajurisdiktion', 'EU · Tyskland', 'USA (CLOUD Act gælder)'],
|
||||
['Licensgrundlag', 'Apache 2.0 / MIT', 'Proprietær'],
|
||||
['Whitelabel', 'Inkluderet', 'Ikke tilgængeligt'],
|
||||
['Pris-forudsigelighed', 'Fastlåst i kontraktperiode', 'Kan ændres ensidigt'],
|
||||
['Dansk support', 'Indbygget', 'Begrænset · ofte engelsk'],
|
||||
['Migrationshjælp', 'Inkluderet', 'DIY eller partner'],
|
||||
],
|
||||
},
|
||||
whitelabel: {
|
||||
label: '06 — for partnere',
|
||||
heading: 'Sælg det som dit eget.',
|
||||
lede: 'MSP\'er og IT-konsulenthuse: kør Dezky under jeres brand. Eget domæne, eget logo, egen prissætning. Vi leverer platformen — I leverer relationen.',
|
||||
bullets: [
|
||||
'Fuldt whitelabel-tema · CSS og logo',
|
||||
'Multi-tenant administration',
|
||||
'Marginer på 30–45 % afhængigt af volumen',
|
||||
'Co-marketing og kundeleads via partnernetværk',
|
||||
],
|
||||
cta: 'Se partnerprogrammet',
|
||||
},
|
||||
stack: {
|
||||
label: '07 — under motorhjelmen',
|
||||
heading: 'Bygget på open source. Verificerbart.',
|
||||
lede: 'Vi skjuler det ikke. Hver komponent er licensren open source — du kan inspicere koden, kompilere den selv, eller flytte din installation et andet sted hen.',
|
||||
rows: [
|
||||
['Mail', 'Stalwart Mail', 'AGPL-3.0', 'stalw.art'],
|
||||
['Filer & drev', 'ownCloud Infinite Scale', 'Apache 2.0', 'owncloud.dev'],
|
||||
['Videomøder', 'Jitsi', 'Apache 2.0', 'jitsi.org'],
|
||||
['Team chat', 'Zulip', 'Apache 2.0', 'zulip.com'],
|
||||
['Identitet & SSO', 'Authentik', 'MIT', 'goauthentik.io'],
|
||||
],
|
||||
},
|
||||
pricing: {
|
||||
label: '08 — priser',
|
||||
heading: 'Forudsigelig pris. Ingen overraskelser.',
|
||||
lede: 'Vi er i et lukket beta-program indtil sommeren 2026. Prisen sættes sammen med vores første kunder — ikke imod dem.',
|
||||
teaser: 'Starter fra',
|
||||
price: '69',
|
||||
unit: 'DKK / bruger / md.',
|
||||
note: 'Endelig prissætning bekræftes ved demo. Volumenrabat fra 25 brugere.',
|
||||
cta: 'Book en demo for priser',
|
||||
},
|
||||
faq: {
|
||||
label: '09 — ofte stillede spørgsmål',
|
||||
heading: 'Det vi bliver spurgt om.',
|
||||
items: [
|
||||
['Hvordan virker migration fra Microsoft 365?', 'Vi flytter mail, kalender, kontakter og OneDrive-filer i baggrunden, mens jeres team arbejder videre. Skifte-dagen er en DNS-opdatering. Typisk forløb er 2–4 uger for 50 brugere.'],
|
||||
['Kan jeg stadig bruge Outlook og Office?', 'Ja. Mail, kalender og kontakter virker via IMAP, CalDAV og CardDAV. Drev-filer åbnes med Office desktop via WebDAV. Vi anbefaler vores web- og mobil-apps som primært valg, men kravet er ikke at I skifter vaner.'],
|
||||
['Hvor er data hosted?', 'Hos Hetzner i Tyskland. Tier III-certificerede datacentre, redundant strøm og netværk, ISO 27001-certificeret operatør. Ingen data forlader EU på noget tidspunkt — ikke for analytics, logs eller support.'],
|
||||
['Hvad sker der hvis Dezky lukker?', 'Hele stakken er open source. I kan eksportere alt og flytte til en anden Dezky-partner. Vores forretningsmodel er drift, ikke gidseltagning.'],
|
||||
['Hvad er jeres SLA?', '99,9 % uptime garanteret på alle planer. 99,95 % på Enterprise. Status-side med real-time data offentligt tilgængelig på status.dezky.com.'],
|
||||
['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.'],
|
||||
],
|
||||
},
|
||||
finalCta: {
|
||||
heading: ['Klar til at få', { hl: 'dine data hjem' }, '?'] as HeadlinePart[],
|
||||
sub: '30 minutters demo. Ingen salgspres. Ingen slides.',
|
||||
cta: 'Book en demo',
|
||||
},
|
||||
footer: {
|
||||
tagline: 'Suveræn produktivitet til danske virksomheder.',
|
||||
legal: { name: 'Dezky ApS', cvr: 'CVR 44 12 89 03', addr: 'Refshalevej 153A · 1432 København K' },
|
||||
cols: [
|
||||
['Produkt', [['Funktioner', '#suite'], ['Sikkerhed', '#sovereignty'], ['Roadmap', '#'], ['Status', '#'], ['Changelog', '#']]],
|
||||
['Selskab', [['Om os', '#'], ['Kunder', '#'], ['Karriere', '#'], ['Presse', '#'], ['Kontakt', '#']]],
|
||||
['Ressourcer', [['Docs', '#'], ['Migrationsguide', '#'], ['Partnere', '#whitelabel'], ['Blog', '#'], ['Brand', '#']]],
|
||||
['Juridisk', [['Privatlivspolitik', '#'], ['Databehandler', '#'], ['Vilkår', '#'], ['SLA', '#'], ['Cookies', '#']]],
|
||||
] as [string, [string, string][]][],
|
||||
copyright: '© 2026 Dezky ApS. Alle rettigheder forbeholdes.',
|
||||
status: 'status · alle systemer kører',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
nav: { product: 'product', security: 'security', whitelabel: 'whitelabel', pricing: 'pricing', docs: 'docs', login: 'log in', cta: 'book a demo' },
|
||||
hero: {
|
||||
eyebrow: '// sovereign productivity · v1.0',
|
||||
headlineA: ['The productivity suite', { hl: 'your data stays in Denmark with.' }] as HeadlinePart[],
|
||||
headlineB: ['Tools you already know.', { hl: 'Sovereignty you actually need.' }] as HeadlinePart[],
|
||||
sub: 'Mail, files, video, chat and SSO — fully integrated, EU-hosted, no lock-in. Built on permissively licensed open source.',
|
||||
cta: 'Book a demo',
|
||||
sub_cta: 'See how it works',
|
||||
trust: ['Powered by open source', 'Hosted in the EU', 'Built in Denmark'],
|
||||
},
|
||||
problem: {
|
||||
label: '01 — the problem',
|
||||
heading: 'It got legally hard to pretend American cloud is neutral.',
|
||||
p1: 'Schrems II made transatlantic data transfers legally fragile. The CLOUD Act lets US providers be compelled to disclose data regardless of where it sits physically.',
|
||||
p2: 'M365 and Workspace change pricing and license terms without notice. Lock-in is a strategic risk most SMBs only discover when they try to leave.',
|
||||
},
|
||||
suite: {
|
||||
label: '02 — the suite',
|
||||
heading: 'Everything you expect. Nothing you don\'t want.',
|
||||
lede: 'Five modules. One login. Built to work together — not just live in the same browser.',
|
||||
cards: [
|
||||
{ name: 'Mail', tag: 'mail · calendar · contacts', desc: 'Domain mail, calendar and contacts with full Outlook and Apple Mail compatibility via IMAP, CalDAV and CardDAV.' },
|
||||
{ name: 'Drive', tag: 'files · sharing · versions', desc: 'Cloud files with sharing, versioning and built-in Office-format editing. Sync clients for Mac, Windows and Linux.' },
|
||||
{ name: 'Meet', tag: 'video · screen share', desc: 'Video meetings in the browser. No download. Screen share, recording and background blur out of the box.' },
|
||||
{ name: 'Chat', tag: 'channels · threads · search', desc: 'Team chat with threads, channels and full history search. Designed to be read async — not to interrupt.' },
|
||||
{ name: 'Identity', tag: 'sso · mfa · lifecycle', desc: 'Single sign-on, multi-factor and user lifecycle in one panel. Add a user once — they get mail, drive, meet and chat instantly.' },
|
||||
],
|
||||
},
|
||||
how: {
|
||||
label: '03 — setup',
|
||||
heading: 'Three steps. No migration consultant.',
|
||||
steps: [
|
||||
{ n: '01', title: 'Bring your domain.', body: 'We help with DNS. Your users keep their @yourcompany.dk addresses.' },
|
||||
{ n: '02', title: 'Provision your team.', body: 'Add users once — they get mail, drive, chat and video behind single sign-on.' },
|
||||
{ n: '03', title: 'Use it like you always have.', body: 'Familiar web and mobile apps. Migration from M365 and Google Workspace is included.' },
|
||||
],
|
||||
},
|
||||
sovereignty: {
|
||||
label: '04 — sovereignty',
|
||||
heading: 'Your data lives under Danish law. Full stop.',
|
||||
body: ['Dezky runs in EU data centers, Tier III certified, encrypted at rest and in transit. We have no US parent, no US subsidiary, and no commercial reason to roll over for a foreign judge.', 'For regulated industries, we offer customer-controlled encryption keys (BYOK) — so even we can\'t read your data.'],
|
||||
checks: [
|
||||
['Data jurisdiction', 'EU · Germany'],
|
||||
['Data centers', 'Hetzner · Germany'],
|
||||
['Encryption', 'AES-256 at rest · TLS 1.3 in transit'],
|
||||
['BYOK', 'Available on Enterprise'],
|
||||
['Audit log', 'Built-in · 13-month retention'],
|
||||
['Compliance', 'GDPR · NIS2-ready · ISO 27001 (in progress)'],
|
||||
],
|
||||
},
|
||||
compare: {
|
||||
label: '05 — comparison',
|
||||
heading: 'Dezky vs. the American default.',
|
||||
lede: 'We\'re not cheaper because we\'re worse. We just have fewer obligations to American equity analysts.',
|
||||
cols: ['Dezky', 'US hyperscaler'],
|
||||
rows: [
|
||||
['Data jurisdiction', 'EU · Germany', 'US (CLOUD Act applies)'],
|
||||
['License basis', 'Apache 2.0 / MIT', 'Proprietary'],
|
||||
['Whitelabel', 'Included', 'Not available'],
|
||||
['Pricing predictability', 'Locked for contract term', 'Subject to unilateral change'],
|
||||
['Danish support', 'Built-in', 'Limited · often English'],
|
||||
['Migration help', 'Included', 'DIY or partner'],
|
||||
],
|
||||
},
|
||||
whitelabel: {
|
||||
label: '06 — for partners',
|
||||
heading: 'Sell it as your own.',
|
||||
lede: 'MSPs and IT consultancies: run Dezky under your brand. Your domain, your logo, your pricing. We provide the platform — you own the relationship.',
|
||||
bullets: [
|
||||
'Full whitelabel theme · CSS and logo',
|
||||
'Multi-tenant administration',
|
||||
'Margins of 30–45% by volume',
|
||||
'Co-marketing and leads via partner network',
|
||||
],
|
||||
cta: 'See the partner program',
|
||||
},
|
||||
stack: {
|
||||
label: '07 — under the hood',
|
||||
heading: 'Built on open source. Verifiable.',
|
||||
lede: 'We don\'t hide it. Every component is permissively licensed — you can inspect the code, build it yourself, or move your installation elsewhere.',
|
||||
rows: [
|
||||
['Mail', 'Stalwart Mail', 'AGPL-3.0', 'stalw.art'],
|
||||
['Files & drive', 'ownCloud Infinite Scale', 'Apache 2.0', 'owncloud.dev'],
|
||||
['Video meetings', 'Jitsi', 'Apache 2.0', 'jitsi.org'],
|
||||
['Team chat', 'Zulip', 'Apache 2.0', 'zulip.com'],
|
||||
['Identity & SSO', 'Authentik', 'MIT', 'goauthentik.io'],
|
||||
],
|
||||
},
|
||||
pricing: {
|
||||
label: '08 — pricing',
|
||||
heading: 'Predictable pricing. No surprises.',
|
||||
lede: 'We\'re in a closed beta until summer 2026. Pricing is set with our first customers — not against them.',
|
||||
teaser: 'Starting at',
|
||||
price: '69',
|
||||
unit: 'DKK / user / mo.',
|
||||
note: 'Final pricing confirmed at demo. Volume discount from 25 users.',
|
||||
cta: 'Book a demo for pricing',
|
||||
},
|
||||
faq: {
|
||||
label: '09 — questions',
|
||||
heading: 'What we get asked.',
|
||||
items: [
|
||||
['How does migration from Microsoft 365 work?', 'We move mail, calendar, contacts and OneDrive files in the background while your team keeps working. Cutover day is a DNS update. Typical timeline is 2–4 weeks for 50 users.'],
|
||||
['Can I still use Outlook and Office?', 'Yes. Mail, calendar and contacts work via IMAP, CalDAV and CardDAV. Drive files open with Office desktop via WebDAV. We recommend our web and mobile apps, but we don\'t require you to change habits.'],
|
||||
['Where is data hosted?', 'With Hetzner in Germany. Tier III certified data centers, redundant power and network, ISO 27001 certified operator. No data leaves the EU at any time — not for analytics, logs or support.'],
|
||||
['What happens if Dezky shuts down?', 'The whole stack is open source. You can export everything and move to another Dezky partner. Our business model is operations — not hostage-taking.'],
|
||||
['What\'s your SLA?', '99.9% uptime guaranteed on all plans. 99.95% on Enterprise. Public real-time status page at status.dezky.com.'],
|
||||
['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.'],
|
||||
],
|
||||
},
|
||||
finalCta: {
|
||||
heading: ['Ready to bring', { hl: 'your data home' }, '?'] as HeadlinePart[],
|
||||
sub: '30-minute demo. No sales pressure. No slides.',
|
||||
cta: 'Book a demo',
|
||||
},
|
||||
footer: {
|
||||
tagline: 'Sovereign productivity for Danish business.',
|
||||
legal: { name: 'Dezky ApS', cvr: 'CVR 44 12 89 03', addr: 'Refshalevej 153A · 1432 Copenhagen K' },
|
||||
cols: [
|
||||
['Product', [['Features', '#suite'], ['Security', '#sovereignty'], ['Roadmap', '#'], ['Status', '#'], ['Changelog', '#']]],
|
||||
['Company', [['About', '#'], ['Customers', '#'], ['Careers', '#'], ['Press', '#'], ['Contact', '#']]],
|
||||
['Resources', [['Docs', '#'], ['Migration guide', '#'], ['Partners', '#whitelabel'], ['Blog', '#'], ['Brand', '#']]],
|
||||
['Legal', [['Privacy', '#'], ['DPA', '#'], ['Terms', '#'], ['SLA', '#'], ['Cookies', '#']]],
|
||||
] as [string, [string, string][]][],
|
||||
copyright: '© 2026 Dezky ApS. All rights reserved.',
|
||||
status: 'status · all systems operational',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export type Lang = keyof typeof COPY
|
||||
@@ -0,0 +1,79 @@
|
||||
// Dezky landing — brand palette, locked logo geometry, and theme helper.
|
||||
// Ported from the Claude Design handoff (landing-sections.jsx). The marketing
|
||||
// site is self-contained colour-wise: it does NOT consume the app's tokens.css
|
||||
// dark/light variables — it threads a `theme` object through components exactly
|
||||
// like the prototype did, so the handoff stays pixel-faithful.
|
||||
|
||||
export const C = {
|
||||
carbon: '#0A0A0A',
|
||||
signal: '#D4FF3A',
|
||||
bone: '#F4F3EE',
|
||||
slate: '#3D3D38',
|
||||
fog: '#E6E4DC',
|
||||
paper: '#FAFAF7',
|
||||
ok: '#1F8A5B',
|
||||
warn: '#E89A1F',
|
||||
bad: '#E23030',
|
||||
} as const
|
||||
|
||||
// Locked Node-mark geometry — the values the user dialled in via Tweaks and
|
||||
// then froze (chat1). Every NodeMark usage in the design passed exactly these.
|
||||
export const LOCKED = {
|
||||
bowlR: 14,
|
||||
stemW: 7,
|
||||
contR: 22,
|
||||
dStyle: 'donut',
|
||||
dotPos: 'corner',
|
||||
dotR: 4,
|
||||
} as const
|
||||
|
||||
export interface DezkyTheme {
|
||||
bg: string
|
||||
bgAlt: string
|
||||
surface: string
|
||||
surfaceAlt: string
|
||||
border: string
|
||||
borderStrong: string
|
||||
fg: string
|
||||
fgMuted: string
|
||||
fgDim: string
|
||||
invert: string
|
||||
invertFg: string
|
||||
signal: string
|
||||
}
|
||||
|
||||
export function makeTheme(dark: boolean): DezkyTheme {
|
||||
return dark
|
||||
? {
|
||||
bg: '#0A0A0A',
|
||||
bgAlt: '#121211',
|
||||
surface: '#171715',
|
||||
surfaceAlt: '#1F1F1C',
|
||||
border: 'rgba(255,255,255,0.08)',
|
||||
borderStrong: 'rgba(255,255,255,0.18)',
|
||||
fg: '#F4F3EE',
|
||||
fgMuted: 'rgba(244,243,238,0.62)',
|
||||
fgDim: 'rgba(244,243,238,0.42)',
|
||||
invert: '#F4F3EE',
|
||||
invertFg: '#0A0A0A',
|
||||
signal: C.signal,
|
||||
}
|
||||
: {
|
||||
bg: C.paper,
|
||||
bgAlt: C.bone,
|
||||
surface: '#FFFFFF',
|
||||
surfaceAlt: C.bone,
|
||||
border: C.fog,
|
||||
borderStrong: 'rgba(10,10,10,0.14)',
|
||||
fg: C.carbon,
|
||||
fgMuted: 'rgba(10,10,10,0.62)',
|
||||
fgDim: 'rgba(10,10,10,0.42)',
|
||||
invert: C.carbon,
|
||||
invertFg: C.bone,
|
||||
signal: C.signal,
|
||||
}
|
||||
}
|
||||
|
||||
// The destination the nav/login CTA points at. Production is app.dezky.com;
|
||||
// locally the portal runs at app.dezky.local.
|
||||
export const APP_URL = 'https://app.dezky.local'
|
||||
Reference in New Issue
Block a user