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:
@@ -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')
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user