itube-specs 0.0.919 → 0.0.921

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 (35) hide show
  1. package/assets/scss/themes/dark/_theme.scss +31 -5
  2. package/assets/scss/themes/light/_theme.scss +31 -5
  3. package/assets/scss/vars/_palette.scss +3 -0
  4. package/components/card/webcam/index.vue +141 -15
  5. package/components/card/webcam/languages.vue +7 -3
  6. package/components/filter/model.vue +56 -1
  7. package/components/grid/webcams.vue +78 -0
  8. package/components/player/core.vue +37 -1
  9. package/components/ui/switcher.vue +114 -0
  10. package/components/view/webcams.vue +370 -0
  11. package/components/webcam/header.vue +1 -43
  12. package/components/webcam/icon.vue +49 -0
  13. package/components/webcam/main.vue +8 -62
  14. package/components/webcam/stripcash-card.vue +2 -2
  15. package/composables/fetch/use-fetch-models-count.ts +20 -0
  16. package/composables/fetch/use-fetch-webcam-models.ts +45 -0
  17. package/lib/index.ts +1 -0
  18. package/lib/scheme/sort-webcams.ts +22 -0
  19. package/package.json +1 -1
  20. package/pages/webcams/[[tag]].vue +75 -0
  21. package/server/api/models/count.get.ts +18 -0
  22. package/server/api/webcams/list.post.ts +41 -0
  23. package/services/api/models.service.ts +20 -0
  24. package/services/api/webcams.service.ts +34 -0
  25. package/types/index.d.ts +3 -0
  26. package/types/switcher-item.d.ts +13 -0
  27. package/types/webcam-facet.d.ts +7 -0
  28. package/types/webcam-models-response.d.ts +12 -0
  29. package/utils/webcams/__tests__/count-filters.test.ts +12 -0
  30. package/utils/webcams/__tests__/normalize-facets.test.ts +52 -0
  31. package/utils/webcams/__tests__/to-filter-payload.test.ts +17 -0
  32. package/utils/webcams/count-filters.ts +14 -0
  33. package/utils/webcams/facets.ts +31 -0
  34. package/utils/webcams/normalize-facets.ts +57 -0
  35. package/utils/webcams/to-filter-payload.ts +16 -0
@@ -66,8 +66,8 @@ const normalized = computed<IWebcamModel>(() => {
66
66
  viewers: item.viewers_count,
67
67
  age: item.age,
68
68
  country: item.country,
69
- languages: item.languages,
70
- flags: item.flags,
69
+ languages: item.languages || [],
70
+ flags: item.flags || [],
71
71
  tags: visibleTags(item.tags),
72
72
  isNew: item.isNew ?? item.is_new ?? isNewModel(item.tags),
73
73
  goal: item.goalMessage ? { message: item.goalMessage, percent } : undefined,
@@ -0,0 +1,20 @@
1
+ import type { Language } from '../../runtime';
2
+ import { ModelsApiService } from '../../services/api/models.service';
3
+
4
+ /**
5
+ * Сколько всего моделей в нише — например подпись у пункта «Видео» в
6
+ * переключателе разделов на странице вебкамов. Счётчик меняется медленно,
7
+ * поэтому ручка кэшируется на бекенде (заголовок `X-Cache-Time`).
8
+ */
9
+ export const useFetchModelsCount = async () => {
10
+ const lang = useLang() as Language;
11
+ const niche = NicheHelper.getSiteDefaultNiche();
12
+
13
+ const { data } = await useAsyncData(
14
+ `models-count-${niche}-${lang}`,
15
+ () => ModelsApiService.getCount(lang),
16
+ { default: () => ({ total: 0 }) },
17
+ );
18
+
19
+ return computed(() => data.value?.total || 0);
20
+ };
@@ -0,0 +1,45 @@
1
+ import type { MaybeRefOrGetter } from 'vue';
2
+ import type { Language } from '../../runtime';
3
+ import type { IWebcamModelsResponse } from '../../types';
4
+ import { WebcamsApiService } from '../../services/api/webcams.service';
5
+ import { toWebcamFilterPayload } from '../../utils/webcams/to-filter-payload';
6
+ import { WEBCAM_NEW_TAG } from '../../lib';
7
+
8
+ /**
9
+ * Страница вебкам-моделей: выдача провайдера + фасеты фильтров одним запросом.
10
+ *
11
+ * Состояние фильтров живёт в query-параметрах `filter_*` (общая конвенция сайта),
12
+ * страница — в `page`. Сортировки у провайдера нет: `?sort=new` превращается в
13
+ * его тег `new`, а тег из пути (`/webcams/{tag}`) старше сортировки.
14
+ *
15
+ * @param tag - тег из пути раздела, если он есть
16
+ */
17
+ export const useFetchWebcamModels = async (tag: MaybeRefOrGetter<string>) => {
18
+ const route = useRoute();
19
+ const lang = useLang() as Language;
20
+ const mobile = !!useState<boolean>('isMobile').value;
21
+ const perPage = mobile ? 12 : 48;
22
+
23
+ const requestTag = computed(() => toValue(tag) || (route.query.sort === WEBCAM_NEW_TAG ? WEBCAM_NEW_TAG : ''));
24
+ const filters = computed(() => toWebcamFilterPayload(route.query));
25
+
26
+ const { data, status, refresh } = await useAsyncData<IWebcamModelsResponse>(
27
+ 'webcam-models',
28
+ () => useApiFetcher<IWebcamModelsResponse>(
29
+ 'webcam-models',
30
+ WebcamsApiService.getModelsWithFacets,
31
+ lang,
32
+ {
33
+ page: Number(route.query.page) || 1,
34
+ 'per-page': perPage,
35
+ },
36
+ requestTag.value,
37
+ filters.value,
38
+ )(),
39
+ {
40
+ watch: [() => JSON.stringify(route.query), requestTag],
41
+ },
42
+ );
43
+
44
+ return { data, status, refresh };
45
+ };
package/lib/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from './scheme/select-duration-items';
9
9
  export * from './scheme/select-added-items';
10
10
  export * from './scheme/sort-channels';
11
11
  export * from './scheme/sort-models';
12
+ export * from './scheme/sort-webcams';
12
13
  export * from './scheme/sort-playlists';
13
14
  export * from './scheme/sort-collections';
14
15
  export * from './scheme/sort-items-default';
@@ -0,0 +1,22 @@
1
+ import type { INavLinkItem } from '../../types';
2
+
3
+ /**
4
+ * Вебкамы: провайдер сортировку не поддерживает (`order` он игнорирует), выдача
5
+ * всегда идёт по популярности. Поэтому «новые» — это не сортировка, а его тег
6
+ * `new`: им же помечена плашка «новая модель» на тумбе.
7
+ */
8
+ export const sortWebcams: Array<INavLinkItem> = [
9
+ {
10
+ title: 'sort.popular',
11
+ value: 'popular',
12
+ key: 'sort',
13
+ },
14
+ {
15
+ title: 'sort.new',
16
+ value: 'new',
17
+ key: 'sort',
18
+ },
19
+ ];
20
+
21
+ /** Тег провайдера, которым отбираются новые модели (сортировка `?sort=new`). */
22
+ export const WEBCAM_NEW_TAG = 'new';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.919",
4
+ "version": "0.0.921",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -0,0 +1,75 @@
1
+ <!-- Раздел вебкам-моделей. Сайт может переопределить страницу, чтобы подключить
2
+ свою статистику к событиям `model-click` / `model-view` у `ViewWebcams`. -->
3
+ <template>
4
+ <div>
5
+ <UiBreadcrumbs
6
+ :items="breadcrumbs"
7
+ class="_breadcrumbs _container"
8
+ />
9
+
10
+ <ViewWebcams
11
+ :data="data"
12
+ :status="status"
13
+ :h1="h1"
14
+ :tag="tag"
15
+ :models-total="modelsTotal"
16
+ />
17
+
18
+ <SectionSeoText
19
+ :text="t('webcams_footer_text')"
20
+ class="webcams__seo _container"
21
+ />
22
+ </div>
23
+ </template>
24
+
25
+ <script setup lang="ts">
26
+ import type { IBreadcrumbItem } from '../../types';
27
+ import { sortWebcams } from '../../lib';
28
+
29
+ const { t } = useI18n();
30
+ const route = useRoute();
31
+
32
+ /** Тег — сегмент пути (`/webcams/{tag}`), как на боевом движке. */
33
+ const tag = computed(() => String(route.params.tag || ''));
34
+
35
+ /** Теги провайдера приходят слагами (`cheap-privates`) — показываем словами. */
36
+ const tagTitle = computed(() => tag.value.replace(/[-_]+/g, ' '));
37
+
38
+ const [{ data, status }, modelsTotal] = await Promise.all([
39
+ useFetchWebcamModels(tag),
40
+ // счётчик для пункта «Видео» в переключателе разделов
41
+ useFetchModelsCount(),
42
+ ]);
43
+
44
+ const { meta, h1 } = useMeta({
45
+ t,
46
+ pageKey: 'webcams',
47
+ brandName: useAppConfig().brandName,
48
+ sortOptions: sortWebcams,
49
+ text: () => tagTitle.value,
50
+ // у страницы тега свой заголовок: без тега шаблон с подстановкой оставлял
51
+ // висячее двоеточие
52
+ h1Key: () => (tag.value ? 'h1_tag' : 'h1'),
53
+ });
54
+
55
+ watchEffect(() => {
56
+ useHead(meta.value);
57
+ });
58
+
59
+ // пути тут без ниши и локали: их подставляет сам UiBreadcrumbs (generateLink)
60
+ const breadcrumbs = computed<IBreadcrumbItem[]>(() => [
61
+ {
62
+ name: t('navigation.main'),
63
+ to: '/',
64
+ },
65
+ ...(tag.value
66
+ ? [{ name: t('navigation.webcams'), to: '/webcams' }, { name: tagTitle.value }]
67
+ : [{ name: t('navigation.webcams') }]),
68
+ ]);
69
+ </script>
70
+
71
+ <style lang="scss">
72
+ .webcams__seo {
73
+ margin-bottom: 24px;
74
+ }
75
+ </style>
@@ -0,0 +1,18 @@
1
+ import { getUrlWithProxiedParams } from '../../../runtime';
2
+
3
+ /**
4
+ * Количество моделей в нише — для счётчиков в интерфейсе (например у пункта
5
+ * «Видео» в переключателе разделов на странице вебкамов).
6
+ *
7
+ * Тонкий прокси к `GET /v2/models/count`: ниша и язык приезжают заголовками от
8
+ * клиента и форвардятся дальше вместе с остальными. Ответ кэшируем на час —
9
+ * счётчик меняется медленно, а показывается на каждой странице раздела.
10
+ */
11
+ export default defineEventHandler(async (event): Promise<{ total: number }> => {
12
+ const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
13
+ const url = getUrlWithProxiedParams(`${apiUrl}/v2/models/count`, event);
14
+
15
+ const data = await cachedApiFetch<{ total?: number }>(url, event, 3600).catch(() => null);
16
+
17
+ return { total: data?.total || 0 };
18
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Листинг вебкам-моделей с фасетами — источник данных раздела `/webcams`.
3
+ *
4
+ * Тонкий прокси к POST `/v2/stripcash/online-models-filter`: тело (выбранные
5
+ * фильтры) уходит как есть, ответ разворачивается в плоскую форму слоя
6
+ * (`items` / `total` / `allPages` / `currentPage` / `filters`).
7
+ *
8
+ * Ручка отвечает 400 без непустого `tag`, а «любой тег» понимает как пробел —
9
+ * поэтому при отсутствии тега подставляем пробел. Ниша и язык приезжают
10
+ * заголовками `X-Niche` / `X-Language` от клиента (без них выдача пустая) и
11
+ * форвардятся дальше вместе с остальными заголовками запроса.
12
+ */
13
+ export default defineEventHandler(async (event) => {
14
+ const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
15
+ const query = getQuery(event);
16
+
17
+ const url = new URL(`${apiUrl}/v2/stripcash/online-models-filter`);
18
+ url.search = new URLSearchParams({
19
+ page: String(query.page || 1),
20
+ 'per-page': String(query['per-page'] || 48),
21
+ tag: String(query.tag || ' '),
22
+ }).toString();
23
+
24
+ const body = await readBody(event);
25
+
26
+ const rawData = await apiFetch(url.toString(), {
27
+ method: 'POST',
28
+ headers: event.node.req.headers as HeadersInit,
29
+ body: JSON.stringify(body),
30
+ });
31
+
32
+ const data = rawData?.data || {};
33
+
34
+ return {
35
+ items: data.items || [],
36
+ filters: data.filters || [],
37
+ total: data.total || 0,
38
+ allPages: data.all_pages || 0,
39
+ currentPage: data.current_page || 1,
40
+ };
41
+ });
@@ -285,6 +285,26 @@ export class ModelsApiService {
285
285
  );
286
286
  }
287
287
 
288
+ /** Сколько всего моделей в нише — для счётчиков в интерфейсе. */
289
+ static async getCount(
290
+ lang: Language,
291
+ niche? = NicheHelper.getSiteDefaultNiche(),
292
+ ): Promise<{ total: number }> {
293
+ return await ApiHelper.fetch(
294
+ '/models/count',
295
+ {
296
+ method: 'GET',
297
+ headers: {
298
+ 'X-Niche': niche,
299
+ 'X-Language': toHeaderLanguage(lang),
300
+ 'X-Cache-Time': 3600,
301
+ 'Content-Type': 'application/json',
302
+ },
303
+ },
304
+ useRuntimeConfig(),
305
+ );
306
+ }
307
+
288
308
  static async getTopChips(
289
309
  limit: number,
290
310
  max_sampling_limit: number,
@@ -0,0 +1,34 @@
1
+ import type { Language } from '../../runtime';
2
+ import { ApiHelper, toHeaderLanguage } from '../../runtime';
3
+ import type { IWebcamModelsResponse } from '../../types';
4
+
5
+ /** Сервис вебкам-моделей: листинг раздела `/webcams` с фасетами фильтров. */
6
+ export class WebcamsApiService {
7
+ static async getModelsWithFacets(
8
+ lang: Language,
9
+ pagination: { page: number, 'per-page': number },
10
+ tag: string,
11
+ filters: Array<{ name: string, value: string }>,
12
+ niche? = NicheHelper.getSiteDefaultNiche(),
13
+ ): Promise<IWebcamModelsResponse> {
14
+ return await ApiHelper.fetch(
15
+ '/webcams/list',
16
+ {
17
+ method: 'POST',
18
+ headers: {
19
+ 'X-Niche': niche,
20
+ 'X-Language': toHeaderLanguage(lang),
21
+ 'Content-Type': 'application/json',
22
+ },
23
+ params: {
24
+ ...pagination,
25
+ tag,
26
+ },
27
+ body: {
28
+ filters,
29
+ },
30
+ },
31
+ useRuntimeConfig(),
32
+ );
33
+ }
34
+ }
package/types/index.d.ts CHANGED
@@ -75,3 +75,6 @@ export * from './webcam-model';
75
75
  export * from './webcam-counts';
76
76
  export * from './stripcash-model';
77
77
  export * from './webcams-config';
78
+ export * from './switcher-item';
79
+ export * from './webcam-facet';
80
+ export * from './webcam-models-response';
@@ -0,0 +1,13 @@
1
+ /** Опция переключателя: значение, подпись и необязательные иконка с подписью-счётчиком. */
2
+ export interface ISwitcherItem {
3
+ /** Значение опции — оно уходит в `v-model`. */
4
+ name: string;
5
+ /** Подпись опции. */
6
+ title: string;
7
+ /** Имя иконки (коллекция `custom`), рисуется перед подписью-счётчиком. */
8
+ icon?: string;
9
+ /** Второстепенная подпись: количество, «30 online» и подобное. */
10
+ subtitle?: string;
11
+ /** Адрес перехода: с ним опция рисуется ссылкой, а не кнопкой выбора. */
12
+ to?: string;
13
+ }
@@ -0,0 +1,7 @@
1
+ /** Фасет фильтра в формате провайдера: имя с префиксом `mf_` и его варианты. */
2
+ export interface IWebcamFacet {
3
+ name?: string;
4
+ title?: string;
5
+ kind?: string;
6
+ options?: Array<{ name: string, title: string, quantity: number }> | null;
7
+ }
@@ -0,0 +1,12 @@
1
+ import type { IStripcashModel } from './stripcash-model';
2
+ import type { IWebcamFacet } from './webcam-facet';
3
+
4
+ /** Ответ листинга вебкам-моделей: страница выдачи и фасеты фильтров. */
5
+ export interface IWebcamModelsResponse {
6
+ items: IStripcashModel[];
7
+ /** Фасеты в формате провайдера (префикс `mf_`) — нормализуются на клиенте. */
8
+ filters: IWebcamFacet[];
9
+ total: number;
10
+ allPages: number;
11
+ currentPage: number;
12
+ }
@@ -0,0 +1,12 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { countWebcamFilters } from '../count-filters';
3
+
4
+ describe('countWebcamFilters', () => {
5
+ it('считает уникальные фильтры, склеивая границы диапазона', () => {
6
+ expect(countWebcamFilters({ filter_age_from: '20', filter_age_to: '30', filter_country: 'co', page: '3' })).toBe(2);
7
+ });
8
+
9
+ it('без фильтров — ноль', () => {
10
+ expect(countWebcamFilters({ sort: 'new' })).toBe(0);
11
+ });
12
+ });
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { normalizeWebcamFacets } from '../normalize-facets';
3
+ import { WEBCAM_FILTER_ANY } from '../facets';
4
+
5
+ const FACETS = [
6
+ { name: 'mf_age', title: 'Age', kind: 'range', options: [{ name: '19', title: '', quantity: 0 }, { name: '38', title: '', quantity: 0 }] },
7
+ { name: 'mf_country', title: 'Country', kind: 'select', options: [{ name: 'co', title: 'Colombia', quantity: 13 }, { name: '', title: '', quantity: 11 }, { name: 'us', title: 'USA', quantity: 2 }] },
8
+ { name: 'mf_eye_color', title: 'Eye Color', kind: 'select', options: [{ name: 'brown', title: 'Brown', quantity: 10 }] },
9
+ { name: 'mf_tag', title: 'Tag', kind: 'select', options: [{ name: 'milf', title: 'Milf', quantity: 3 }, { name: 'teen', title: 'Teen', quantity: 5 }] },
10
+ ];
11
+
12
+ describe('normalizeWebcamFacets', () => {
13
+ it('снимает префикс провайдера и выкидывает технические фасеты', () => {
14
+ expect(normalizeWebcamFacets(FACETS).map(item => item.name)).toEqual(['age', 'country']);
15
+ });
16
+
17
+ it('отбрасывает опции с пустым значением', () => {
18
+ const country = normalizeWebcamFacets(FACETS).find(item => item.name === 'country');
19
+
20
+ expect(country?.options.map(option => option.name)).toEqual([WEBCAM_FILTER_ANY, 'co', 'us']);
21
+ });
22
+
23
+ it('добавляет селекту вариант «любой» с общим количеством, диапазону — нет', () => {
24
+ const [age, country] = normalizeWebcamFacets(FACETS, 930);
25
+
26
+ expect(age.options.map(option => option.name)).toEqual(['19', '38']);
27
+ expect(country.options[0]).toEqual({ name: WEBCAM_FILTER_ANY, title: WEBCAM_FILTER_ANY, quantity: 930 });
28
+ });
29
+
30
+ it('скрывает селект с одним вариантом и слипшийся диапазон', () => {
31
+ const degenerate = [
32
+ { name: 'mf_eye_color', kind: 'select', options: [{ name: 'brown', title: 'Brown', quantity: 1 }] },
33
+ { name: 'mf_age', kind: 'range', options: [{ name: '25', title: '', quantity: 0 }, { name: '25', title: '', quantity: 0 }] },
34
+ ];
35
+
36
+ expect(normalizeWebcamFacets(degenerate)).toEqual([]);
37
+ });
38
+
39
+ it('раскладывает фильтры по группам: строка фильтров и модалка', () => {
40
+ const groups = normalizeWebcamFacets([
41
+ ...FACETS,
42
+ { name: 'mf_body_type', title: 'Body Type', kind: 'select', options: [{ name: 'thin', title: 'Thin', quantity: 6 }, { name: 'athletic', title: 'Athletic', quantity: 4 }] },
43
+ ]).map(item => [item.name, item.group.name]);
44
+
45
+ expect(groups).toEqual([['age', 'quick'], ['country', 'quick'], ['body_type', 'appearance']]);
46
+ });
47
+
48
+ it('пустой ответ провайдера не роняет нормализацию', () => {
49
+ expect(normalizeWebcamFacets(null)).toEqual([]);
50
+ expect(normalizeWebcamFacets(undefined)).toEqual([]);
51
+ });
52
+ });
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { toWebcamFilterPayload } from '../to-filter-payload';
3
+
4
+ describe('toWebcamFilterPayload', () => {
5
+ it('переводит query-фильтры в параметры провайдера', () => {
6
+ expect(toWebcamFilterPayload({ filter_country: 'co', filter_age_from: '20', page: '2', sort: 'new' }))
7
+ .toEqual([{ name: 'mf_country', value: 'co' }, { name: 'mf_age_from', value: '20' }]);
8
+ });
9
+
10
+ it('пустые значения не отправляем', () => {
11
+ expect(toWebcamFilterPayload({ filter_country: '', filter_language: undefined })).toEqual([]);
12
+ });
13
+
14
+ it('из массива значений берёт первое', () => {
15
+ expect(toWebcamFilterPayload({ filter_language: ['en', 'es'] })).toEqual([{ name: 'mf_language', value: 'en' }]);
16
+ });
17
+ });
@@ -0,0 +1,14 @@
1
+ import { WEBCAM_FILTER_QUERY_PREFIX } from './facets';
2
+
3
+ /**
4
+ * Сколько фильтров применено — для счётчика на кнопке «Фильтр». Границы диапазона
5
+ * (`_from` / `_to`) считаются одним фильтром.
6
+ * @param query - query текущего роута
7
+ */
8
+ export function countWebcamFilters(query: Record<string, unknown>): number {
9
+ const names = Object.keys(query)
10
+ .filter(key => key.startsWith(WEBCAM_FILTER_QUERY_PREFIX))
11
+ .map(key => key.replace(/_(from|to)$/, ''));
12
+
13
+ return new Set(names).size;
14
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Фасетные фильтры вебкам-моделей: имена и группы, по которым фасеты провайдера
3
+ * приводятся к формату слоя.
4
+ *
5
+ * Провайдер (Stripcash) отдаёт фасеты с префиксом `mf_` и без групп, а готовые
6
+ * фильтр-компоненты слоя (`FilterSelects`, `FilterModel`, `FilterSlider`) работают
7
+ * с `IModelFilter` и держат состояние в query-параметрах `filter_*`. Поэтому
8
+ * вебкамы живут на общей конвенции сайта (`?filter_country=co`), а префикс
9
+ * провайдера приклеивается только в запросе.
10
+ */
11
+
12
+ /** Префикс фасетов у провайдера. */
13
+ export const WEBCAM_FACET_PREFIX = 'mf_';
14
+
15
+ /** Префикс query-параметров фильтров — общий для всего сайта. */
16
+ export const WEBCAM_FILTER_QUERY_PREFIX = 'filter_';
17
+
18
+ /** Фасеты, которые провайдер отдаёт, а интерфейсу они не нужны. */
19
+ export const WEBCAM_EXCLUDED_FACETS = ['interested_in', 'tag'];
20
+
21
+ /** Фасеты в строке фильтров; остальные уезжают в модалку «Фильтр». */
22
+ export const WEBCAM_QUICK_FACETS = ['age', 'language', 'country', 'specifics'];
23
+
24
+ /** Значение селекта «любой» — слой по нему удаляет параметр из query. */
25
+ export const WEBCAM_FILTER_ANY = 'all';
26
+
27
+ /** Группы фильтров: строка фильтров и всё остальное. */
28
+ export const WEBCAM_FILTER_GROUPS = {
29
+ quick: { name: 'quick', order: 0 },
30
+ more: { name: 'appearance', order: 1 },
31
+ };
@@ -0,0 +1,57 @@
1
+ import type { IModelFilter, IModelFilterOptions, IWebcamFacet } from '../../types';
2
+ import {
3
+ WEBCAM_EXCLUDED_FACETS,
4
+ WEBCAM_FACET_PREFIX,
5
+ WEBCAM_FILTER_ANY,
6
+ WEBCAM_FILTER_GROUPS,
7
+ WEBCAM_QUICK_FACETS,
8
+ } from './facets';
9
+
10
+ /**
11
+ * Приводит фасеты провайдера к фильтрам слоя.
12
+ *
13
+ * Правила скрытия и исключений взяты с боевого движка: технические фасеты не
14
+ * рисуются, вырожденные контролы (один вариант, слипшийся диапазон) — тоже.
15
+ *
16
+ * Селектам добавляем вариант «любой» первым: в разметке слоя первая опция — это
17
+ * значение «фильтр не выбран», без неё нельзя выбрать то, что стоит первым в
18
+ * списке. Диапазонам такой вариант не нужен — у них границы.
19
+ *
20
+ * @param facets - фасеты из ответа провайдера
21
+ * @param total - всего моделей в текущей выдаче (подпись у варианта «любой»)
22
+ */
23
+ export function normalizeWebcamFacets(facets: IWebcamFacet[] | null | undefined, total = 0): IModelFilter[] {
24
+ return (facets || []).reduce<IModelFilter[]>((result, facet) => {
25
+ const name = String(facet.name || '').replace(WEBCAM_FACET_PREFIX, '');
26
+ if (!name || WEBCAM_EXCLUDED_FACETS.includes(name)) return result;
27
+
28
+ // пустое значение у провайдера означает «не указано» — как вариант выбора
29
+ // оно бессмысленно (в старом движке такие опции тоже отбрасывались)
30
+ const options = (facet.options || []).filter(option => option.name !== '');
31
+ const isRange = facet.kind === 'range';
32
+
33
+ if (isRange && !isRangeUsable(options)) return result;
34
+ if (!isRange && options.length < 2) return result;
35
+
36
+ const group = WEBCAM_QUICK_FACETS.includes(name) ? WEBCAM_FILTER_GROUPS.quick : WEBCAM_FILTER_GROUPS.more;
37
+
38
+ result.push({
39
+ name,
40
+ title: String(facet.title || name),
41
+ kind: String(facet.kind || 'select'),
42
+ options: isRange ? options : [{ name: WEBCAM_FILTER_ANY, title: WEBCAM_FILTER_ANY, quantity: total }, ...options],
43
+ group: { name: group.name, title: group.name, order: group.order, description: '' },
44
+ icon: '',
45
+ show_on_form: true,
46
+ });
47
+
48
+ return result;
49
+ }, []);
50
+ }
51
+
52
+ /** Диапазон имеет смысл, только если границы разные. */
53
+ function isRangeUsable(options: IModelFilterOptions[]): boolean {
54
+ const values = options.map(option => Number(option.name)).filter(value => !isNaN(value));
55
+
56
+ return values.length > 1 && Math.min(...values) !== Math.max(...values);
57
+ }
@@ -0,0 +1,16 @@
1
+ import { WEBCAM_FACET_PREFIX, WEBCAM_FILTER_QUERY_PREFIX } from './facets';
2
+
3
+ /**
4
+ * Собирает тело запроса к провайдеру из query-параметров фильтров:
5
+ * `?filter_country=co` → `[{ name: 'mf_country', value: 'co' }]`.
6
+ * @param query - query текущего роута
7
+ */
8
+ export function toWebcamFilterPayload(query: Record<string, unknown>): Array<{ name: string, value: string }> {
9
+ return Object.entries(query)
10
+ .filter(([key]) => key.startsWith(WEBCAM_FILTER_QUERY_PREFIX))
11
+ .map(([key, value]) => ({
12
+ name: `${WEBCAM_FACET_PREFIX}${key.slice(WEBCAM_FILTER_QUERY_PREFIX.length)}`,
13
+ value: String(Array.isArray(value) ? value[0] : value ?? ''),
14
+ }))
15
+ .filter(item => item.value !== '');
16
+ }