feat(website): bilingual i18n (English default, Danish at /da) + SEO
Add @nuxtjs/i18n: English is the default locale (no prefix), Danish lives under /da (prefix_except_default). Both server-rendered and indexed with hreflang alternates + per-locale canonical (useLocaleHead in app.vue). First-visit browser language is auto-detected and remembered in the i18n_redirected cookie (redirectOn root). - Keep the hand-authored COPY object; useLang/useCopy now read the i18n locale; useLocalizeHref/useLangToggle added. Every internal link is localized so navigation stays in-locale. - Clear segmented EN|DA language switcher (active segment filled) replacing the ambiguous "en · da" pill. - SEO: useSeoMeta defaults are locale-aware; og:image switches per locale (English / Danish share card); favicon links; robots.txt + sitemap.xml; env-aware siteUrl/baseUrl (localhost in dev, dezky.eu in prod). - Update the cookie policy (dezky-lang -> i18n_redirected). - Gate the Traefik wss:443 HMR behind DEZKY_TRAEFIK so localhost dev/DevTools connect over plain ws (fixes the DevTools disconnect loop).
This commit is contained in:
@@ -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<Lang>('dz-lang', () => {
|
||||
const saved = useCookie<Lang>(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<Lang>(() => (locale.value === 'da' ? 'da' : 'en'))
|
||||
}
|
||||
export const useDark = () => useState<boolean>('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>(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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user