@@ -17,7 +18,7 @@ const copy = useCopy()
{{ copy.pricing.lede }}
- {{ copy.pricing.cta }} →
+ {{ copy.pricing.cta }} →
diff --git a/apps/website/components/landing/ProductMockup.vue b/apps/website/components/landing/ProductMockup.vue
index 530e02c..e1fefd7 100644
--- a/apps/website/components/landing/ProductMockup.vue
+++ b/apps/website/components/landing/ProductMockup.vue
@@ -38,7 +38,9 @@ onMounted(() => {
const parent = frame.value?.parentElement
if (parent && typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(recompute)
- ro.observe(parent)
+ // Cast via unknown: a dependency pulls a second DOM lib so HTMLElement and
+ // ResizeObserver's Element param resolve to non-overlapping types here.
+ ro.observe(parent as unknown as Element)
}
})
onBeforeUnmount(() => ro?.disconnect())
diff --git a/apps/website/components/landing/Whitelabel.vue b/apps/website/components/landing/Whitelabel.vue
index ccd1b66..2c47e6e 100644
--- a/apps/website/components/landing/Whitelabel.vue
+++ b/apps/website/components/landing/Whitelabel.vue
@@ -6,6 +6,7 @@ import { useTheme, useCopy, useDark } from '~/composables/useLanding'
const t = useTheme()
const copy = useCopy()
+const localePath = useLocalePath()
const dark = useDark()
const sectionBg = computed(() => (dark.value ? '#1A1A17' : '#EFEDE3'))
@@ -45,7 +46,7 @@ const partnerCards = computed(() =>
- {{ copy.whitelabel.cta }} →
+ {{ copy.whitelabel.cta }} →
diff --git a/apps/website/composables/useLanding.ts b/apps/website/composables/useLanding.ts
index 6316440..160cf72 100644
--- a/apps/website/composables/useLanding.ts
+++ b/apps/website/composables/useLanding.ts
@@ -2,22 +2,14 @@ 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.
-
-// Language choice persists in the first-party `dezky-lang` cookie (see the
-// cookie policy). useState stays the reactive source of truth so a toggle in
-// one component updates the whole page live; we seed it from the cookie on SSR
-// init (so a reload keeps the chosen language) and write the cookie on change.
-const LANG_COOKIE = 'dezky-lang'
-const LANG_MAX_AGE = 60 * 60 * 24 * 365 // 12 months
-
-export const useLang = () => useState
('dz-lang', () => {
- const saved = useCookie(LANG_COOKIE).value
- return saved === 'en' ? 'en' : 'da'
-})
+// Locale is owned by @nuxtjs/i18n (URL-based: English at /, Danish at /da, with
+// cookie-remembered browser detection). useLang exposes it as the 'da' | 'en'
+// the COPY object expects; useCopy maps to the matching translations. `dark` is
+// unused machinery from the design's Tweaks panel (the site is light-only).
+export const useLang = () => {
+ const { locale } = useI18n()
+ return computed(() => (locale.value === 'da' ? 'da' : 'en'))
+}
export const useDark = () => useState('dz-dark', () => false)
export const useTheme = () => {
@@ -26,16 +18,31 @@ export const useTheme = () => {
}
export const useCopy = () => {
- const lang = useLang()
- return computed(() => COPY[lang.value === 'en' ? 'en' : 'da'])
+ const { locale } = useI18n()
+ return computed(() => COPY[locale.value === 'da' ? 'da' : 'en'])
}
-export function toggleLang() {
- const lang = useLang()
- const next: Lang = lang.value === 'da' ? 'en' : 'da'
- lang.value = next
- // Persist so the choice survives a reload / return visit.
- useCookie(LANG_COOKIE, { maxAge: LANG_MAX_AGE, sameSite: 'lax', path: '/' }).value = next
+// Setup-only. Returns a click handler that switches to the other locale's
+// localized route (flips the URL, e.g. / <-> /da).
+export function useLangToggle() {
+ const { locale } = useI18n()
+ const switchLocalePath = useSwitchLocalePath()
+ return () => navigateTo(switchLocalePath(locale.value === 'da' ? 'en' : 'da'))
+}
+
+// Setup-only. Localizes an internal href, preserving any #hash. Page links get
+// the locale prefix (/about -> /da/about in Danish); section anchors resolve
+// against the current locale's home (/#suite -> /da#suite). Bare #hash returns
+// unchanged (same-page anchor).
+export function useLocalizeHref() {
+ const localePath = useLocalePath()
+ return (href: string) => {
+ if (href.startsWith('#')) return href
+ const i = href.indexOf('#')
+ const path = i === -1 ? href : href.slice(0, i)
+ const hash = i === -1 ? '' : href.slice(i)
+ return localePath(path || '/') + hash
+ }
}
// Smooth-scroll to an in-page anchor, accounting for the sticky 72px nav.
@@ -48,18 +55,3 @@ export function scrollToAnchor(hash: string) {
window.scrollTo({ top, behavior: 'smooth' })
history.replaceState(null, '', hash)
}
-
-// Navigate to a homepage section from anywhere. Footer/Nav links use the form
-// "/#suite": when already on the homepage we smooth-scroll in place; from a
-// sub-page we route home and index.vue scrolls to the hash on mount. Accepts
-// either "/#suite" or "#suite". Returns true if it handled the click (so the
-// caller can preventDefault), false to let normal navigation proceed.
-export function goToSection(href: string, currentPath: string): boolean {
- const hash = href.slice(href.indexOf('#'))
- if (currentPath === '/') {
- scrollToAnchor(hash)
- return true
- }
- navigateTo(`/${hash}`)
- return true
-}
diff --git a/apps/website/i18n/i18n.config.ts b/apps/website/i18n/i18n.config.ts
new file mode 100644
index 0000000..ee3452e
--- /dev/null
+++ b/apps/website/i18n/i18n.config.ts
@@ -0,0 +1,10 @@
+// Minimal vue-i18n config. The marketing site keeps its translations in the
+// hand-authored COPY object (utils/landingCopy.ts) and reads them via
+// useCopy(); @nuxtjs/i18n is used only for locale routing + SEO (hreflang,
+// canonical, ), so the message catalogues stay empty.
+export default defineI18nConfig(() => ({
+ legacy: false,
+ locale: 'en',
+ fallbackLocale: 'en',
+ messages: { en: {}, da: {} },
+}))
diff --git a/apps/website/layouts/page.vue b/apps/website/layouts/page.vue
index df33fec..ad6a238 100644
--- a/apps/website/layouts/page.vue
+++ b/apps/website/layouts/page.vue
@@ -2,12 +2,10 @@
// Sub-page shell: same Nav + Footer chrome as the landing page, with a content
// slot in between. Used by every footer-linked page so they share the header,
// footer, theme and language toggle.
-import { useTheme, useLang } from '~/composables/useLanding'
+import { useTheme } from '~/composables/useLanding'
const t = useTheme()
-const lang = useLang()
-
-useHead({ htmlAttrs: { lang } })
+// is set globally by @nuxtjs/i18n (useLocaleHead in app.vue).
diff --git a/apps/website/nuxt.config.ts b/apps/website/nuxt.config.ts
index 843c2ba..94ac96a 100644
--- a/apps/website/nuxt.config.ts
+++ b/apps/website/nuxt.config.ts
@@ -6,10 +6,15 @@
// 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.
+const siteUrl = process.env.NUXT_PUBLIC_SITE_URL
+ || (process.env.NODE_ENV === 'production' ? 'https://dezky.eu' : 'http://localhost:3000')
+
export default defineNuxtConfig({
compatibilityDate: '2026-01-01',
devtools: { enabled: true },
+ modules: ['@nuxtjs/i18n'],
+
css: ['~/assets/styles/tokens.css', '~/assets/styles/base.css'],
// Auto-import from the shared packages/ui workspace in addition to the
@@ -25,10 +30,12 @@ export default defineNuxtConfig({
app: {
head: {
- // Marketing site is light by default (the design's primary theme). The
- // page sets reactively based on the da/en toggle.
- htmlAttrs: { lang: 'da' },
+ // /dir + hreflang alternates are managed by @nuxtjs/i18n
+ // (useLocaleHead in app.vue). Static favicons + theme-color live here.
link: [
+ { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
+ { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32.png' },
+ { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' },
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
{
@@ -36,6 +43,41 @@ export default defineNuxtConfig({
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',
},
],
+ // theme-color + favicons are static; the OG/Twitter/canonical SEO is set
+ // in app.vue via useSeoMeta so og:url/og:image are absolute against the
+ // env-aware siteUrl (runtimeConfig below) and og:url is per-route.
+ meta: [
+ { name: 'theme-color', content: '#FAFAF7' },
+ ],
+ },
+ },
+
+ // Public site URL used to build absolute canonical / OG / sitemap URLs.
+ // Defaults to localhost in dev (so local share-image previews work) and
+ // the production domain otherwise; override with NUXT_PUBLIC_SITE_URL.
+ runtimeConfig: {
+ public: {
+ siteUrl,
+ },
+ },
+
+ // Bilingual: English is the default (no prefix); Danish lives under /da.
+ // Both are server-rendered and indexed with hreflang alternates. First-time
+ // visitors are auto-detected (cookie-remembered) on the root path only.
+ i18n: {
+ defaultLocale: 'en',
+ strategy: 'prefix_except_default',
+ baseUrl: siteUrl,
+ locales: [
+ { code: 'en', language: 'en', name: 'English' },
+ { code: 'da', language: 'da-DK', name: 'Dansk' },
+ ],
+ detectBrowserLanguage: {
+ useCookie: true,
+ cookieKey: 'i18n_redirected',
+ redirectOn: 'root',
+ fallbackLocale: 'en',
+ alwaysRedirect: false,
},
},
@@ -44,10 +86,12 @@ export default defineNuxtConfig({
// 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,
- },
+ // HMR/DevTools websocket. Default (localhost:3000 dev) lets Vite infer
+ // ws on the page host/port. When served behind Traefik TLS at
+ // dezky.local, set DEZKY_TRAEFIK=1 so the client connects via wss:443.
+ hmr: process.env.DEZKY_TRAEFIK === '1'
+ ? { protocol: 'wss', clientPort: 443 }
+ : undefined,
},
},
})
diff --git a/apps/website/package.json b/apps/website/package.json
index ff33f7e..01ac01b 100644
--- a/apps/website/package.json
+++ b/apps/website/package.json
@@ -12,6 +12,7 @@
"lint": "eslint ."
},
"dependencies": {
+ "@nuxtjs/i18n": "^10.4.0",
"nuxt": "^4.4.7",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
diff --git a/apps/website/pages/contact.vue b/apps/website/pages/contact.vue
index 0558090..2d93667 100644
--- a/apps/website/pages/contact.vue
+++ b/apps/website/pages/contact.vue
@@ -5,6 +5,7 @@ definePageMeta({ layout: 'page' })
const t = useTheme()
const copy = useCopy()
+const localePath = useLocalePath()
const c = computed(() => copy.value.pages.contact)
const h2 = computed(() => ({
@@ -56,7 +57,7 @@ useHead({ title: () => `${copy.value.pages.contact.label} · dezky` })
{{ c.demoHeading }}
{{ c.demoBody }}
-
{{ copy.pages.ctaDemo }} →
+
{{ copy.pages.ctaDemo }} →
diff --git a/apps/website/pages/index.vue b/apps/website/pages/index.vue
index 39b213a..a69bd77 100644
--- a/apps/website/pages/index.vue
+++ b/apps/website/pages/index.vue
@@ -21,8 +21,7 @@ onMounted(() => {
})
useHead({
- title: 'dezky · suveræn produktivitet',
- htmlAttrs: { lang },
+ title: () => (lang.value === 'da' ? 'dezky · suveræn produktivitet' : 'dezky · sovereign productivity'),
meta: [
{ name: 'description', content: description },
],
diff --git a/apps/website/pages/migration.vue b/apps/website/pages/migration.vue
index 60ae238..dac9673 100644
--- a/apps/website/pages/migration.vue
+++ b/apps/website/pages/migration.vue
@@ -5,6 +5,7 @@ definePageMeta({ layout: 'page' })
const t = useTheme()
const copy = useCopy()
+const localePath = useLocalePath()
const c = computed(() => copy.value.pages.migration)
useHead({ title: () => `${copy.value.pages.migration.label} · dezky` })
@@ -26,7 +27,7 @@ useHead({ title: () => `${copy.value.pages.migration.label} · dezky` })
{{ c.note }}
- {{ copy.pages.ctaDemo }} →
+ {{ copy.pages.ctaDemo }} →