feat(website): gated coming-soon holding page with email signup

Add a standalone bilingual /coming-soon page (branded, dark, email signup
via mailto:info@dezky.eu, fires a waitlist-signup Umami event, noindex) plus
a global middleware that redirects every route to the locale-correct holding
page while NUXT_PUBLIC_COMING_SOON=true.

- Env-gated (default off, so dev and the live site are unaffected); flip the
  env in Coolify to show/hide the holding page with no code change.
- Preview the real site behind the gate via ?preview=<previewToken>
  (NUXT_PUBLIC_PREVIEW_TOKEN), which sets a 7-day cookie.
- Locale-preserving redirects (/da/* -> /da/coming-soon), no loops.
This commit is contained in:
Ronni Baslund
2026-06-06 21:51:26 +02:00
parent c9e22ec117
commit 04191193c2
4 changed files with 126 additions and 0 deletions
@@ -0,0 +1,25 @@
// Holding-page gate. While NUXT_PUBLIC_COMING_SOON=true, every route is
// redirected to the locale-appropriate /coming-soon page so the unfinished
// site stays hidden. Flip the env to false (in Coolify) to launch — no code
// change. The team can preview the real site via ?preview=<token>, which sets
// a short-lived cookie so subsequent navigation stays unlocked.
export default defineNuxtRouteMiddleware((to) => {
const config = useRuntimeConfig()
if (!config.public.comingSoon) return
// Never redirect the holding page itself (avoids a loop).
if (to.path === '/coming-soon' || to.path === '/da/coming-soon') return
const token = config.public.previewToken as string
const bypass = useCookie<string | null>('dz_preview', {
maxAge: 60 * 60 * 24 * 7, sameSite: 'lax', path: '/',
})
if (token && to.query.preview === token) {
bypass.value = '1'
return
}
if (bypass.value === '1') return
const isDa = to.path === '/da' || to.path.startsWith('/da/')
return navigateTo(isDa ? '/da/coming-soon' : '/coming-soon')
})
+6
View File
@@ -76,6 +76,12 @@ export default defineNuxtConfig({
runtimeConfig: { runtimeConfig: {
public: { public: {
siteUrl, siteUrl,
// Holding-page gate. Set NUXT_PUBLIC_COMING_SOON=true (Coolify env) to
// show the coming-soon page for every route; flip to false to launch.
// Preview the real site behind the gate with ?preview=<previewToken>
// (override the token via NUXT_PUBLIC_PREVIEW_TOKEN).
comingSoon: false,
previewToken: 'dezky-preview',
}, },
}, },
+79
View File
@@ -0,0 +1,79 @@
<script setup lang="ts">
// Gated "coming soon" holding page. Shown for every route while
// NUXT_PUBLIC_COMING_SOON=true (see middleware/coming-soon.global.ts). Interim
// signup composes an email to info@dezky.eu — swap for a real list later.
import { ref } from 'vue'
import { C } from '~/utils/landingTokens'
import { useCopy, useLang, track } from '~/composables/useLanding'
definePageMeta({ layout: false })
const copy = useCopy()
const lang = useLang()
const c = computed(() => copy.value.waitlist)
const switchLocalePath = useSwitchLocalePath()
const email = ref('')
function submit() {
const subject = lang.value === 'da' ? 'Skriv mig op til dezky' : 'Add me to the dezky waitlist'
const body = lang.value === 'da'
? `Skriv mig op til ventelisten: ${email.value}`
: `Please add me to the waitlist: ${email.value}`
track('waitlist-signup')
window.location.href = `mailto:info@dezky.eu?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`
}
function segStyle(code: 'en' | 'da') {
const active = lang.value === code
return {
padding: '5px 9px', border: 'none', cursor: 'pointer', borderRadius: '3px',
fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', letterSpacing: '0.08em',
background: active ? C.bone : 'transparent',
color: active ? C.carbon : 'rgba(244,243,238,0.6)',
fontWeight: active ? 700 : 500,
}
}
useHead({
title: () => (lang.value === 'da' ? 'dezky — snart klar' : 'dezky — coming soon'),
meta: [{ name: 'robots', content: 'noindex, nofollow' }],
})
</script>
<template>
<div :style="{ minHeight: '100vh', background: C.carbon, color: C.bone, display: 'flex', flexDirection: 'column', padding: 'clamp(24px, 5vw, 56px)' }">
<!-- Top bar: wordmark + language switcher -->
<div :style="{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }">
<div :style="{ display: 'flex', alignItems: 'center', gap: '12px' }">
<BrandNodeMark :size="32" :fg="C.signal" :accent="C.carbon" />
<span :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontWeight: 600, fontSize: '16px', letterSpacing: '-0.02em' }">dezky</span>
</div>
<div :style="{ display: 'inline-flex', alignItems: 'center', gap: '2px', border: '1px solid rgba(244,243,238,0.2)', borderRadius: '5px', padding: '2px' }">
<NuxtLink :to="switchLocalePath('en')" :style="segStyle('en')">EN</NuxtLink>
<NuxtLink :to="switchLocalePath('da')" :style="segStyle('da')">DA</NuxtLink>
</div>
</div>
<!-- Center -->
<div :style="{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', maxWidth: '620px' }">
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '12px', letterSpacing: '0.18em', textTransform: 'uppercase', color: C.signal }">{{ c.eyebrow }}</div>
<h1 :style="{ fontFamily: '\'Inter Tight\', sans-serif', fontWeight: 600, fontSize: 'clamp(40px, 8vw, 84px)', letterSpacing: '-0.04em', lineHeight: 0.98, margin: '20px 0 0', textWrap: 'balance' }">{{ c.heading }}</h1>
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: 'clamp(16px, 2.4vw, 19px)', lineHeight: 1.55, color: 'rgba(244,243,238,0.72)', margin: '28px 0 0', textWrap: 'pretty' }">{{ c.body }}</p>
<form @submit.prevent="submit" :style="{ display: 'flex', gap: '12px', marginTop: '40px', flexWrap: 'wrap', maxWidth: '480px' }">
<input
v-model="email" type="email" required :placeholder="c.emailPh"
:style="{ flex: 1, minWidth: '220px', padding: '15px 18px', borderRadius: '4px', border: '1px solid rgba(244,243,238,0.2)', background: 'rgba(244,243,238,0.05)', color: C.bone, fontFamily: '\'Inter\', sans-serif', fontSize: '15px', boxSizing: 'border-box' }"
>
<button
type="submit"
:style="{ background: C.signal, color: C.carbon, border: 'none', padding: '15px 26px', fontFamily: '\'Inter\', sans-serif', fontSize: '15px', fontWeight: 600, borderRadius: '4px', cursor: 'pointer', whiteSpace: 'nowrap' }"
>{{ c.submit }} </button>
</form>
<p :style="{ fontFamily: '\'Inter\', sans-serif', fontSize: '13px', color: 'rgba(244,243,238,0.45)', margin: '16px 0 0' }">{{ c.note }}</p>
</div>
<!-- Footer -->
<div :style="{ fontFamily: '\'JetBrains Mono\', monospace', fontSize: '11px', color: 'rgba(244,243,238,0.4)' }">dezky.eu · info@dezky.eu</div>
</div>
</template>
+16
View File
@@ -6,6 +6,14 @@ export type HeadlinePart = string | { hl: string }
export const COPY = { export const COPY = {
da: { da: {
waitlist: {
eyebrow: 'snart klar',
heading: 'Vi er der næsten.',
body: 'dezky lægger sidste hånd på den suveræne arbejdsplads — mail, filer, video, chat og login, hostet i EU. Skriv dig op, så siger vi til, så snart vi går live.',
emailPh: 'din@e-mail.dk',
submit: 'Hold mig opdateret',
note: 'Ingen spam — kun én mail, når vi lancerer.',
},
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',
@@ -508,6 +516,14 @@ export const COPY = {
}, },
}, },
en: { en: {
waitlist: {
eyebrow: 'coming soon',
heading: 'We\'re almost there.',
body: 'dezky is putting the finishing touches on its sovereign workplace — mail, files, video, chat and sign-in, hosted in the EU. Leave your email and we\'ll let you know the moment we go live.',
emailPh: 'you@company.com',
submit: 'Notify me',
note: 'No spam — just one email when we launch.',
},
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',