itube-specs 0.0.869 → 0.0.870
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/layout/header.vue +3 -9
- package/components/layout/language.vue +14 -52
- package/composables/__tests__/use-client-country.test.ts +121 -0
- package/composables/use-client-country.ts +73 -0
- package/composables/use-meta.ts +13 -2
- package/package.json +1 -1
- package/pages/index.vue +20 -21
- package/plugins/countries.server.ts +22 -40
- package/server/middleware/country.ts +3 -3
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
@click="showLanguageMenu"
|
|
28
28
|
>
|
|
29
29
|
<img
|
|
30
|
-
v-if="
|
|
31
|
-
:src="
|
|
30
|
+
v-if="clientCountryFlag"
|
|
31
|
+
:src="clientCountryFlag"
|
|
32
32
|
:alt="clientCountryIcon"
|
|
33
33
|
class="layout-header__language-icon"
|
|
34
34
|
width="24"
|
|
@@ -96,14 +96,8 @@
|
|
|
96
96
|
import { AuthSteps } from '../../runtime';
|
|
97
97
|
import { onClickOutside } from '@vueuse/core';
|
|
98
98
|
import { AuthorizationApiService } from '../../services/api/authorization.service';
|
|
99
|
-
import { isoCodeMap } from '../../utils/iso-code-map';
|
|
100
99
|
|
|
101
|
-
const
|
|
102
|
-
const clientCountryIcon = computed(() => {
|
|
103
|
-
const code = clientCountryCode.value;
|
|
104
|
-
if (!code) return '';
|
|
105
|
-
return isoCodeMap[code] || code;
|
|
106
|
-
});
|
|
100
|
+
const { icon: clientCountryIcon, flagSrc: clientCountryFlag } = useClientCountry();
|
|
107
101
|
|
|
108
102
|
const burgerShow = ref(false);
|
|
109
103
|
const showLanguage = ref(false);
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
>
|
|
43
43
|
<summary class="layout-language__summary">
|
|
44
44
|
<img
|
|
45
|
-
v-if="
|
|
45
|
+
v-if="clientCountryFlag"
|
|
46
46
|
:alt="clientCountryName"
|
|
47
|
-
:src="
|
|
47
|
+
:src="clientCountryFlag"
|
|
48
48
|
class="layout-language__icon"
|
|
49
49
|
width="20"
|
|
50
50
|
height="20"
|
|
@@ -109,8 +109,6 @@
|
|
|
109
109
|
|
|
110
110
|
<script setup lang="ts">
|
|
111
111
|
import type { ISelectItem } from '../../types';
|
|
112
|
-
import { isoCodeMap } from '../../utils/iso-code-map';
|
|
113
|
-
import { DictionariesApiService } from '../../services/api/dictionaries.service';
|
|
114
112
|
|
|
115
113
|
const { locale, locales } = useI18n();
|
|
116
114
|
const switchLocalePath = useSwitchLocalePath();
|
|
@@ -119,22 +117,14 @@ defineProps<{
|
|
|
119
117
|
iconSize: string
|
|
120
118
|
}>();
|
|
121
119
|
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
const clientCountryNameState = useState<string>('clientCountryName', () => '');
|
|
132
|
-
|
|
133
|
-
const clientCountryName = computed(() => {
|
|
134
|
-
if (!clientCountryIcon.value) return '';
|
|
135
|
-
const fromList = countriesItems.value.find(item => item.icon === clientCountryIcon.value)?.value;
|
|
136
|
-
return fromList || clientCountryNameState.value || '';
|
|
137
|
-
});
|
|
120
|
+
const {
|
|
121
|
+
icon: clientCountryIcon,
|
|
122
|
+
name: clientCountryName,
|
|
123
|
+
flagSrc: clientCountryFlag,
|
|
124
|
+
countriesItems,
|
|
125
|
+
setCountry,
|
|
126
|
+
loadCountries,
|
|
127
|
+
} = useClientCountry();
|
|
138
128
|
|
|
139
129
|
const isCountryOpen = ref(false);
|
|
140
130
|
const countryValue = ref('');
|
|
@@ -144,26 +134,6 @@ const filteredCountries = computed(() => {
|
|
|
144
134
|
return countriesItems.value.filter(item => item.title.toLowerCase().includes(query));
|
|
145
135
|
});
|
|
146
136
|
|
|
147
|
-
const isCountryLoading = ref(false);
|
|
148
|
-
|
|
149
|
-
// Список стран не приходит в payload (см. plugins/countries.server.ts) — догружаем
|
|
150
|
-
// его лениво на клиенте при первом открытии селектора.
|
|
151
|
-
async function loadCountries() {
|
|
152
|
-
if (countriesItems.value.length || isCountryLoading.value) return;
|
|
153
|
-
isCountryLoading.value = true;
|
|
154
|
-
try {
|
|
155
|
-
const data = await DictionariesApiService.getCountries();
|
|
156
|
-
countriesItems.value = (data?.countries ?? []).map((item) => {
|
|
157
|
-
const iso = item.isoCodes[0].toLowerCase();
|
|
158
|
-
return { title: item.title, value: item.title, icon: isoCodeMap[iso] || iso, key: iso };
|
|
159
|
-
});
|
|
160
|
-
} catch {
|
|
161
|
-
// оставляем пустым — следующее открытие попробует снова
|
|
162
|
-
} finally {
|
|
163
|
-
isCountryLoading.value = false;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
137
|
function onCountryToggle(e: Event) {
|
|
168
138
|
isCountryOpen.value = (e.target as HTMLDetailsElement).open;
|
|
169
139
|
if (isCountryOpen.value) loadCountries();
|
|
@@ -191,18 +161,10 @@ function changeLocale(locale: string) {
|
|
|
191
161
|
window.location.href = switchLocalePath(locale);
|
|
192
162
|
}
|
|
193
163
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
function changeCountry(item: ISelectItem) {
|
|
200
|
-
const code = item.key || '';
|
|
201
|
-
clientCountryCode.value = code;
|
|
202
|
-
clientCountryNameState.value = item.value;
|
|
203
|
-
countryCookie.value = code;
|
|
204
|
-
countryNameCookie.value = item.value;
|
|
205
|
-
countryNameCodeCookie.value = code;
|
|
164
|
+
async function changeCountry(item: ISelectItem) {
|
|
165
|
+
setCountry(item);
|
|
166
|
+
// куку пишет вотчер useCookie — ждём флаш, иначе навигация может срезать запись
|
|
167
|
+
await nextTick();
|
|
206
168
|
window.location.href = '/';
|
|
207
169
|
}
|
|
208
170
|
</script>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// @vitest-environment nuxt
|
|
2
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
3
|
+
import { useClientCountry } from '../use-client-country';
|
|
4
|
+
|
|
5
|
+
const getCountries = vi.fn();
|
|
6
|
+
|
|
7
|
+
vi.mock('../../services/api/dictionaries.service', () => ({
|
|
8
|
+
DictionariesApiService: { getCountries: () => getCountries() },
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const setCode = (code: string) => useState<string>('clientCountryCode', () => '').value = code;
|
|
12
|
+
const setResolvedName = (name: string) => useState<string>('clientCountryName', () => '').value = name;
|
|
13
|
+
|
|
14
|
+
describe('useClientCountry', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
useNuxtApp().payload.state = {};
|
|
17
|
+
getCountries.mockReset();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('без кода не отдаёт ни флаг, ни имя', () => {
|
|
21
|
+
const { icon, name, flagSrc } = useClientCountry();
|
|
22
|
+
|
|
23
|
+
expect(icon.value).toBe('');
|
|
24
|
+
expect(name.value).toBe('');
|
|
25
|
+
expect(flagSrc.value).toBe('');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('строит иконку и путь к флагу из кода', () => {
|
|
29
|
+
setCode('ad');
|
|
30
|
+
setResolvedName('Andorra');
|
|
31
|
+
const { icon, flagSrc } = useClientCountry();
|
|
32
|
+
|
|
33
|
+
expect(icon.value).toBe('ad');
|
|
34
|
+
expect(flagSrc.value).toBe('/icons/flags/ad.svg');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('нормализует код: регистр и пробелы', () => {
|
|
38
|
+
setCode(' AD ');
|
|
39
|
+
setResolvedName('Andorra');
|
|
40
|
+
const { icon, flagSrc } = useClientCountry();
|
|
41
|
+
|
|
42
|
+
expect(icon.value).toBe('ad');
|
|
43
|
+
expect(flagSrc.value).toBe('/icons/flags/ad.svg');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('учитывает страны, где код флага отличается от ISO-кода', () => {
|
|
47
|
+
setCode('cn');
|
|
48
|
+
setResolvedName('China');
|
|
49
|
+
const { icon, flagSrc } = useClientCountry();
|
|
50
|
+
|
|
51
|
+
expect(icon.value).toBe('zh');
|
|
52
|
+
expect(flagSrc.value).toBe('/icons/flags/zh.svg');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('не показывает флаг для служебного кода, которого нет в словаре', () => {
|
|
56
|
+
// Cloudflare отдаёт XX для неизвестного IP и T1 для Tor — файла такого флага нет
|
|
57
|
+
setCode('xx');
|
|
58
|
+
const { icon, name, flagSrc } = useClientCountry();
|
|
59
|
+
|
|
60
|
+
expect(icon.value).toBe('xx');
|
|
61
|
+
expect(name.value).toBe('');
|
|
62
|
+
expect(flagSrc.value).toBe('');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('имя следует за кодом и не может остаться от другой страны', async () => {
|
|
66
|
+
setCode('ad');
|
|
67
|
+
// имя, доставшееся от предыдущей страны — именно этот рассинхрон ловим
|
|
68
|
+
setResolvedName('USA');
|
|
69
|
+
getCountries.mockResolvedValue({
|
|
70
|
+
countries: [
|
|
71
|
+
{ title: 'Andorra', isoCodes: ['AD', 'AND'] },
|
|
72
|
+
{ title: 'USA', isoCodes: ['US', 'USA'] },
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const { name, flagSrc, loadCountries } = useClientCountry();
|
|
77
|
+
await loadCountries();
|
|
78
|
+
|
|
79
|
+
expect(name.value).toBe('Andorra');
|
|
80
|
+
expect(flagSrc.value).toBe('/icons/flags/ad.svg');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('до загрузки списка берёт имя, отрезолвленное на сервере', () => {
|
|
84
|
+
setCode('ad');
|
|
85
|
+
setResolvedName('Andorra');
|
|
86
|
+
const { name } = useClientCountry();
|
|
87
|
+
|
|
88
|
+
expect(name.value).toBe('Andorra');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('выбор страны меняет и код, и имя', () => {
|
|
92
|
+
setCode('ad');
|
|
93
|
+
setResolvedName('Andorra');
|
|
94
|
+
const { code, name, flagSrc, setCountry } = useClientCountry();
|
|
95
|
+
|
|
96
|
+
setCountry({ title: 'USA', value: 'USA', icon: 'us', key: 'us' });
|
|
97
|
+
|
|
98
|
+
expect(code.value).toBe('us');
|
|
99
|
+
expect(name.value).toBe('USA');
|
|
100
|
+
expect(flagSrc.value).toBe('/icons/flags/us.svg');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('не перезапрашивает список, если он уже загружен', async () => {
|
|
104
|
+
getCountries.mockResolvedValue({ countries: [{ title: 'Andorra', isoCodes: ['AD'] }] });
|
|
105
|
+
const { loadCountries } = useClientCountry();
|
|
106
|
+
|
|
107
|
+
await loadCountries();
|
|
108
|
+
await loadCountries();
|
|
109
|
+
|
|
110
|
+
expect(getCountries).toHaveBeenCalledTimes(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('падение запроса оставляет список пустым — следующее открытие попробует снова', async () => {
|
|
114
|
+
getCountries.mockRejectedValue(new Error('500'));
|
|
115
|
+
const { countriesItems, loadCountries } = useClientCountry();
|
|
116
|
+
|
|
117
|
+
await loadCountries();
|
|
118
|
+
|
|
119
|
+
expect(countriesItems.value).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ISelectItem } from '../types';
|
|
2
|
+
import { isoCodeMap } from '../utils/iso-code-map';
|
|
3
|
+
import { DictionariesApiService } from '../services/api/dictionaries.service';
|
|
4
|
+
|
|
5
|
+
/** Ручной выбор страны переживает перезагрузку год — читает его server/middleware/country.ts */
|
|
6
|
+
const COOKIE_OPTS = { maxAge: 60 * 60 * 24 * 365, path: '/', secure: true, sameSite: 'lax' as const };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Единый источник страны клиента: ISO-код (гео-заголовок прокси или ручной выбор
|
|
10
|
+
* в селекторе), иконка флага и локализованное имя.
|
|
11
|
+
*
|
|
12
|
+
* Имя всегда резолвится ОТ ТЕКУЩЕГО кода — своего кеша у него нет. Раньше имя
|
|
13
|
+
* лежало в отдельной куке `clientCountryName` рядом с парной `clientCountryNameCode`,
|
|
14
|
+
* и пара могла разъехаться: заголовок писал одну страну, флаг и селектор показывали
|
|
15
|
+
* другую (реальный кейс на проде: `clientCountryName=USA` при коде `ad`).
|
|
16
|
+
*/
|
|
17
|
+
export const useClientCountry = () => {
|
|
18
|
+
const code = useState<string>('clientCountryCode', () => '');
|
|
19
|
+
const resolvedName = useState<string>('clientCountryName', () => '');
|
|
20
|
+
const countriesItems = useState<ISelectItem[]>('countriesItems', () => []);
|
|
21
|
+
const codeCookie = useCookie<string | null>('clientCountryCode', COOKIE_OPTS);
|
|
22
|
+
|
|
23
|
+
/** ISO-код → имя файла флага (у части стран код локали отличается от кода страны) */
|
|
24
|
+
const toIcon = (value: string) => {
|
|
25
|
+
const iso = value.trim().toLowerCase();
|
|
26
|
+
return iso ? isoCodeMap[iso] || iso : '';
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const icon = computed(() => toIcon(code.value));
|
|
30
|
+
|
|
31
|
+
// Список стран догружается лениво на клиенте (см. layout/language.vue) — если он
|
|
32
|
+
// уже есть, имя берём из него: там оно точно совпадает с текущим кодом.
|
|
33
|
+
const name = computed(() => {
|
|
34
|
+
if (!icon.value) return '';
|
|
35
|
+
return countriesItems.value.find(item => item.icon === icon.value)?.title || resolvedName.value;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Флаг только для страны, которую смогли назвать: гео-заголовок умеет отдавать
|
|
39
|
+
// служебные коды (Cloudflare `XX` для неизвестного IP и `T1` для Tor, MaxMind `AP`),
|
|
40
|
+
// их нет ни в словаре стран, ни среди файлов флагов — вышла бы битая картинка.
|
|
41
|
+
const flagSrc = computed(() => (icon.value && name.value ? `/icons/flags/${icon.value}.svg` : ''));
|
|
42
|
+
|
|
43
|
+
/** Ручной выбор страны в селекторе: наружу переживает перезагрузку только код. */
|
|
44
|
+
const setCountry = (item: ISelectItem) => {
|
|
45
|
+
code.value = item.key || '';
|
|
46
|
+
resolvedName.value = item.title;
|
|
47
|
+
codeCookie.value = code.value || null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const isLoading = ref(false);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Полный список стран в payload не кладётся (см. plugins/countries.server.ts) —
|
|
54
|
+
* догружаем лениво на клиенте, когда открыли селектор.
|
|
55
|
+
*/
|
|
56
|
+
const loadCountries = async () => {
|
|
57
|
+
if (countriesItems.value.length || isLoading.value) return;
|
|
58
|
+
isLoading.value = true;
|
|
59
|
+
try {
|
|
60
|
+
const data = await DictionariesApiService.getCountries();
|
|
61
|
+
countriesItems.value = (data?.countries ?? []).map((item) => {
|
|
62
|
+
const iso = item.isoCodes[0].toLowerCase();
|
|
63
|
+
return { title: item.title, value: item.title, icon: toIcon(iso), key: iso };
|
|
64
|
+
});
|
|
65
|
+
} catch {
|
|
66
|
+
// оставляем пустым — следующее открытие попробует снова
|
|
67
|
+
} finally {
|
|
68
|
+
isLoading.value = false;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return { code, icon, name, flagSrc, countriesItems, setCountry, loadCountries };
|
|
73
|
+
};
|
package/composables/use-meta.ts
CHANGED
|
@@ -3,6 +3,16 @@ import type { Ref } from 'vue';
|
|
|
3
3
|
import { useRoute } from 'vue-router';
|
|
4
4
|
import type { IChipsItem } from '../types';
|
|
5
5
|
|
|
6
|
+
type MetaOptions = {
|
|
7
|
+
/**
|
|
8
|
+
* Подменяет i18n-ключ заголовка (по умолчанию 'h1') — вызывается с фактической
|
|
9
|
+
* сортировкой, потому что варианты заголовка есть не у каждой из них.
|
|
10
|
+
* Нужен, когда подставляемого текста может не быть и в шаблоне остаётся
|
|
11
|
+
* висячий предлог (`… Videos in ` без страны).
|
|
12
|
+
*/
|
|
13
|
+
h1Key?: (sortType: string) => string
|
|
14
|
+
};
|
|
15
|
+
|
|
6
16
|
/**
|
|
7
17
|
* Генерирует реактивные meta (title, description) и h1 с учётом сортировки, страницы и i18n-ключей.
|
|
8
18
|
* @param t - функция перевода (i18n)
|
|
@@ -13,8 +23,9 @@ import type { IChipsItem } from '../types';
|
|
|
13
23
|
* @param secondText - второй подставляемый текст
|
|
14
24
|
* @param thirdText - третий подставляемый текст
|
|
15
25
|
* @param fourthText - четвёртый подставляемый текст
|
|
26
|
+
* @param options - уточнения (подмена ключа h1)
|
|
16
27
|
*/
|
|
17
|
-
export function useMeta(t: (key: string, params?: Record<string, unknown>) => string, page: string, brandName: string, sortOptions?: IChipsItem[], text?: Ref<string>, secondText?: Ref<string>, thirdText?: Ref<string>, fourthText?: Ref<string
|
|
28
|
+
export function useMeta(t: (key: string, params?: Record<string, unknown>) => string, page: string, brandName: string, sortOptions?: IChipsItem[], text?: Ref<string>, secondText?: Ref<string>, thirdText?: Ref<string>, fourthText?: Ref<string>, options?: MetaOptions) {
|
|
18
29
|
const route = useRoute();
|
|
19
30
|
|
|
20
31
|
const sortType = computed(() => {
|
|
@@ -46,7 +57,7 @@ export function useMeta(t: (key: string, params?: Record<string, unknown>) => st
|
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
const metaTitle = computed(() => getPath('title'));
|
|
49
|
-
const h1 = computed(() => getPath('h1'));
|
|
60
|
+
const h1 = computed(() => getPath(options?.h1Key?.(sortType.value) || 'h1'));
|
|
50
61
|
|
|
51
62
|
const meta = computed(() => {
|
|
52
63
|
const description = getPath('meta_description');
|
package/package.json
CHANGED
package/pages/index.vue
CHANGED
|
@@ -21,9 +21,8 @@ import { convertQueryCategories, convertString, AsyncData } from '../runtime';
|
|
|
21
21
|
import { VideosApiService } from '../services/api/videos.service';
|
|
22
22
|
import { ModelsApiService } from '../services/api/models.service';
|
|
23
23
|
import { CategoriesApiService } from '../services/api/categories.service';
|
|
24
|
-
import { isoCodeMap } from '../utils/iso-code-map';
|
|
25
24
|
import { sortItemsDefault } from '../lib';
|
|
26
|
-
import type {
|
|
25
|
+
import type { IChipsItem } from '../types';
|
|
27
26
|
|
|
28
27
|
const { t } = useI18n();
|
|
29
28
|
|
|
@@ -62,28 +61,28 @@ const routeQueryCategories = computed(() => {
|
|
|
62
61
|
return '';
|
|
63
62
|
});
|
|
64
63
|
|
|
65
|
-
const
|
|
66
|
-
const countriesItems = useState<ISelectItem[]>('countriesItems', () => []);
|
|
67
|
-
const clientCountryNameState = useState<string>('clientCountryName', () => '');
|
|
64
|
+
const clientCountry = useClientCountry();
|
|
68
65
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (!code) return '';
|
|
73
|
-
const icon = isoCodeMap[code] || code;
|
|
74
|
-
const fromList = countriesItems.value.find(item => item.icon === icon)?.title;
|
|
75
|
-
return fromList || clientCountryNameState.value || '';
|
|
76
|
-
});
|
|
66
|
+
// Страна в заголовке уместна только на дефолтной сортировке (в остальных шаблонах
|
|
67
|
+
// её нет) — при явной сортировке гасим и подпись, и флаг.
|
|
68
|
+
const isCountryShown = computed(() => !route.query['sort'] && !!clientCountry.name.value);
|
|
77
69
|
|
|
78
|
-
const
|
|
70
|
+
const countryName = computed(() => (isCountryShown.value ? clientCountry.name.value : ''));
|
|
79
71
|
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
72
|
+
const { meta, h1 } = useMeta(
|
|
73
|
+
t,
|
|
74
|
+
pageName,
|
|
75
|
+
useAppConfig().brandName,
|
|
76
|
+
sortItemsDefault,
|
|
77
|
+
routeQueryCategories,
|
|
78
|
+
countryName,
|
|
79
|
+
undefined,
|
|
80
|
+
undefined,
|
|
81
|
+
// Страну не определили — берём заголовок без неё, иначе останется висячий предлог.
|
|
82
|
+
{ h1Key: sortType => (sortType === 'trending' && !countryName.value ? 'h1_no_country' : 'h1') },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const countryFlagImg = computed(() => (isCountryShown.value ? clientCountry.flagSrc.value : undefined));
|
|
87
86
|
|
|
88
87
|
watchEffect(() => {
|
|
89
88
|
useHead(meta.value);
|
|
@@ -1,45 +1,32 @@
|
|
|
1
1
|
// SSR-плагин: по ISO-коду клиента резолвит имя страны в useState('clientCountryName')
|
|
2
2
|
// для гео-заголовка. Полный список стран в payload не кладётся (его лениво догружает
|
|
3
|
-
// селектор языка на клиенте). Результат кэшируется в
|
|
4
|
-
import type { ICountries
|
|
5
|
-
import { isoCodeMap } from '../utils/iso-code-map';
|
|
3
|
+
// селектор языка на клиенте). Результат кэшируется в память процесса.
|
|
4
|
+
import type { ICountries } from '../types';
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
/** Опции легаси-кук — нужны только чтобы их погасить (см. ниже) */
|
|
7
|
+
const LEGACY_COOKIE_OPTS = { path: '/', secure: true, sameSite: 'lax' as const };
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
items: ISelectItem[];
|
|
11
|
-
nameByIso: Map<string, string>;
|
|
12
|
-
};
|
|
9
|
+
const cache = new Map<string, Promise<Map<string, string> | null>>();
|
|
13
10
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
function buildCache(data: ICountries): CountriesCache {
|
|
17
|
-
const items: ISelectItem[] = [];
|
|
11
|
+
function buildNameByIso(data: ICountries): Map<string, string> {
|
|
18
12
|
const nameByIso = new Map<string, string>();
|
|
19
13
|
|
|
20
14
|
for (const item of data.countries) {
|
|
21
|
-
const iso = item.isoCodes[0].toLowerCase();
|
|
22
|
-
items.push({
|
|
23
|
-
title: item.title,
|
|
24
|
-
value: item.title,
|
|
25
|
-
icon: isoCodeMap[iso] || iso,
|
|
26
|
-
key: iso,
|
|
27
|
-
});
|
|
28
15
|
for (const code of item.isoCodes) {
|
|
29
16
|
nameByIso.set(code.toLowerCase(), item.title);
|
|
30
17
|
}
|
|
31
18
|
}
|
|
32
|
-
return
|
|
19
|
+
return nameByIso;
|
|
33
20
|
}
|
|
34
21
|
|
|
35
|
-
function
|
|
22
|
+
function getNameByIso(language: string, domain: string): Promise<Map<string, string> | null> {
|
|
36
23
|
const key = `${language}|${domain}`;
|
|
37
24
|
let promise = cache.get(key);
|
|
38
25
|
if (!promise) {
|
|
39
26
|
promise = $fetch<ICountries>('/api/dictionaries/countries', {
|
|
40
27
|
headers: { 'x-language': language, 'x-domain': domain },
|
|
41
28
|
})
|
|
42
|
-
.then(data => (data?.countries?.length ?
|
|
29
|
+
.then(data => (data?.countries?.length ? buildNameByIso(data) : null))
|
|
43
30
|
.catch(() => {
|
|
44
31
|
cache.delete(key);
|
|
45
32
|
return null;
|
|
@@ -51,34 +38,29 @@ function getCountries(language: string, domain: string): Promise<CountriesCache
|
|
|
51
38
|
|
|
52
39
|
export default defineNuxtPlugin(async () => {
|
|
53
40
|
const event = useRequestEvent();
|
|
54
|
-
const code = (event?.context?.clientCountryCode as string | undefined) || '';
|
|
41
|
+
const code = ((event?.context?.clientCountryCode as string | undefined) || '').trim().toLowerCase();
|
|
55
42
|
|
|
56
43
|
const countryName = useState<string>('clientCountryName', () => '');
|
|
57
44
|
|
|
45
|
+
// Имя страны больше не кешируется в куках: пара `clientCountryName` +
|
|
46
|
+
// `clientCountryNameCode` умела разъезжаться с кодом страны (заголовок писал одну
|
|
47
|
+
// страну, флаг и селектор показывали другую), а выигрыш был только в одном
|
|
48
|
+
// сетевом запросе на холодный процесс. Старые куки гасим, чтобы не висели год.
|
|
49
|
+
const legacyName = useCookie<string | null>('clientCountryName', LEGACY_COOKIE_OPTS);
|
|
50
|
+
const legacyNameCode = useCookie<string | null>('clientCountryNameCode', LEGACY_COOKIE_OPTS);
|
|
51
|
+
if (legacyName.value) legacyName.value = null;
|
|
52
|
+
if (legacyNameCode.value) legacyNameCode.value = null;
|
|
53
|
+
|
|
58
54
|
// Резолвим только имя текущей страны для гео-заголовка. Полный список стран
|
|
59
55
|
// НЕ кладём в useState (не сериализуем в payload) — language.vue догружает
|
|
60
56
|
// его лениво на клиенте при открытии селектора.
|
|
61
57
|
if (!code) return;
|
|
62
58
|
|
|
63
|
-
const codeCookie = useCookie<string>('clientCountryNameCode', COOKIE_OPTS);
|
|
64
|
-
const nameCookie = useCookie<string>('clientCountryName', COOKIE_OPTS);
|
|
65
|
-
|
|
66
|
-
// Тёплая кука → имя уже известно, серверный фетч стран не нужен.
|
|
67
|
-
if (nameCookie.value && codeCookie.value === code) {
|
|
68
|
-
countryName.value = nameCookie.value;
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
59
|
const language = useRequestHeaders(['x-language'])['x-language'] ?? '';
|
|
73
60
|
const domain = useRuntimeConfig().public.xDomain as string;
|
|
74
61
|
|
|
75
|
-
const
|
|
76
|
-
if (!
|
|
62
|
+
const nameByIso = await getNameByIso(language, domain);
|
|
63
|
+
if (!nameByIso) return;
|
|
77
64
|
|
|
78
|
-
|
|
79
|
-
if (name) {
|
|
80
|
-
countryName.value = name;
|
|
81
|
-
nameCookie.value = name;
|
|
82
|
-
codeCookie.value = code;
|
|
83
|
-
}
|
|
65
|
+
countryName.value = nameByIso.get(code) || '';
|
|
84
66
|
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Определяет страну клиента и кладёт ISO-код в event.context.clientCountryCode:
|
|
2
|
-
// сначала из cookie `clientCountryCode
|
|
3
|
-
// `cf-ipcountry` / `x-country-code` / `x-country`).
|
|
2
|
+
// сначала из cookie `clientCountryCode` (ручной выбор в селекторе), иначе из
|
|
3
|
+
// заголовков прокси (Cloudflare `cf-ipcountry` / `x-country-code` / `x-country`).
|
|
4
4
|
export default defineEventHandler((event) => {
|
|
5
5
|
const cookie = getCookie(event, 'clientCountryCode');
|
|
6
6
|
if (cookie) {
|
|
7
|
-
event.context.clientCountryCode = cookie;
|
|
7
|
+
event.context.clientCountryCode = cookie.trim().toLowerCase();
|
|
8
8
|
return;
|
|
9
9
|
}
|
|
10
10
|
|