Skip to content

Commit 49e41dd

Browse files
committed
feat(web): add domain-based locale detection and lang-prefixed SSG routes
Detect locale from domain (fxtun.ru → ru), generate /ru/* and /en/* prefixed routes with forcedLocale, improve canonical URLs and hreflang.
1 parent 27ec325 commit 49e41dd

8 files changed

Lines changed: 106 additions & 36 deletions

File tree

web/src/composables/useSeo.ts

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,89 @@
11
import { useHead, useSeoMeta } from '@unhead/vue'
22
import { computed } from 'vue'
33
import { useI18n } from 'vue-i18n'
4+
import { useRoute } from 'vue-router'
5+
import { i18n, getDomainLocale } from '../i18n'
46

57
interface SeoOptions {
68
titleKey?: string
79
descriptionKey?: string
810
title?: string
911
description?: string
10-
path?: string
1112
image?: string
1213
type?: 'website' | 'article'
1314
}
1415

1516
export function useSeo(options: SeoOptions = {}) {
1617
const { t, locale } = useI18n()
18+
const route = useRoute()
1719

18-
const title = computed(() => options.title || (options.titleKey ? t(options.titleKey) : 'fxTunnel'))
19-
const description = computed(() => options.description || (options.descriptionKey ? t(options.descriptionKey) : t('seo.defaultDescription')))
20-
const url = computed(() => `https://fxtun.dev${options.path || ''}`)
20+
// Effective locale: forcedLocale from route meta takes priority
21+
const effectiveLocale = (route.meta.forcedLocale as 'en' | 'ru') ?? locale.value
22+
23+
// Apply forcedLocale globally (for component body rendering)
24+
if (route.meta.forcedLocale) {
25+
locale.value = effectiveLocale
26+
// @ts-expect-error vue-i18n composition api
27+
i18n.global.locale.value = effectiveLocale
28+
}
29+
30+
// Helper: translate with explicit locale (bypasses reactive locale for SSG)
31+
// @ts-expect-error vue-i18n message schema
32+
const te = (key: string) => i18n.global.messages.value[effectiveLocale]?.[key.split('.')[0]]
33+
? t(key, [], { locale: effectiveLocale })
34+
: t(key)
35+
36+
const title = computed(() => options.title || (options.titleKey ? te(options.titleKey) : 'fxTunnel'))
37+
const description = computed(() => options.description || (options.descriptionKey ? te(options.descriptionKey) : te('seo.defaultDescription')))
2138
const image = options.image || 'https://fxtun.dev/og-image.png'
2239

40+
const isLangPrefix = computed(() =>
41+
route.path.startsWith('/ru') || route.path.startsWith('/en')
42+
)
43+
44+
const cleanPath = computed(() => {
45+
if (route.path.startsWith('/ru')) return route.path.slice(3) || '/'
46+
if (route.path.startsWith('/en')) return route.path.slice(3) || '/'
47+
return route.path
48+
})
49+
50+
const enCanonical = computed(() => `https://fxtun.dev${cleanPath.value}`)
51+
const ruCanonical = computed(() => `https://fxtun.ru${cleanPath.value}`)
52+
53+
const canonical = computed(() => {
54+
if (isLangPrefix.value) {
55+
return route.path.startsWith('/ru') ? ruCanonical.value : enCanonical.value
56+
}
57+
const domain = getDomainLocale() === 'ru' ? 'fxtun.ru' : 'fxtun.dev'
58+
return `https://${domain}${cleanPath.value}`
59+
})
60+
61+
const showHreflang = computed(() => !isLangPrefix.value)
62+
63+
useHead({
64+
htmlAttrs: { lang: effectiveLocale },
65+
link: computed(() => [
66+
{ rel: 'canonical', href: canonical.value },
67+
...(showHreflang.value ? [
68+
{ rel: 'alternate', hreflang: 'en', href: enCanonical.value },
69+
{ rel: 'alternate', hreflang: 'ru', href: ruCanonical.value },
70+
{ rel: 'alternate', hreflang: 'x-default', href: enCanonical.value },
71+
] : []),
72+
]),
73+
})
74+
2375
useSeoMeta({
2476
title,
2577
description,
2678
ogTitle: title,
2779
ogDescription: description,
2880
ogImage: image,
29-
ogUrl: url,
81+
ogUrl: canonical,
3082
ogType: options.type || 'website',
3183
ogSiteName: 'fxTunnel',
3284
twitterCard: 'summary_large_image',
3385
twitterTitle: title,
3486
twitterDescription: description,
3587
twitterImage: image,
3688
})
37-
38-
useHead({
39-
htmlAttrs: { lang: locale },
40-
link: [
41-
{ rel: 'canonical', href: url },
42-
{ rel: 'alternate', hreflang: 'en', href: url },
43-
{ rel: 'alternate', hreflang: 'ru', href: url },
44-
{ rel: 'alternate', hreflang: 'x-default', href: url },
45-
],
46-
})
4789
}

web/src/i18n/index.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,18 @@ import ru from './ru.json'
44

55
type MessageSchema = typeof en
66

7+
export function getDomainLocale(): 'en' | 'ru' | null {
8+
if (import.meta.env.SSR) return null
9+
const host = window.location.hostname
10+
if (host === 'fxtun.ru' || host.endsWith('.fxtun.ru')) return 'ru'
11+
return null
12+
}
13+
714
function getDefaultLocale(): 'en' | 'ru' {
815
if (import.meta.env.SSR) return 'en'
9-
10-
const saved = localStorage.getItem('locale')
11-
if (saved === 'en' || saved === 'ru') {
12-
return saved
13-
}
14-
15-
const browserLang = navigator.language.split('-')[0]
16-
if (['ru', 'uk', 'be'].includes(browserLang)) {
17-
return 'ru'
18-
}
19-
20-
return 'en'
16+
return getDomainLocale()
17+
?? (localStorage.getItem('locale') as 'en' | 'ru' | null)
18+
?? (['ru', 'uk', 'be'].includes(navigator.language.split('-')[0]) ? 'ru' : 'en')
2119
}
2220

2321
export const i18n = createI18n<[MessageSchema], 'en' | 'ru'>({

web/src/main.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,22 @@ export const createApp = ViteSSG(
1212
app.use(createPinia())
1313
app.use(i18n)
1414

15+
// Set locale from route meta — works during both SSG and client
16+
router.beforeEach((to, _from, next) => {
17+
if (to.meta.forcedLocale) {
18+
// @ts-expect-error vue-i18n composition api
19+
i18n.global.locale.value = to.meta.forcedLocale as 'en' | 'ru'
20+
}
21+
next()
22+
})
23+
1524
if (!import.meta.env.SSR) {
1625
router.beforeEach(async (to, _from, next) => {
26+
if (to.meta.forcedLocale) {
27+
const { setLocale } = await import('./i18n')
28+
setLocale(to.meta.forcedLocale as 'en' | 'ru')
29+
}
30+
1731
const { useAuthStore } = await import('./stores/auth')
1832
const authStore = useAuthStore()
1933

web/src/router.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { RouteRecordRaw } from 'vue-router'
22

3-
export const routes: RouteRecordRaw[] = [
3+
const publicRoutes: RouteRecordRaw[] = [
44
{
55
path: '/',
66
name: 'landing',
@@ -24,6 +24,19 @@ export const routes: RouteRecordRaw[] = [
2424
name: 'offer',
2525
component: () => import('./views/OfferView.vue'),
2626
},
27+
]
28+
29+
function langPrefixedRoutes(lang: 'ru' | 'en'): RouteRecordRaw[] {
30+
return publicRoutes.map(r => ({
31+
...r,
32+
path: `/${lang}${r.path === '/' ? '' : r.path}`,
33+
name: `${String(r.name)}-${lang}`,
34+
meta: { ...r.meta, forcedLocale: lang },
35+
}))
36+
}
37+
38+
export const routes: RouteRecordRaw[] = [
39+
...publicRoutes,
2740
{
2841
path: '/checkout',
2942
name: 'checkout',
@@ -140,6 +153,8 @@ export const routes: RouteRecordRaw[] = [
140153
component: () => import('./views/admin/AdminSubscriptionsView.vue'),
141154
meta: { requiresAuth: true, requiresAdmin: true },
142155
},
156+
...langPrefixedRoutes('ru'),
157+
...langPrefixedRoutes('en'),
143158
{
144159
path: '/:pathMatch(.*)*',
145160
name: 'not-found',

web/src/views/LoginView.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const themeStore = useThemeStore()
1212
const authStore = useAuthStore()
1313
const { t, locale } = useI18n()
1414
15-
useSeo({ titleKey: 'seo.login.title', descriptionKey: 'seo.login.description', path: '/login' })
15+
useSeo({ titleKey: 'seo.login.title', descriptionKey: 'seo.login.description' })
1616
1717
const showOffer = computed(() => locale.value === 'ru')
1818

web/src/views/OfferView.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { useSeo } from '@/composables/useSeo'
88
const themeStore = useThemeStore()
99
const { t, locale } = useI18n()
1010
11-
useSeo({ titleKey: 'seo.offer.title', descriptionKey: 'seo.offer.description', path: '/offer' })
11+
useSeo({ titleKey: 'seo.offer.title', descriptionKey: 'seo.offer.description' })
1212
1313
function toggleLocale() {
1414
const current = getLocale()

web/src/views/RegisterView.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import Card from '@/components/ui/Card.vue'
99
const themeStore = useThemeStore()
1010
const { t } = useI18n()
1111
12-
useSeo({ titleKey: 'seo.register.title', descriptionKey: 'seo.register.description', path: '/register' })
12+
useSeo({ titleKey: 'seo.register.title', descriptionKey: 'seo.register.description' })
1313
1414
function toggleLocale() {
1515
const current = getLocale()

web/vite.config.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export default defineConfig({
99
Sitemap({
1010
hostname: "https://fxtun.dev",
1111
dynamicRoutes: ["/login", "/register", "/offer"],
12-
exclude: ["/docs/offer"],
12+
exclude: ["/docs/offer", "/ru", "/ru/*", "/en", "/en/*"],
1313
generateRobotsTxt: false,
1414
}),
1515
],
@@ -26,10 +26,11 @@ export default defineConfig({
2626
script: "async",
2727
formatting: "minify",
2828
beastiesOptions: false,
29-
includedRoutes(paths) {
30-
return paths.filter((p) =>
31-
["/", "/login", "/register", "/offer"].includes(p)
32-
);
29+
includedRoutes() {
30+
const pages = ["/", "/login", "/register", "/offer"];
31+
const ruPages = pages.map((p) => `/ru${p === "/" ? "" : p}`);
32+
const enPages = pages.map((p) => `/en${p === "/" ? "" : p}`);
33+
return [...pages, ...ruPages, ...enPages];
3334
},
3435
},
3536
server: {

0 commit comments

Comments
 (0)