itube-specs 0.0.1051 → 0.0.1053

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 (55) hide show
  1. package/SITE_UPDATES.md +48 -0
  2. package/app.config.ts +6 -0
  3. package/components/grid/videos.vue +13 -0
  4. package/components/grid/webcams.vue +16 -4
  5. package/components/player/core.vue +1 -0
  6. package/components/ui/pagination.vue +5 -0
  7. package/components/view/model-videos.vue +2 -4
  8. package/components/view/video.vue +20 -5
  9. package/components/view/videos.vue +2 -4
  10. package/composables/fetch/use-fetch-models-by-letter.ts +5 -0
  11. package/composables/fetch/use-fetch-models-by-phrases.ts +5 -0
  12. package/composables/fetch/use-fetch-models.ts +5 -0
  13. package/composables/page/use-category-page.ts +15 -3
  14. package/composables/page/use-channel-page.ts +8 -3
  15. package/composables/page/use-model-page.ts +8 -3
  16. package/composables/page/use-search-page.ts +2 -0
  17. package/composables/use-device-id.ts +40 -0
  18. package/composables/use-meta.ts +1 -0
  19. package/composables/use-metrika-goal.ts +12 -0
  20. package/composables/use-search-niche-redirect.ts +44 -0
  21. package/composables/use-sort-guard.ts +30 -0
  22. package/composables/use-stats-page.ts +30 -0
  23. package/composables/use-webcam-stats.ts +45 -0
  24. package/middleware/normalize.global.ts +6 -1
  25. package/nuxt.config.ts +2 -0
  26. package/package.json +1 -1
  27. package/pages/categories/[slug].vue +2 -4
  28. package/pages/channels/[channel].vue +2 -4
  29. package/pages/playlists/[playlistId]/[slug].vue +8 -0
  30. package/pages/search/[slug]/index.vue +4 -4
  31. package/pages/search/[slug]/videos.vue +2 -0
  32. package/pages/tag/[tag].vue +2 -0
  33. package/pages/videos/[slug].vue +10 -0
  34. package/plugins/metrika.ts +47 -0
  35. package/plugins/stats.client.ts +66 -0
  36. package/runtime/index.ts +3 -0
  37. package/runtime/utils/__tests__/normalize-url.test.ts +9 -1
  38. package/runtime/utils/__tests__/page-info.test.ts +32 -0
  39. package/runtime/utils/__tests__/resolve-sort-query.test.ts +30 -0
  40. package/runtime/utils/__tests__/rotator-grid.test.ts +22 -0
  41. package/runtime/utils/normalize-url.ts +5 -0
  42. package/runtime/utils/page-info.ts +70 -0
  43. package/runtime/utils/resolve-sort-query.ts +22 -0
  44. package/runtime/utils/rotator-grid.ts +32 -0
  45. package/server/api/search/niche.post.ts +19 -0
  46. package/server/api/stats/rotator.post.ts +11 -0
  47. package/server/api/stats/user.post.ts +11 -0
  48. package/server/api/stats/webcams.post.ts +11 -0
  49. package/services/api/search.service.ts +22 -0
  50. package/types/player-site-hooks.d.ts +2 -0
  51. package/utils/stats/flush.ts +88 -0
  52. package/utils/stats/rotator.ts +159 -0
  53. package/utils/stats/service.ts +30 -0
  54. package/utils/stats/ulogger.ts +67 -0
  55. package/utils/stats/webcams.ts +76 -0
package/SITE_UPDATES.md CHANGED
@@ -11,6 +11,54 @@
11
11
  коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
12
12
  кому именно (всем / сайтам с N), что будет, если не сделать.
13
13
 
14
+ ## 0.0.1053 — 2026-10-26
15
+
16
+ - **Всем — к сведению:** нативка в конце сеток листингов и похожих (`AdvNativeBottom` в `view/video`, `view/videos`,
17
+ `view/model-videos`, страницах категории, канала, поиска) больше не ленивая. Контейнер приезжает в серверной
18
+ разметке, и сеть заполняет его до гидрации, а ленивая гидрация пересобирала уже заполненный iframe — креатив
19
+ терялся, слот оставался пустым (найдено на hdsex-remaster). Делать ничего не надо.
20
+ - **Сайтам со своими сортировками на страницах на page-composables:** у `useCategoryPage`, `useChannelPage`,
21
+ `useModelPage` новая опция `sortOptions` (по умолчанию `sortItemsDefault`) — по ней ключи меты и проверка `?sort`.
22
+ Без неё свои значения `?sort` такой страницы — 404. Образец — коммит hdsex-remaster с этим изменением.
23
+ - **Сайтам с частотным капом преролла (hdsex-remaster):** в `usePlayerSiteHooks()` новый хук `onAdStart()` — старт
24
+ рекламы в плеере (например, поставить куку капа). Остальным — ничего.
25
+ - **Сайтам с боковой колонкой моделей у плеера (hdsex-remaster):** `appConfig.videoPage.hideAsideModelChips: true` —
26
+ с 1024px не дублировать чипсой над плеером модель, у которой есть блок в колонке. По умолчанию `false`, ничего не
27
+ меняется.
28
+
29
+ ## 0.0.1052 — 2026-10-22
30
+
31
+ - **Всем — к сведению:** мусорные адреса листингов теперь 404. `?page` не натуральное число (`0`, `-1`, `abc`, `01`) —
32
+ 404 в `normalize.global.ts`; `?sort`, которого нет у страницы, — 404 (`useSortGuard`, его зовёт `useMeta`, у страницы
33
+ категории без `metaKeys` — `useCategoryPage`); `?sort` у страницы без сортировок — тоже 404; модели за последней
34
+ страницей (API отвечает ошибкой) — 404; пустая пагинация за последней страницей (`UiPagination` с `total = 0`) —
35
+ 404. Значение `?sort`, совпадающее с ключом API сортировки страницы (`-created`, `rank`, `-updated`), — 301 на неё
36
+ (сортировка по умолчанию — без `sort`). Своя страница со своими сортировками — передавать их в `useMeta`
37
+ (`sortOptions`), иначе её `?sort` станет 404.
38
+ - **Сайтам, переехавшим со старого движка (videosection-redesign, hdsex-remaster, gay-cool-new):** новый ключ
39
+ `appConfig.legacySorts` (через `config/site.config.ts`) — старые значения `?sort`, которых нет у страниц или они под
40
+ другим ключом: старое → ключ API (`''` — аналога нет, 301 на страницу без сортировки). Без карты такие адреса из
41
+ индекса (`?sort=-views`, `?sort=-videos_count`…) станут 404. Образец — коммит videosection-redesign с этим изменением.
42
+ - **Сайтам с поиском по нишам (videosection-redesign, hdsex-remaster):** фича-флаг `FeatureSearchNicheRedirectEnabled`
43
+ теперь читает слой: поиск во «чужой» нише (`/search/gay-anal` на straight) — 302 в нишу фразы; нишу и очищенную фразу
44
+ отдаёт v2 поиска (`POST /bff/search/niche`), как site-flag `isSearchNicheRedirectEnabled` старого движка. Страницы
45
+ поиска слоя зовут `await useSearchNicheRedirect()` первым; своя страница поиска — добавить тот же вызов до запросов
46
+ выдачи. hdsex-remaster: свою реализацию (`server/api/search/niche.get.ts`, `redirectToNicheWithResults` в
47
+ `pages/search/[slug]/index.vue`) удалить — иначе сработают обе.
48
+ - **Сайтам со своей статистикой (hdsex-remaster):** статистика старого движка теперь в слое под `FeatureStatsEnabled`:
49
+ `plugins/stats.client.ts`, `utils/stats/*`, BFF `/bff/stats/{rotator,user,webcams}`, `useDeviceId`, `useWebcamStats`
50
+ (его зовут `GridWebcams` и врезки `GridVideos` — клики/показы вебкам-тумб уходят без вызова на сайте), `useStatsPage`
51
+ (контекст страницы: видео, ролик плейлиста, категория, тег, поиск зовут его сами — ротатор на видео и категории,
52
+ u-logger там же, где на бою). Тумбы сетки видео получили `data-md5` / `data-thumb-number` / `data-pos-number`.
53
+ hdsex-remaster: удалить свои `plugins/stats.client.ts`, `utils/stats-{flush,service,rotator,ulogger,webcams}.ts`,
54
+ `utils/device-id.ts`, `lib/page-info.ts`, `lib/rotator-grid.ts`, `composables/use-webcam-stats.ts`, ручки
55
+ `server/api/stats/{rotator,user,webcams}.post.ts` и вызовы `useWebcamStats` в своих view — иначе при включённом флаге
56
+ статистика уйдёт дважды. Телеметрию рекламы (`stats-ad-availability`, `PUT /stats/c`) оставить своей. Остальным —
57
+ флаг выключен, ничего не меняется.
58
+ - **Сайтам с Яндекс.Метрикой (videosection-redesign; hdsex-remaster — счётчик 94076295 в бэклоге):** счётчик теперь в
59
+ слое — `plugins/metrika.ts`, id из `runtimeConfig.public.metrikaId` (env `NUXT_PUBLIC_METRIKA_ID`; в `nuxt.config.ts`
60
+ сайта — `metrikaId: process.env.NUXT_PUBLIC_METRIKA_ID || ''`), цели — `useMetrikaGoal()`. Пусто — счётчика нет.
61
+
14
62
  ## 0.0.1045 — 2026-10-20
15
63
 
16
64
  - **Сайтам, у которых бэк включает хинди (`/v1/site/languages` отдаёт `name: "in"`, `iso: "hi"` — сейчас
package/app.config.ts CHANGED
@@ -21,6 +21,10 @@ export default defineAppConfig({
21
21
  videos: null as { afterRows: number, inserts: number } | null,
22
22
  models: null as { afterRows: number } | null,
23
23
  },
24
+ /** Старые значения `?sort` → ключ API сортировки редизайна (`''` — аналога нет, 301 на страницу без сортировки).
25
+ * Значения, совпадающие с ключом API сортировки страницы (`-created`, `rank`), узнаются и без карты;
26
+ * прочие неизвестные `?sort` — 404 (`useSortGuard`). */
27
+ legacySorts: {} as Record<string, string>,
24
28
  /** Класс кликабельной части карточек в сетках видео и моделей — для сайтовых хуков (триггер попандера). */
25
29
  cardTriggerClass: '',
26
30
  /** Состав страницы видео. */
@@ -35,6 +39,8 @@ export default defineAppConfig({
35
39
  webcamsBlock: false,
36
40
  /** Сколько моделей из хвоста списка уходит во врезки похожих, когда блок Live Cams включён. */
37
41
  webcamInserts: 8,
42
+ /** С 1024px прятать чипсы моделей над плеером, у которых есть блок в боковой колонке (не дублировать ссылку). */
43
+ hideAsideModelChips: false,
38
44
  /** Мета ролика как на старых движках: description — до трёх фраз перехода (недостающие — из тегов), без них —
39
45
  * категории, склейка ключами `meta.videos-slug.tags_and` / `tags_tail`; в title доступен `{thirdText}` — название
40
46
  * в Title Case. Выключено — description из описания ролика. */
@@ -34,6 +34,9 @@
34
34
  :playlist-id="playlistId"
35
35
  :playlist="playlist"
36
36
  :link-class="cardTriggerClass"
37
+ :data-md5="cell.video.md5"
38
+ :data-thumb-number="cell.videoIndex + 1"
39
+ :data-pos-number="cell.videoIndex + 1"
37
40
  class="grid-videos__card"
38
41
  />
39
42
  </template>
@@ -65,6 +68,9 @@
65
68
  :playlist-id="playlistId"
66
69
  :playlist="playlist"
67
70
  :link-class="cardTriggerClass"
71
+ :data-md5="cell.video.md5"
72
+ :data-thumb-number="cell.videoIndex + 1"
73
+ :data-pos-number="cell.videoIndex + 1"
68
74
  class="grid-videos__card"
69
75
  loading="lazy"
70
76
  hydrate-on-idle
@@ -89,6 +95,9 @@
89
95
  :playlist="playlist"
90
96
  :hydrate-on-visible="HYDRATE_MARGIN"
91
97
  :link-class="cardTriggerClass"
98
+ :data-md5="cell.video.md5"
99
+ :data-thumb-number="cell.videoIndex + 1"
100
+ :data-pos-number="cell.videoIndex + 1"
92
101
  class="grid-videos__card"
93
102
  loading="lazy"
94
103
  />
@@ -141,11 +150,15 @@ const emit = defineEmits<{
141
150
  }>();
142
151
 
143
152
  /** Событие врезки наружу отдаём через хендлер: в шаблоне модель тумбы уже не сузить. */
153
+ const webcamStats = useWebcamStats();
154
+
144
155
  function onInsertClick(cell: IGridCell) {
156
+ if (cell.webcam) webcamStats.onModelClick({ model: cell.webcam, index: cell.insertIndex });
145
157
  if (cell.webcam) emit('model-click', { model: cell.webcam, index: cell.insertIndex });
146
158
  }
147
159
 
148
160
  function onInsertView(cell: IGridCell, element: HTMLElement) {
161
+ if (cell.webcam) webcamStats.onModelView({ model: cell.webcam, index: cell.insertIndex, element });
149
162
  if (cell.webcam) emit('model-view', { model: cell.webcam, index: cell.insertIndex, element });
150
163
  }
151
164
 
@@ -15,8 +15,8 @@
15
15
  >
16
16
  <WebcamThumb
17
17
  :model="model"
18
- @click="emit('model-click', { model, index })"
19
- @poster="element => emit('model-view', { model, index, element })"
18
+ @click="onClick({ model, index })"
19
+ @poster="element => onView({ model, index, element })"
20
20
  />
21
21
  </li>
22
22
  </ul>
@@ -30,13 +30,25 @@ defineProps<{
30
30
  }>();
31
31
 
32
32
  /**
33
- * Клики и показы тумб сетка отдаёт наружу: индексы она знает, а куда их
34
- * отправлять — дело сайта (у статистики свой контекст страницы и устройства).
33
+ * Клики и показы тумб: статистика слоя (`useWebcamStats`, при `FeatureStatsEnabled`) и событиями наружу — для
34
+ * своей статистики сайта.
35
35
  */
36
36
  const emit = defineEmits<{
37
37
  'model-click': [payload: { model: IStripcashModel, index: number }]
38
38
  'model-view': [payload: { model: IStripcashModel, index: number, element: HTMLElement }]
39
39
  }>();
40
+
41
+ const webcamStats = useWebcamStats();
42
+
43
+ function onClick(payload: { model: IStripcashModel, index: number }) {
44
+ webcamStats.onModelClick(payload);
45
+ emit('model-click', payload);
46
+ }
47
+
48
+ function onView(payload: { model: IStripcashModel, index: number, element: HTMLElement }) {
49
+ webcamStats.onModelView(payload);
50
+ emit('model-view', payload);
51
+ }
40
52
  </script>
41
53
 
42
54
  <style lang="scss">
@@ -14,6 +14,7 @@
14
14
  lazy
15
15
  load-on="placeholder"
16
16
  @ended="emit('end')"
17
+ @adstart="siteHooks.onAdStart?.()"
17
18
  @advancerequest="onAdvanceRequest"
18
19
  @sourcechange="onSourceChange"
19
20
  @menurequest="onMenuRequest"
@@ -180,6 +180,11 @@ watchEffect(() => {
180
180
  if (activeNumber.value > length.value && props.total > 0) {
181
181
  navigateTo(linkTo(length.value));
182
182
  }
183
+
184
+ // за последней страницей, а страниц нет вовсе (API отдал пустую выдачу) — увести некуда, адрес мусорный
185
+ if (activeNumber.value > 1 && props.total === 0) {
186
+ throw createError({ statusCode: 404, statusMessage: 'Not Found' });
187
+ }
183
188
  });
184
189
 
185
190
  function linkTo(value: string | number) {
@@ -38,10 +38,8 @@
38
38
  priority
39
39
  >
40
40
  <template #grid-end>
41
- <LazyAdvNativeBottom
42
- v-if="featureFlags.AdsNativeEnabled"
43
- hydrate-on-visible
44
- />
41
+ <!-- не ленивая: ленивая гидрация пересобирала заполненный сетью iframe, креатив терялся -->
42
+ <AdvNativeBottom v-if="featureFlags.AdsNativeEnabled"/>
45
43
  </template>
46
44
  </GridVideos>
47
45
  <div
@@ -29,6 +29,7 @@
29
29
  v-for="(model, index) in data.models"
30
30
  :key="index"
31
31
  :item="getModelChips(model)"
32
+ :class="{'view-video__model-chips': hasAsideBlock(model.title)}"
32
33
  is-link
33
34
  />
34
35
  </template>
@@ -75,11 +76,10 @@
75
76
  />
76
77
  </template>
77
78
 
79
+ <!-- Без ленивой гидрации: контейнер приезжает в серверной разметке и сеть заполняет его до гидрации,
80
+ а ленивая гидрация пересобирала уже заполненный iframe — креатив терялся, слот оставался пустым. -->
78
81
  <template #grid-end>
79
- <LazyAdvNativeBottom
80
- v-if="featureFlags.AdsNativeEnabled"
81
- hydrate-on-visible
82
- />
82
+ <AdvNativeBottom v-if="featureFlags.AdsNativeEnabled"/>
83
83
  </template>
84
84
  </LazyGridVideos>
85
85
  </section>
@@ -126,7 +126,7 @@
126
126
 
127
127
  <script setup lang="ts">
128
128
  import type { IChipsItem, IModelCard, IStripcashModel, IVideoCard, IVideoData, PaginatedResponse } from '../../types';
129
- import { convertModelToRoundCard } from '../../runtime';
129
+ import { convertModelToRoundCard, convertString } from '../../runtime';
130
130
  import { VideosApiService } from '../../services/api/videos.service';
131
131
  import type { AsyncDataRequestStatus } from '#app';
132
132
 
@@ -232,6 +232,15 @@ const topChips = computed(() => {
232
232
  const topChipsStrings = computed(() => topChips.value?.map(item => item.value));
233
233
 
234
234
  /** Портрет в чипсе — квадратный кроп по лицу; своего у видео нет, его подмешивает BFF. */
235
+ // `videoPage.hideAsideModelChips`: с 1024px чипса модели не нужна тем, у кого есть блок в боковой колонке плеера
236
+ // (`useFetchAsideModels`). Снимок, а не реактивное чтение: список заполняется эффектом, и реактивный класс
237
+ // пересчитывался бы во время гидрации — разметка расходилась бы с серверной.
238
+ const asideModelSlugs = new Set(videoPage.hideAsideModelChips ? useAsideModelSlugs().value : []);
239
+
240
+ function hasAsideBlock(title: string): boolean {
241
+ return asideModelSlugs.has(convertString().toSlug(title));
242
+ }
243
+
235
244
  const placeholderOf = useModelPlaceholder();
236
245
 
237
246
  function getModelChips(model: IVideoData['models'][number]): IChipsItem {
@@ -283,6 +292,12 @@ function getModelChips(model: IVideoData['models'][number]): IChipsItem {
283
292
  margin-top: 32px;
284
293
  }
285
294
 
295
+ .view-video__model-chips {
296
+ @include from-br(sd) {
297
+ display: none;
298
+ }
299
+ }
300
+
286
301
  .view-video__popular-models {
287
302
  margin-top: 32px;
288
303
  }
@@ -36,10 +36,8 @@
36
36
  priority
37
37
  >
38
38
  <template #grid-end>
39
- <LazyAdvNativeBottom
40
- v-if="featureFlags.AdsNativeEnabled"
41
- hydrate-on-visible
42
- />
39
+ <!-- не ленивая: ленивая гидрация пересобирала заполненный сетью iframe, креатив терялся -->
40
+ <AdvNativeBottom v-if="featureFlags.AdsNativeEnabled"/>
43
41
  </template>
44
42
 
45
43
  <LazySectionSwiperCards
@@ -48,5 +48,10 @@ export const useFetchModelsByLetter = async (
48
48
  }
49
49
  );
50
50
 
51
+ // за последней страницей API моделей отвечает ошибкой, а не пустой страницей — пагинации не на что увести
52
+ if ((Number(route.query['page']) || 1) > 1 && !data.value?.items?.length) {
53
+ throw createError({ statusCode: 404, statusMessage: 'Not Found' });
54
+ }
55
+
51
56
  return { data, status, refresh };
52
57
  };
@@ -38,5 +38,10 @@ export const useFetchModelsByPhrases = async (
38
38
  }
39
39
  );
40
40
 
41
+ // за последней страницей API моделей отвечает ошибкой, а не пустой страницей — пагинации не на что увести
42
+ if ((Number(route.query['page']) || 1) > 1 && !data.value?.items?.length) {
43
+ throw createError({ statusCode: 404, statusMessage: 'Not Found' });
44
+ }
45
+
41
46
  return { data, status, error };
42
47
  };
@@ -46,5 +46,10 @@ export const useFetchModels = async (
46
46
  }
47
47
  );
48
48
 
49
+ // за последней страницей API моделей отвечает ошибкой, а не пустой страницей — пагинации не на что увести
50
+ if ((Number(route.query['page']) || 1) > 1 && !data.value?.items?.length) {
51
+ throw createError({ statusCode: 404, statusMessage: 'Not Found' });
52
+ }
53
+
49
54
  return { data, status, refresh };
50
55
  };
@@ -1,7 +1,7 @@
1
1
  import { CategoriesApiService } from '../../services/api/categories.service';
2
2
  import { VideosApiService } from '../../services/api/videos.service';
3
3
  import { sortItemsDefault } from '../../lib';
4
- import type { IBreadcrumbItem, ICardInfo, IChipsItem } from '../../types';
4
+ import type { IBreadcrumbItem, ICardInfo, IChipsItem, INavLinkItem } from '../../types';
5
5
 
6
6
  /** Слаги категорий, у которых не показываем связанные поисковые фразы. */
7
7
  const DISALLOWED_SLUGS = ['18', '19'];
@@ -35,7 +35,11 @@ export async function useCategoryPageData() {
35
35
  */
36
36
  export function useCategoryPage(
37
37
  pageData: Awaited<ReturnType<typeof useCategoryPageData>>,
38
- options: { metaKeys?: boolean } = {},
38
+ options: {
39
+ metaKeys?: boolean
40
+ /** Сортировки страницы (по умолчанию `sortItemsDefault`): по ним ключи меты и проверка `?sort` (чужой — 404). */
41
+ sortOptions?: INavLinkItem[]
42
+ } = {},
39
43
  ) {
40
44
  const { categoryInfo, data, status, relatedData } = pageData;
41
45
  const { t } = useI18n();
@@ -48,12 +52,20 @@ export function useCategoryPage(
48
52
 
49
53
  const title = computed(() => categoryInfo.value?.title || '');
50
54
 
55
+ // статистика: ротатор листинга категории + u-logger, как на старом движке
56
+ useStatsPage(() => ({ rotatorID: 'category', categories: slug.value ? [slug.value] : [] }));
57
+
58
+ // с metaKeys `?sort` проверяет useMeta
59
+ const sortOptions = options.sortOptions || sortItemsDefault;
60
+
61
+ if (!options.metaKeys) useSortGuard(sortOptions);
62
+
51
63
  const keysMeta = options.metaKeys
52
64
  ? useMeta({
53
65
  t,
54
66
  pageKey: useGetPureRouteName(),
55
67
  brandName: useAppConfig().brandName,
56
- sortOptions: sortItemsDefault,
68
+ sortOptions,
57
69
  text: title,
58
70
  nicheSafeText: title,
59
71
  seo: () => ({
@@ -4,7 +4,7 @@ import type { Language } from '../../runtime';
4
4
  import { sortItemsDefault } from '../../lib';
5
5
  import { ChannelsApiService } from '../../services/api/channels.service';
6
6
  import { VideosApiService } from '../../services/api/videos.service';
7
- import type { IBreadcrumbItem, IChipsItem, IPopularTags } from '../../types';
7
+ import type { IBreadcrumbItem, IChipsItem, IPopularTags, INavLinkItem } from '../../types';
8
8
 
9
9
  /**
10
10
  * Данные страницы канала: карточка канала, видео, популярные теги. Только запросы — их можно запускать в
@@ -37,7 +37,12 @@ export async function useChannelPageData() {
37
37
  */
38
38
  export function useChannelPage(
39
39
  pageData: Awaited<ReturnType<typeof useChannelPageData>>,
40
- options: { metaSort?: MaybeRefOrGetter<string>, metaSecondText?: Ref<string> } = {},
40
+ options: {
41
+ metaSort?: MaybeRefOrGetter<string>
42
+ metaSecondText?: Ref<string>
43
+ /** Сортировки страницы (по умолчанию `sortItemsDefault`): по ним ключи меты и проверка `?sort` (чужой — 404). */
44
+ sortOptions?: INavLinkItem[]
45
+ } = {},
41
46
  ) {
42
47
  const { cardInfo, status, channelTagsData } = pageData;
43
48
  const route = useRoute();
@@ -50,7 +55,7 @@ export function useChannelPage(
50
55
  t,
51
56
  pageKey: useGetPureRouteName(),
52
57
  brandName: useAppConfig().brandName,
53
- sortOptions: sortItemsDefault,
58
+ sortOptions: options.sortOptions || sortItemsDefault,
54
59
  sort: options.metaSort,
55
60
  text: metaTitle,
56
61
  secondText: options.metaSecondText,
@@ -4,7 +4,7 @@ import type { Language } from '../../runtime';
4
4
  import { sortItemsDefault } from '../../lib';
5
5
  import { ModelsApiService } from '../../services/api/models.service';
6
6
  import { VideosApiService } from '../../services/api/videos.service';
7
- import type { IBreadcrumbItem } from '../../types';
7
+ import type { IBreadcrumbItem, INavLinkItem } from '../../types';
8
8
 
9
9
  /**
10
10
  * Данные страницы модели: видео и карточка модели. Только запросы — их можно запускать в `Promise.all` вместе
@@ -34,7 +34,12 @@ export async function useModelPageData() {
34
34
  */
35
35
  export function useModelPage(
36
36
  pageData: Awaited<ReturnType<typeof useModelPageData>>,
37
- options: { metaSort?: MaybeRefOrGetter<string>, metaSecondText?: Ref<string> } = {},
37
+ options: {
38
+ metaSort?: MaybeRefOrGetter<string>
39
+ metaSecondText?: Ref<string>
40
+ /** Сортировки страницы (по умолчанию `sortItemsDefault`): по ним ключи меты и проверка `?sort` (чужой — 404). */
41
+ sortOptions?: INavLinkItem[]
42
+ } = {},
38
43
  ) {
39
44
  const { status, cardInfo } = pageData;
40
45
  const route = useRoute();
@@ -48,7 +53,7 @@ export function useModelPage(
48
53
  t,
49
54
  pageKey: useGetPureRouteName(),
50
55
  brandName: useAppConfig().brandName,
51
- sortOptions: sortItemsDefault,
56
+ sortOptions: options.sortOptions || sortItemsDefault,
52
57
  sort: options.metaSort,
53
58
  text: metaTitle,
54
59
  secondText: options.metaSecondText,
@@ -20,6 +20,8 @@ export function useSearchPage(
20
20
 
21
21
  const metaTitle = computed(() => convertString().toCapitalize(slug.value));
22
22
 
23
+ useStatsPage(() => ({ search: slug.value }));
24
+
23
25
  const { meta, h1 } = useMeta({
24
26
  t,
25
27
  pageKey: useGetPureRouteName(),
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Идентификатор устройства для статистики — кука `itube-uid` на год.
3
+ *
4
+ * Тот же ключ и тот же алфавит, что на боевом движке (там `nanoid(42)` из `1234567890qwertyuiopasdfghjklzxcvbnm`):
5
+ * статистика склеивается по этому id, поэтому формат менять нельзя. Кука ставится на базовый домен с точкой, чтобы
6
+ * языковые поддомены считались одним устройством.
7
+ */
8
+ const COOKIE_NAME = 'itube-uid';
9
+ const ALPHABET = '1234567890qwertyuiopasdfghjklzxcvbnm';
10
+ const LENGTH = 42;
11
+ const YEAR_SECONDS = 365 * 24 * 60 * 60;
12
+
13
+ function generate(): string {
14
+ const bytes = new Uint8Array(LENGTH);
15
+ crypto.getRandomValues(bytes);
16
+
17
+ return Array.from(bytes, byte => ALPHABET[byte % ALPHABET.length]).join('');
18
+ }
19
+
20
+ /** Возвращает id устройства, при первом заходе создавая его. */
21
+ export function useDeviceId(): string {
22
+ // домен куки ставим только если реально находимся на нём: иначе (стенд на localhost, превью-домен) браузер
23
+ // молча выбрасывает куку и id теряется
24
+ const configured = useRuntimeConfig().public.xDomain as string | undefined;
25
+ const host = import.meta.client ? location.hostname : '';
26
+ const domain = configured && host.endsWith(configured) ? configured : undefined;
27
+
28
+ const cookie = useCookie<string | null>(COOKIE_NAME, {
29
+ maxAge: YEAR_SECONDS,
30
+ path: '/',
31
+ domain: domain ? `.${domain}` : undefined,
32
+ sameSite: 'lax',
33
+ });
34
+
35
+ if (!cookie.value && import.meta.client) {
36
+ cookie.value = generate();
37
+ }
38
+
39
+ return cookie.value || '';
40
+ }
@@ -63,6 +63,7 @@ type MetaParams = {
63
63
  */
64
64
  export function useMeta({ t, pageKey, brandName, sortOptions, sort, text, secondText, thirdText, fourthText, h1Key, seo, nicheSafeText }: MetaParams) {
65
65
  const route = useRoute();
66
+ useSortGuard(sortOptions);
66
67
  const { te, locale } = useI18n();
67
68
  const defaultNiche = NicheHelper.getSiteDefaultNiche();
68
69
  // стейт берём здесь, в setup: computed читается и вне контекста Nuxt (после await, в useHead)
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Цель Яндекс.Метрики сайта: `const reachGoal = useMetrikaGoal()` в setup, `reachGoal('adv.click', params)` в
3
+ * обработчике. Счётчика нет (`metrikaId` пуст) или тег ещё не встал — тихо ничего.
4
+ */
5
+ export function useMetrikaGoal() {
6
+ const id = Number(useRuntimeConfig().public.metrikaId);
7
+
8
+ return (goal: string, params?: Record<string, unknown>) => {
9
+ const ym = import.meta.client ? (window as unknown as { ym?: (...args: unknown[]) => void }).ym : undefined;
10
+ if (id && typeof ym === 'function') ym(id, 'reachGoal', goal, params);
11
+ };
12
+ }
@@ -0,0 +1,44 @@
1
+ import type { Language } from '../runtime';
2
+ import { convertString } from '../runtime';
3
+ import { nichePath, withNichePrefix, type NicheSlugs } from '../config/niches';
4
+ import { SearchApiService } from '../services/api/search.service';
5
+
6
+ /**
7
+ * Поиск во «чужой» нише (`/search/gay-anal` на straight) — 302 в нишу фразы, как site-flag
8
+ * `isSearchNicheRedirectEnabled` старого движка: нишу и очищенную фразу определяет v2 поиска
9
+ * (`SearchApiService.getQueryNiche`). Включается `FeatureSearchNicheRedirectEnabled`; звать первым `await` страницы
10
+ * поиска — до запросов выдачи.
11
+ */
12
+ export async function useSearchNicheRedirect() {
13
+ if (!useRuntimeConfig().public.featureFlags.FeatureSearchNicheRedirectEnabled) return;
14
+
15
+ // всё, что зависит от инстанса Nuxt, — до await: после него контекст потерян
16
+ const nuxtApp = useNuxtApp();
17
+ const slug = useSlug();
18
+ const lang = useLang() as Language;
19
+ const localePath = useLocalePath();
20
+ const appConfig = useAppConfig();
21
+ const currentNiche = NicheHelper.getCurrentNiche();
22
+ const nicheRouting = {
23
+ niches: appConfig.niches as string[],
24
+ defaultNiche: NicheHelper.getSiteDefaultNiche(),
25
+ slugs: appConfig.nicheSlugs as NicheSlugs | undefined,
26
+ };
27
+
28
+ const { data } = await useAsyncData(
29
+ `search-niche-${currentNiche}-${lang}-${slug.value}`,
30
+ () => SearchApiService.getQueryNiche(lang, slug.value, currentNiche).catch(() => null),
31
+ );
32
+
33
+ const niche = data.value?.niche;
34
+ if (!niche || niche === currentNiche || !nicheRouting.niches.includes(niche)) return;
35
+
36
+ const phrase = data.value?.phrase || slug.value;
37
+ const prefix = nichePath(niche, nicheRouting);
38
+
39
+ await nuxtApp.runWithContext(() => {
40
+ const target = withNichePrefix(localePath(`/search/${encodeURIComponent(convertString().toSlug(phrase))}`), prefix, localePath('/'));
41
+
42
+ return navigateTo(target, { redirectCode: 302, replace: true });
43
+ });
44
+ }
@@ -0,0 +1,30 @@
1
+ import { resolveSortQuery } from '../runtime';
2
+ import type { INavLinkItem } from '../types';
3
+
4
+ /**
5
+ * `?sort` страницы против её сортировок (`resolveSortQuery`): мусор — 404, старые значения
6
+ * (`appConfig.legacySorts`, ключи API) — 301 на аналог или на страницу без сортировки. Зовётся в setup страницы:
7
+ * его вызывает `useMeta`, так что листинги проверены без отдельного вызова.
8
+ */
9
+ export function useSortGuard(sortOptions?: INavLinkItem[]) {
10
+ const route = useRoute();
11
+ const raw = route.query['sort'];
12
+ if (raw === undefined || raw === null) return;
13
+
14
+ const legacy = (useAppConfig().legacySorts || {}) as Record<string, string>;
15
+ const resolution = resolveSortQuery(String(raw), sortOptions || [], legacy);
16
+
17
+ if (resolution.action === 'notFound') {
18
+ throw createError({ statusCode: 404, statusMessage: 'Not Found' });
19
+ }
20
+
21
+ if (resolution.action === 'redirect') {
22
+ const { sort: _sort, ...query } = route.query;
23
+
24
+ // в setup не ждём: на сервере ответ-редирект выставляется раньше, чем закончится рендер страницы
25
+ void navigateTo(
26
+ { path: route.path, query: resolution.sort ? { ...query, sort: resolution.sort } : query },
27
+ { redirectCode: 301, replace: true },
28
+ );
29
+ }
30
+ }
@@ -0,0 +1,30 @@
1
+ /** Что страница сообщает статистике (на старых движках — data-атрибуты скриптов `info.js` и `u-logger.js`). */
2
+ export interface IStatsPageContext {
3
+ /** Включает ротатор на странице (`video`, `category`) — на бою он стоял только там. */
4
+ rotatorID?: string
5
+ categories?: string[]
6
+ tags?: string[]
7
+ models?: string[]
8
+ search?: string
9
+ videoMD5?: string
10
+ videoID?: string
11
+ }
12
+
13
+ const STATE_KEY = 'stats-page';
14
+
15
+ /** Контекст статистики текущей страницы — читает `plugins/stats.client.ts`. */
16
+ export const useStatsPageState = () => useState<IStatsPageContext | null>(STATE_KEY, () => null);
17
+
18
+ /**
19
+ * Страница отдаёт статистике свой контекст: без вызова на странице есть только статистика вебкам-тумб, с ним —
20
+ * u-logger (время и интересы) и, с `rotatorID`, ротатор тумб. Звать в setup; данные могут доехать позже — геттер.
21
+ */
22
+ export function useStatsPage(context: () => IStatsPageContext) {
23
+ if (!useRuntimeConfig().public.featureFlags.FeatureStatsEnabled) return;
24
+
25
+ const state = useStatsPageState();
26
+
27
+ watchEffect(() => {
28
+ state.value = context();
29
+ });
30
+ }
@@ -0,0 +1,45 @@
1
+ import type { IStripcashModel } from '../types';
2
+ import { resolvePageInfo } from '../runtime';
3
+ import { observeWebcamView, sendWebcamClick, trackWebcamViews } from '../utils/stats/webcams';
4
+
5
+ /**
6
+ * Статистика вебкам-тумб для места, где они выводятся (`GridWebcams`, врезки `GridVideos`): клик уходит сразу,
7
+ * показ — по нижней границе тумбы, показы копятся и уезжают одним запросом на уходе со страницы
8
+ * (`utils/stats/webcams.ts`). Без `FeatureStatsEnabled` — пустые обработчики.
9
+ */
10
+ export const useWebcamStats = () => {
11
+ const noop = { onModelClick: (_payload: { model: IStripcashModel, index: number }) => undefined, onModelView: (_payload: { model: IStripcashModel, index: number, element: HTMLElement }) => undefined };
12
+ if (!useRuntimeConfig().public.featureFlags.FeatureStatsEnabled) return noop;
13
+
14
+ // всё контекстное — в setup: обработчики зовутся из событий DOM, вне инстанса
15
+ const router = useRouter();
16
+ const deviceId = useDeviceId();
17
+
18
+ const statsContext = () => {
19
+ const route = router.currentRoute.value;
20
+ const { pageType, pageValue, pageNumber } = resolvePageInfo(route.name, route.params, route.query);
21
+
22
+ return { deviceId, niche: NicheHelper.getCurrentNiche() || 'straight', pageType, pageValue, pageNumber };
23
+ };
24
+
25
+ const stopHandlers: Array<() => void> = [];
26
+ const username = (model: IStripcashModel) => String(model.username || model.id || '');
27
+
28
+ onMounted(() => {
29
+ stopHandlers.push(trackWebcamViews(statsContext()));
30
+ });
31
+
32
+ onBeforeUnmount(() => {
33
+ stopHandlers.forEach(stop => stop());
34
+ stopHandlers.length = 0;
35
+ });
36
+
37
+ return {
38
+ onModelClick: ({ model, index }: { model: IStripcashModel, index: number }) => {
39
+ sendWebcamClick(statsContext(), username(model), index + 1);
40
+ },
41
+ onModelView: ({ model, index, element }: { model: IStripcashModel, index: number, element: HTMLElement }) => {
42
+ stopHandlers.push(observeWebcamView(element, username(model), index + 1));
43
+ },
44
+ };
45
+ };