itube-specs 0.0.998 → 0.0.999

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.
Files changed (93) hide show
  1. package/components/card/channel.vue +6 -3
  2. package/components/card/model-mini.vue +5 -2
  3. package/components/card/model.vue +5 -3
  4. package/components/card/playlist.vue +3 -0
  5. package/components/card/round.vue +8 -2
  6. package/components/card/webcam/index.vue +1 -1
  7. package/components/info/model.vue +6 -5
  8. package/components/layout/footer.vue +30 -1
  9. package/components/layout/header-user.vue +4 -2
  10. package/components/layout/header.vue +5 -1
  11. package/components/layout/language.vue +3 -0
  12. package/components/player/interface.vue +2 -0
  13. package/components/search/item.vue +5 -3
  14. package/components/search/result.vue +1 -0
  15. package/components/section/categories-island.server.vue +6 -2
  16. package/components/ui/pagination.vue +5 -3
  17. package/components/view/video.vue +8 -3
  18. package/components/view/videos.vue +2 -0
  19. package/components/webcam/header.vue +1 -1
  20. package/components/webcam/stripcash-card.vue +1 -1
  21. package/composables/__tests__/use-seo-links.test.ts +62 -1
  22. package/composables/fetch/use-fetch-aside-models.ts +5 -3
  23. package/composables/fetch/use-fetch-models-count.ts +1 -1
  24. package/composables/fetch/use-fetch-webcams.ts +1 -1
  25. package/composables/use-generate-link.ts +9 -5
  26. package/composables/use-lang-choice.ts +32 -0
  27. package/composables/use-meta.ts +42 -2
  28. package/composables/use-niches.ts +8 -0
  29. package/composables/use-seo-links.ts +71 -17
  30. package/composables/use-seo-overrides.ts +7 -1
  31. package/composables/use-webcam-alive.ts +1 -1
  32. package/config/__tests__/locale-domains.test.ts +53 -0
  33. package/config/__tests__/niches.test.ts +34 -0
  34. package/config/index.ts +2 -0
  35. package/config/locale-domains.ts +95 -0
  36. package/config/niches.ts +65 -0
  37. package/lib/scheme/navigation-items.ts +3 -1
  38. package/lib/scheme/profile-navigation-items.ts +5 -3
  39. package/middleware/feature-flags.global.ts +13 -0
  40. package/middleware/niche.global.ts +17 -0
  41. package/nuxt.config.ts +8 -2
  42. package/package.json +1 -1
  43. package/pages/following.vue +2 -0
  44. package/pages/history.vue +2 -0
  45. package/pages/library/categories.vue +2 -0
  46. package/pages/library/channels.vue +2 -0
  47. package/pages/library/favorites.vue +2 -0
  48. package/pages/library/index.vue +15 -12
  49. package/pages/library/models.vue +2 -0
  50. package/pages/library/playlists.vue +2 -0
  51. package/pages/library/watch-later.vue +2 -0
  52. package/plugins/i18n-domains.ts +33 -0
  53. package/public/img/model-placeholder-female-portrait.avif +0 -0
  54. package/public/img/model-placeholder-female-portrait.webp +0 -0
  55. package/public/img/model-placeholder-female-square.avif +0 -0
  56. package/public/img/model-placeholder-female-square.webp +0 -0
  57. package/public/img/model-placeholder-portrait.avif +0 -0
  58. package/public/img/model-placeholder-portrait.webp +0 -0
  59. package/public/img/model-placeholder-square.avif +0 -0
  60. package/public/img/model-placeholder-square.webp +0 -0
  61. package/runtime/index.ts +1 -0
  62. package/runtime/utils/__tests__/model-placeholder.test.ts +25 -0
  63. package/runtime/utils/__tests__/video-data-add-model-portraits.test.ts +7 -0
  64. package/runtime/utils/cleaners/__tests__/clean-video-data.test.ts +4 -3
  65. package/runtime/utils/cleaners/clean-model-card.ts +2 -0
  66. package/runtime/utils/cleaners/clean-model-info.ts +2 -0
  67. package/runtime/utils/cleaners/clean-video-data.ts +2 -0
  68. package/runtime/utils/converters/convert-model-to-round-card.ts +1 -0
  69. package/runtime/utils/model-placeholder.ts +30 -0
  70. package/runtime/utils/video-data-add-model-portraits.ts +7 -4
  71. package/server/middleware/locale-redirect.ts +25 -0
  72. package/server/utils/__tests__/locale-redirect.test.ts +50 -0
  73. package/server/utils/locale-redirect.ts +89 -0
  74. package/services/api/categories.service.ts +10 -10
  75. package/services/api/channels.service.ts +4 -4
  76. package/services/api/models.service.ts +12 -12
  77. package/services/api/playlists.service.ts +5 -5
  78. package/services/api/search.service.ts +2 -2
  79. package/services/api/videos.service.ts +24 -24
  80. package/services/api/webcams.service.ts +1 -1
  81. package/types/card-info.d.ts +2 -0
  82. package/types/model-card.d.ts +2 -0
  83. package/types/raw/raw-model-card.d.ts +2 -0
  84. package/types/raw/raw-model-info.d.ts +2 -0
  85. package/types/raw/raw-video-data.d.ts +3 -0
  86. package/types/round-card.d.ts +2 -0
  87. package/types/search-top-models.d.ts +2 -0
  88. package/types/video-data.d.ts +2 -0
  89. package/utils/__tests__/niche-post-translation.test.ts +13 -0
  90. package/utils/adverts-helper.ts +3 -3
  91. package/utils/banner-helper.ts +1 -1
  92. package/utils/niche-helper.ts +8 -0
  93. package/utils/niche-post-translation.ts +63 -0
@@ -93,15 +93,18 @@ type InfoItem = {
93
93
  icon: string
94
94
  }
95
95
 
96
+ const featureFlags = useRuntimeConfig().public.featureFlags;
97
+
96
98
  const infoItems = computed<InfoItem[]>(() => [
97
99
  {
98
100
  text: props.card.videosCount,
99
101
  icon: 'video-camera'
100
102
  },
101
- {
102
- text: n(95000, 'compact'), // TODO тестовое
103
+ // число подписчиков — заглушка, ручки нет: живёт вместе с подписками
104
+ ...(featureFlags.FeatureFollowEnabled ? [{
105
+ text: n(95000, 'compact'),
103
106
  icon: 'double-user'
104
- },
107
+ }] : []),
105
108
  {
106
109
  text: formatDate(props.card.updated),
107
110
  icon: 'date'
@@ -47,13 +47,16 @@
47
47
 
48
48
  <script setup lang="ts">
49
49
  import type { IModelCard } from '../../types';
50
+ import { modelPlaceholder } from '../../runtime';
51
+ import { NicheHelper } from '../../utils/niche-helper';
50
52
 
51
53
  const props = defineProps<{
52
54
  card: IModelCard
53
55
  }>();
54
56
 
55
- const fallbackImage = '/img/model-placeholder-square.webp';
56
- const fallbackAvifImage = '/img/model-placeholder-square.avif';
57
+ const placeholder = modelPlaceholder(props.card.gender, NicheHelper.getCurrentNiche(), 'square');
58
+ const fallbackImage = placeholder.webp;
59
+ const fallbackAvifImage = placeholder.avif;
57
60
  const imageError = ref(false);
58
61
 
59
62
  /** Аватар 48px круглый — нужен квадратный кроп, вертикальный портрет сюда не годится. */
@@ -77,7 +77,8 @@
77
77
  </template>
78
78
 
79
79
  <script setup lang="ts">
80
- import { convertString, formatDate } from '../../runtime';
80
+ import { convertString, formatDate, modelPlaceholder } from '../../runtime';
81
+ import { NicheHelper } from '../../utils/niche-helper';
81
82
  import type { IModelCard } from '../../types';
82
83
 
83
84
  const props = withDefaults(defineProps<{
@@ -98,8 +99,9 @@ const { t } = useI18n();
98
99
  */
99
100
  const SIZES = 'auto, (max-width: 449px) 50vw, (max-width: 1385px) 215px, 250px';
100
101
 
101
- const fallbackImage = '/img/model-placeholder-portrait.webp';
102
- const fallbackAvifImage = '/img/model-placeholder-portrait.avif';
102
+ const placeholder = modelPlaceholder(props.card.gender, NicheHelper.getCurrentNiche(), 'portrait');
103
+ const fallbackImage = placeholder.webp;
104
+ const fallbackAvifImage = placeholder.avif;
103
105
  const imageError = ref(false);
104
106
 
105
107
  const isFallback = computed(() => imageError.value || !props.card.primaryImageUrl);
@@ -172,6 +172,7 @@
172
172
  </template>
173
173
  <template v-else>
174
174
  <button
175
+ v-if="featureFlags.FeatureFollowEnabled"
175
176
  :class="className"
176
177
  type="button"
177
178
  @click="toggleFollow(card.id, card.name)"
@@ -257,6 +258,8 @@ const userProfileData = computed(() => useState<IProfileData>('profileData').val
257
258
 
258
259
  const { isAuthorized } = useUser(AuthorizationApiService);
259
260
 
261
+ const featureFlags = useRuntimeConfig().public.featureFlags;
262
+
260
263
  const { isFollowing, toggleFollow, toggle } = usePlaylistFollow();
261
264
 
262
265
  const { openPlaylistEdit } = usePlaylistEdit();
@@ -43,6 +43,8 @@
43
43
  <script setup lang="ts">
44
44
  import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
45
45
  import type { IRoundCard } from '../../types';
46
+ import { modelPlaceholder } from '../../runtime';
47
+ import { NicheHelper } from '../../utils/niche-helper';
46
48
 
47
49
  const props = withDefaults(defineProps<{
48
50
  card: IRoundCard
@@ -63,7 +65,11 @@ const { generateLink } = useGenerateLink();
63
65
  /** `--card-round-size`: 80px до sm, 96px дальше. `auto` впереди — см. `UiImg`. */
64
66
  const SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 80px, 96px`;
65
67
 
66
- const fallbackImage = props.placeholderAvatar;
68
+ // У карточек моделей есть `gender` (пусть и пустой) — им заглушка по полу и нише, остальным — из пропа.
69
+ const modelFallback = props.card.gender === undefined
70
+ ? null
71
+ : modelPlaceholder(props.card.gender, NicheHelper.getCurrentNiche(), 'square');
72
+ const fallbackImage = modelFallback?.webp || props.placeholderAvatar;
67
73
  const imageError = ref(false);
68
74
 
69
75
  const isFallback = computed(() => imageError.value || !props.card.primaryImageUrl);
@@ -73,7 +79,7 @@ const currentImage = computed(() => (
73
79
  ));
74
80
 
75
81
  const currentAvifSrc = computed(() => (
76
- isFallback.value ? props.placeholderAvatarAvif : props.card.primaryImageAvifUrl
82
+ isFallback.value ? modelFallback?.avif || props.placeholderAvatarAvif : props.card.primaryImageAvifUrl
77
83
  ));
78
84
 
79
85
  function onImageError() {
@@ -234,7 +234,7 @@ const ageFilterUrl = computed(() => {
234
234
  const webcamsPath = useWebcamsPath();
235
235
 
236
236
  const nichePath = computed(() => {
237
- const niche = NicheHelper.getSiteDefaultNiche();
237
+ const niche = NicheHelper.getCurrentNiche();
238
238
 
239
239
  return niche && niche !== 'straight' ? `/${niche}` : '';
240
240
  });
@@ -136,7 +136,8 @@
136
136
  </template>
137
137
 
138
138
  <script setup lang="ts">
139
- import { AuthSteps } from '../../runtime';
139
+ import { AuthSteps, modelPlaceholder } from '../../runtime';
140
+ import { NicheHelper } from '../../utils/niche-helper';
140
141
  import type { ICardInfo, IGroupedParameter, ILinkItem, IModelCard } from '../../types';
141
142
  import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
142
143
  import { AuthorizationApiService } from '../../services/api/authorization.service';
@@ -151,19 +152,19 @@ const props = defineProps<{
151
152
  const { t, locale } = useI18n();
152
153
 
153
154
  const featureFlags = useRuntimeConfig().public.featureFlags;
155
+ const niche = NicheHelper.getCurrentNiche();
154
156
 
155
157
  const KNOWN_ICONS = ['facebook', 'instagram', 'tiktok', 'twitter', 'x', 'youtube', 'snapchat'];
156
158
 
157
- const PLACEHOLDER = '/img/model-placeholder-portrait.webp';
158
- const PLACEHOLDER_AVIF = '/img/model-placeholder-portrait.avif';
159
+ const placeholder = computed(() => modelPlaceholder(props.card.gender, niche, 'portrait'));
159
160
 
160
161
  /** `.info-model__img` — 160px до sm, 240px дальше. `auto` впереди — см. `UiImg`. */
161
162
  const IMG_SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 160px, 240px`;
162
163
 
163
164
  const imageError = ref(false);
164
165
  const isFallback = computed(() => imageError.value || !props.card.portraitImg);
165
- const currentImage = computed(() => (isFallback.value ? PLACEHOLDER : props.card.portraitImg));
166
- const currentAvifSrc = computed(() => (isFallback.value ? PLACEHOLDER_AVIF : props.card.portraitAvifUrl));
166
+ const currentImage = computed(() => (isFallback.value ? placeholder.value.webp : props.card.portraitImg));
167
+ const currentAvifSrc = computed(() => (isFallback.value ? placeholder.value.avif : props.card.portraitAvifUrl));
167
168
 
168
169
  const socialItems = computed<ILinkItem[]>(() => (
169
170
  Object.entries(props.card.social || {}).map(([key, value]) => ({
@@ -6,7 +6,14 @@
6
6
 
7
7
  <div class="layout-footer__footer">
8
8
  <LayoutLogo class="layout-footer__logo" />
9
- <LayoutFooterLanguage />
9
+ <div
10
+ v-if="hasNiches"
11
+ class="layout-footer__middle"
12
+ >
13
+ <LayoutNiche class="layout-footer__niche" />
14
+ <LayoutFooterLanguage />
15
+ </div>
16
+ <LayoutFooterLanguage v-else />
10
17
  <p class="layout-footer__copyright">{{ copyrightText }}</p>
11
18
  </div>
12
19
  </footer>
@@ -14,6 +21,7 @@
14
21
 
15
22
  <script setup lang="ts">
16
23
  const copyrightText = useAppConfig().copyrightText;
24
+ const hasNiches = ((useAppConfig().niches as string[] | undefined) || []).length > 1;
17
25
 
18
26
  const { t } = useI18n();
19
27
  </script>
@@ -47,6 +55,27 @@ $breakpoint-footer: sm;
47
55
  }
48
56
  }
49
57
 
58
+ .layout-footer__middle {
59
+ display: flex;
60
+ flex-direction: column;
61
+ row-gap: 16px;
62
+ align-items: center;
63
+
64
+ @include from-br($breakpoint-footer) {
65
+ flex-direction: row;
66
+ column-gap: 24px;
67
+ margin: 0 auto;
68
+ }
69
+ }
70
+
71
+ .layout-footer__niche .layout-niche__list {
72
+ @include from-br($breakpoint-footer) {
73
+ top: -8px;
74
+ bottom: auto;
75
+ transform: translateY(-100%);
76
+ }
77
+ }
78
+
50
79
  .layout-footer__copyright {
51
80
  @include font-xs;
52
81
 
@@ -43,7 +43,7 @@
43
43
  class="layout-header-user__links-wrapper"
44
44
  >
45
45
  <template
46
- v-for="(item, index) in profileNavigationItems.filter(item => item.group === group)"
46
+ v-for="(item, index) in navItems.filter(item => item.group === group)"
47
47
  :key="`profile-link-${index}`"
48
48
  >
49
49
  <component
@@ -136,7 +136,9 @@ function showMenu() {
136
136
  isMenuShow.value = !isMenuShow.value;
137
137
  }
138
138
 
139
- const groupsCount = computed(() => new Set(profileNavigationItems.map(item => item.group)).size);
139
+ const featureFlags = useRuntimeConfig().public.featureFlags as Record<string, boolean | undefined>;
140
+ const navItems = profileNavigationItems.filter(item => !item.featureFlag || featureFlags[item.featureFlag]);
141
+ const groupsCount = computed(() => new Set(navItems.map(item => item.group)).size);
140
142
 
141
143
  function component(hasChild: boolean | undefined) {
142
144
  if (hasChild) {
@@ -15,7 +15,10 @@
15
15
  icon-size="20"
16
16
  />
17
17
 
18
- <LayoutNotifications class="layout-header__notifications"/>
18
+ <LayoutNotifications
19
+ v-if="featureFlags.FeatureFollowEnabled"
20
+ class="layout-header__notifications"
21
+ />
19
22
 
20
23
  <div
21
24
  ref="languageRef"
@@ -91,6 +94,7 @@ import { AuthSteps } from '../../runtime';
91
94
  import { AuthorizationApiService } from '../../services/api/authorization.service';
92
95
 
93
96
  const { locale } = useI18n();
97
+ const featureFlags = useRuntimeConfig().public.featureFlags;
94
98
 
95
99
  const burgerShow = ref(false);
96
100
  const showLanguage = ref(false);
@@ -207,8 +207,11 @@ const emit = defineEmits<{
207
207
 
208
208
  const localeCookie = useCookie('i18n_redirected', { path: '/', secure: true, sameSite: 'lax' as const });
209
209
 
210
+ const { rememberLangChoice } = useLangChoice();
211
+
210
212
  function changeLocale(locale: string) {
211
213
  emit('change', locale);
214
+ rememberLangChoice(locale);
212
215
  localeCookie.value = locale;
213
216
  window.location.href = switchLocalePath(locale);
214
217
  }
@@ -10,6 +10,7 @@
10
10
  />
11
11
  <div class="player-interface__wrapper">
12
12
  <button
13
+ v-if="featureFlags.FeatureFavoritesEnabled"
13
14
  :class="{'--favorite': isFavorite}"
14
15
  :aria-pressed="isFavorite"
15
16
  type="button"
@@ -106,6 +107,7 @@ const props = defineProps<{
106
107
  }>();
107
108
 
108
109
  const { t } = useI18n();
110
+ const featureFlags = useRuntimeConfig().public.featureFlags;
109
111
 
110
112
  const showDescription = ref(false);
111
113
  const descriptionTextRef = ref<HTMLParagraphElement>();
@@ -31,7 +31,8 @@
31
31
 
32
32
  <script setup lang="ts">
33
33
  import type { ISearchTopModelsType } from '../../types';
34
- import { convertString } from '../../runtime';
34
+ import { convertString, modelPlaceholder } from '../../runtime';
35
+ import { NicheHelper } from '../../utils/niche-helper';
35
36
  import DOMPurify from 'dompurify';
36
37
 
37
38
  const props = defineProps<{
@@ -42,9 +43,10 @@ const props = defineProps<{
42
43
  }>();
43
44
 
44
45
  /** Иконка 32px — квадратный кроп по лицу, если он есть в ответе. */
46
+ const placeholder = modelPlaceholder(props.item?.gender, NicheHelper.getCurrentNiche(), 'square');
45
47
  const icon = computed(() => ({
46
- webp: props.item?.imageUrl || '/img/model-placeholder-square.webp',
47
- avif: props.item?.imageUrl ? props.item.imageAvifUrl : '/img/model-placeholder-square.avif',
48
+ webp: props.item?.imageUrl || placeholder.webp,
49
+ avif: props.item?.imageUrl ? props.item.imageAvifUrl : placeholder.avif,
48
50
  }));
49
51
 
50
52
  const title = computed(() => props.item?.title?.replace(/<[^>]*>/g, ''));
@@ -145,6 +145,7 @@ const topModels = computed(() => {
145
145
  videosCount: item.videosCount,
146
146
  imageUrl: item.primaryImageSmallUrl,
147
147
  imageAvifUrl: item.primaryImageSmallAvifUrl,
148
+ gender: item.gender,
148
149
  })) || [];
149
150
  });
150
151
 
@@ -15,13 +15,17 @@
15
15
 
16
16
  <script setup lang="ts">
17
17
  import { CategoriesApiService } from '../../services/api/categories.service';
18
- import { AsyncData } from '../../runtime';
18
+ import { AsyncData, type Niche } from '../../runtime';
19
19
 
20
- defineProps<{
20
+ const props = defineProps<{
21
21
  footerText?: string
22
22
  footerLinksLink?: string
23
+ /** Ниша страницы — только у сайтов с несколькими нишами: у island свой контекст без стейта ниши. */
24
+ niche?: Niche
23
25
  }>();
24
26
 
27
+ if (props.niche) useState<Niche>('niche').value = props.niche;
28
+
25
29
  const { t } = useI18n();
26
30
 
27
31
  const { data } = await useFetchCategoriesGroupByLetter(
@@ -146,6 +146,8 @@ const props = withDefaults(defineProps<{
146
146
  perPage?: number | string
147
147
  limit?: number
148
148
  arrowIcon?: boolean
149
+ /** путь, на котором живут страницы пагинации; по умолчанию — текущий */
150
+ basePath?: string
149
151
  }>(), {
150
152
  prefix: 'page',
151
153
  perPage: 48,
@@ -181,15 +183,15 @@ watchEffect(() => {
181
183
  });
182
184
 
183
185
  function linkTo(value: string | number) {
184
- return getPaginationLink(value, props.prefix, route.path, route.query);
186
+ return getPaginationLink(value, props.prefix, props.basePath || route.path, route.query);
185
187
  }
186
188
 
187
189
  watch(() => route.query['page'], () => {
188
190
  document.documentElement.querySelector('#anchor')?.scrollIntoView({ behavior: 'instant' });
189
191
  });
190
192
 
191
- // canonical/hreflang выпускает app.vue — отсюда уходит только факт, который знает лишь пагинация
192
- useSetSeoOverride(() => ({ alonePage: props.total === 1 }));
193
+ // canonical/hreflang/prev/next выпускает app.vue — отсюда уходят факты, которые знает лишь пагинация
194
+ useSetSeoOverride(() => ({ alonePage: props.total === 1, totalPages: length.value, paginationPath: props.basePath || undefined }));
193
195
  </script>
194
196
 
195
197
  <style lang="scss">
@@ -112,7 +112,8 @@
112
112
 
113
113
  <script setup lang="ts">
114
114
  import type { IChipsItem, IModelCard, IStripcashModel, IVideoCard, IVideoData, PaginatedResponse } from '../../types';
115
- import { convertModelToRoundCard } from '../../runtime';
115
+ import { convertModelToRoundCard, modelPlaceholder } from '../../runtime';
116
+ import { NicheHelper } from '../../utils/niche-helper';
116
117
  import type { AsyncDataRequestStatus } from '#app';
117
118
 
118
119
  const featureFlags = useRuntimeConfig().public.featureFlags;
@@ -205,13 +206,17 @@ const topChips = computed(() => {
205
206
  const topChipsStrings = computed(() => topChips.value?.map(item => item.value));
206
207
 
207
208
  /** Портрет в чипсе — квадратный кроп по лицу; своего у видео нет, его подмешивает BFF. */
209
+ const niche = NicheHelper.getCurrentNiche();
210
+
208
211
  function getModelChips(model: IVideoData['models'][number]): IChipsItem {
212
+ const placeholder = modelPlaceholder(model.gender, niche, 'square');
213
+
209
214
  return {
210
215
  prefix: '/models',
211
216
  value: model.title,
212
217
  title: model.title,
213
- image: model.imageUrl || '/img/model-placeholder-square.webp',
214
- imageAvif: model.imageUrl ? model.imageAvifUrl : '/img/model-placeholder-square.avif',
218
+ image: model.imageUrl || placeholder.webp,
219
+ imageAvif: model.imageUrl ? model.imageAvifUrl : placeholder.avif,
215
220
  };
216
221
  }
217
222
  </script>
@@ -75,6 +75,7 @@
75
75
  v-if="!hideCategories && isFirstPage"
76
76
  :footer-text="footerText"
77
77
  :footer-links-link="footerLinksLink"
78
+ v-bind="islandNicheProps"
78
79
  />
79
80
  </div>
80
81
  </template>
@@ -90,6 +91,7 @@ import type {
90
91
  import { convertModelToRoundCard } from '../../runtime';
91
92
 
92
93
  const featureFlags = useRuntimeConfig().public.featureFlags;
94
+ const islandNicheProps = useIslandNicheProps();
93
95
 
94
96
  const props = defineProps<{
95
97
  data: PaginatedResponse<IVideoCard> | undefined
@@ -40,7 +40,7 @@ defineProps<{
40
40
  const { t, te } = useI18n();
41
41
 
42
42
  const webcamsConfig = computed(() => (useRuntimeConfig().public.webcamsConfig || {}) as IWebcamsConfig);
43
- const niche = computed(() => NicheHelper.getSiteDefaultNiche() || 'straight');
43
+ const niche = computed(() => NicheHelper.getCurrentNiche() || 'straight');
44
44
  const nichePath = computed(() => (niche.value === 'straight' ? '' : `/${niche.value}`));
45
45
 
46
46
  /**
@@ -46,7 +46,7 @@ const emit = defineEmits<{
46
46
  }>();
47
47
 
48
48
  const webcamsConfig = computed(() => (useRuntimeConfig().public.webcamsConfig || {}) as IWebcamsConfig);
49
- const niche = computed(() => NicheHelper.getSiteDefaultNiche() || 'straight');
49
+ const niche = computed(() => NicheHelper.getCurrentNiche() || 'straight');
50
50
 
51
51
  /** Маппинг полей Stripcash → нормализованная модель. */
52
52
  const normalized = computed<IWebcamModel>(() => {
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment nuxt
2
- import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3
3
  import { mockNuxtImport } from '@nuxt/test-utils/runtime';
4
4
  import { useSeoLinks } from '../use-seo-links';
5
5
  import { useSeoOverridesState } from '../use-seo-overrides';
@@ -174,4 +174,65 @@ describe('useSeoLinks', () => {
174
174
  expect(canonicalUrl.value).toContain('categories=teen');
175
175
  expect(canonicalUrl.value).not.toContain('foo');
176
176
  });
177
+
178
+ it('prev/next по totalPages из пагинации', () => {
179
+ mockRoute = { path: '/videos', query: { page: '2', sort: 'popular' } };
180
+ useSeoOverridesState().value = { path: '/videos', totalPages: 3 };
181
+ const { paginationLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
182
+ expect(paginationLinks.value.map(l => [l.rel, l.href])).toEqual([
183
+ ['prev', 'https://example.com/videos?sort=popular'],
184
+ ['next', 'https://example.com/videos?sort=popular&page=3'],
185
+ ]);
186
+ });
187
+
188
+ it('без пагинации prev/next нет', () => {
189
+ const { paginationLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
190
+ expect(paginationLinks.value).toEqual([]);
191
+ });
192
+ });
193
+
194
+ describe('useSeoLinks — локали на поддоменах', () => {
195
+ const locales = useRuntimeConfig().public.locales;
196
+
197
+ beforeEach(() => {
198
+ mockRoute = { path: '/gay/videos', query: { page: '2', sort: 'popular' } };
199
+ mockI18n.locale.value = 'ja';
200
+ useSeoOverridesState().value = null;
201
+ useEnabledLanguageCodes().value = [];
202
+ locales.subdomains = { fr: 'fr', de: 'de', ja: 'jp', zh: 'ch' };
203
+ useRuntimeConfig().public.seo = { canonicalParams: ['page'] };
204
+ });
205
+
206
+ afterEach(() => {
207
+ locales.subdomains = {};
208
+ useRuntimeConfig().public.seo = {};
209
+ });
210
+
211
+ it('canonical на домене текущей локали, путь без префикса', () => {
212
+ const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
213
+ expect(canonicalUrl.value).toBe('https://jp.example.com/gay/videos?page=2');
214
+ });
215
+
216
+ it('hreflang — тот же путь на доменах локалей, x-default на базовом', () => {
217
+ const { alternateLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
218
+ const byLang = Object.fromEntries(alternateLinks.value.map(l => [l.hreflang, l.href]));
219
+ expect(byLang['zh-CN']).toBe('https://ch.example.com/gay/videos?page=2');
220
+ expect(byLang['en']).toBe('https://example.com/gay/videos?page=2');
221
+ expect(byLang['x-default']).toBe('https://example.com/gay/videos?page=2');
222
+ });
223
+
224
+ it('canonicalLocale уводит на домен указанной локали', () => {
225
+ useSeoOverridesState().value = { path: '/gay/videos', canonicalLocale: 'en' };
226
+ const { canonicalUrl } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
227
+ expect(canonicalUrl.value).toBe('https://example.com/gay/videos?page=2');
228
+ });
229
+
230
+ it('prev/next на домене текущей локали и на пути пагинации', () => {
231
+ useSeoOverridesState().value = { path: '/gay/videos', totalPages: 5, paginationPath: '/gay/videos/popular' };
232
+ const { paginationLinks } = useSeoLinks('https://example.com', mockI18n, mockLocalePath);
233
+ expect(paginationLinks.value.map(l => l.href)).toEqual([
234
+ 'https://jp.example.com/gay/videos/popular',
235
+ 'https://jp.example.com/gay/videos/popular?page=3',
236
+ ]);
237
+ });
177
238
  });
@@ -82,9 +82,11 @@ export const useFetchAsideModels = async (
82
82
  }));
83
83
 
84
84
  // Кому роликов хватило — тот в колонке; остальные остаются чипсами над плеером.
85
- watchEffect(() => {
86
- shown.value = models.filter(model => model.videos.value.length).map(model => model.slug);
87
- });
85
+ // Присваиваем сразу, а не эффектом: список читают при рендере (сайт решает по нему,
86
+ // рисовать ли чипсу модели), и реактивная запись пересчитывала бы разметку уже во
87
+ // время гидрации — она расходилась с серверной. Выдача к этому моменту получена и на
88
+ // сервере, и на клиенте (приезжает в payload), так что значение готово.
89
+ shown.value = models.filter(model => model.videos.value.length).map(model => model.slug);
88
90
 
89
91
  return models;
90
92
  };
@@ -8,7 +8,7 @@ import { ModelsApiService } from '../../services/api/models.service';
8
8
  */
9
9
  export const useFetchModelsCount = async () => {
10
10
  const lang = useLang() as Language;
11
- const niche = NicheHelper.getSiteDefaultNiche();
11
+ const niche = NicheHelper.getCurrentNiche();
12
12
 
13
13
  const { data } = await useAsyncData(
14
14
  `models-count-${niche}-${lang}`,
@@ -26,7 +26,7 @@ export const useFetchWebcams = async (options: {
26
26
  const { limit = config?.limit || WEBCAMS_DEFAULTS.limit, category, clientOnly, keepAlive } = options;
27
27
  const isEnabled = !!useRuntimeConfig().public.featureFlags.FeatureWebcamsEnabled;
28
28
  const { locale } = useI18n();
29
- const niche = NicheHelper.getSiteDefaultNiche();
29
+ const niche = NicheHelper.getCurrentNiche();
30
30
 
31
31
  /** Ключ фетча: он же адрес общего списка моделей для проверки живости. */
32
32
  const cacheKey = `webcams-${limit}-${category || 'all'}-${niche}-${locale.value}`;
@@ -8,21 +8,25 @@ export const useGenerateLink = () => {
8
8
 
9
9
  /**
10
10
  * Генерирует путь с учетом ниши и локали
11
- * @param path - относительный путь (например: '/videos/abc123')
11
+ * @param path - относительный путь (например: '/videos/abc123'); абсолютный URL отдаётся как есть
12
12
  * @param localeArg - локаль
13
13
  * @returns string - корректный путь для <NuxtLink> и navigateTo()
14
14
  */
15
15
  const generateLink = (path: string, localeArg?: Language): string => {
16
+ if (/^https?:\/\//.test(path)) return path;
17
+
16
18
  const cleanPath = path.startsWith('/') ? path : `/${path}`;
17
19
 
18
20
  // Ниша сайта — из окружения (тем же значением уходит в заголовок X-Niche),
19
- // список ниш — из per-site конфига проекта.
21
+ // список ниш и общие для всех ниш разделы — из per-site конфига проекта.
20
22
  const defaultNiche = NicheHelper.getSiteDefaultNiche();
21
- const projectNiches = useAppConfig().niches as Niche[];
22
- const isOneNiche = projectNiches.length === 1;
23
+ const appConfig = useAppConfig();
24
+ const isOneNiche = (appConfig.niches as Niche[]).length === 1;
25
+ const nicheFreeRoots = (appConfig.nicheFreeRoots as string[] | undefined) || [];
26
+ const isNicheFree = nicheFreeRoots.some(root => cleanPath === root || cleanPath.startsWith(`${root}/`));
23
27
 
24
28
  const withNiche =
25
- niche.value === defaultNiche || isOneNiche
29
+ !niche.value || niche.value === defaultNiche || isOneNiche || isNicheFree
26
30
  ? cleanPath
27
31
  : `/${niche.value}${cleanPath}`;
28
32
 
@@ -0,0 +1,32 @@
1
+ import { LANG_COOKIE, baseDomainFromHost, hasLocaleSubdomains, type LocaleSubdomains } from '../config/locale-domains';
2
+
3
+ /**
4
+ * Явный выбор языка на сайтах с локалями на поддоменах. Пишет cookie `lang` (значение — поддомен
5
+ * локали, у дефолтной пусто) на `.<базовый домен>`, иначе её не видно с соседних поддоменов. По ней
6
+ * server/middleware/locale-redirect.ts перестаёт уводить по языку браузера. У префиксных локалей —
7
+ * no-op: там выбор помнит cookie `i18n_redirected`.
8
+ */
9
+ export const useLangChoice = () => {
10
+ const subdomains = useRuntimeConfig().public.locales.subdomains as LocaleSubdomains;
11
+
12
+ function rememberLangChoice(code: string) {
13
+ if (!import.meta.client || !hasLocaleSubdomains(subdomains)) return;
14
+
15
+ const base = baseDomainFromHost(window.location.host, subdomains);
16
+ const isLocalhost = /^localhost(?::\d+)?$|^\d+\.\d+\.\d+\.\d+/.test(base);
17
+
18
+ const parts = [
19
+ `${LANG_COOKIE}=${subdomains[code] || ''}`,
20
+ 'path=/',
21
+ `max-age=${60 * 60 * 24 * 365}`,
22
+ 'samesite=lax',
23
+ ];
24
+ // на localhost domain-атрибут с точкой браузеры отбрасывают
25
+ if (!isLocalhost) parts.push(`domain=.${base}`);
26
+ if (window.location.protocol === 'https:') parts.push('secure');
27
+
28
+ document.cookie = parts.join('; ');
29
+ }
30
+
31
+ return { rememberLangChoice };
32
+ };