Перейти к содержанию

Кастомные хуки и утилиты

Уровень: L2 (details) | ← Назад

Обзор

Кастомные хуки и утилиты инкапсулируют переиспользуемую логику: работа с погодой (геолокация + Open-Meteo API), виртуальная пагинация (IntersectionObserver), debounce, Service Worker, а также вспомогательные функции для медиа, изображений и русской локализации.


Хуки

useWeather

Файл: frontend/src/hooks/useWeather.ts

Определение погоды по геолокации для персонализации ленты.

import { useEffect, useState } from 'react';

export type FeedWeatherContext = 'rain' | 'hot' | 'cold' | 'mild';

interface WeatherSnapshot {
  latitude: number;
  longitude: number;
  temperatureC: number;
  weatherCode: number;
  description: string;
  displayText: string;
  feedContext: FeedWeatherContext;
  updatedAt: number;
}

interface UseWeatherResult {
  displayText: string;             // "24° · ясно"
  feedContext?: FeedWeatherContext; // Для фильтрации ленты
  temperatureC?: number;
  loading: boolean;
}

export const WEATHER_CACHE_STORAGE_KEY = 'plechiki_weather_cache_v1';
const WEATHER_CACHE_TTL_MS = 30 * 60 * 1000;  // 30 минут

// Маппинг WMO weather codes → русское описание
function toDescription(weatherCode: number): string {
  if (weatherCode === 0) return 'ясно';
  if (weatherCode <= 3) return 'облачно';
  if (weatherCode === 45 || weatherCode === 48) return 'туман';
  if ([51, 53, 55, 56, 57].includes(weatherCode)) return 'морось';
  if ([61, 63, 65, 66, 67, 80, 81, 82].includes(weatherCode)) return 'дождь';
  if ([71, 73, 75, 77, 85, 86].includes(weatherCode)) return 'снег';
  if ([95, 96, 99].includes(weatherCode)) return 'гроза';
  return 'переменная погода';
}

// Определение контекста для фильтрации ленты
function toFeedContext(temperatureC: number, weatherCode: number): FeedWeatherContext {
  if (isRainLikeCode(weatherCode)) return 'rain';
  if (temperatureC > 25) return 'hot';
  if (temperatureC < 5) return 'cold';
  return 'mild';
}

Особенности: - Кеширование в localStorage (TTL 30 мин) - Fallback при отсутствии геолокации - Режим passive — только чтение кеша (для ленты) - API: Open-Meteo (бесплатный, без ключа)

Параметр Значение
API api.open-meteo.com/v1/forecast
TTL кеша 30 минут
Geolocation timeout 8 секунд
Accuracy Low (enableHighAccuracy: false)

useVirtualPagination

Файл: frontend/src/hooks/useVirtualPagination.ts

Виртуальная пагинация через IntersectionObserver (infinite scroll без сервера).

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

interface UseVirtualPaginationOptions {
  batchSize?: number;      // Порция элементов (default: 20)
  resetKey?: string;       // Сброс при изменении фильтров
  rootMargin?: string;     // Порог preload (default: '220px 0px')
}

export function useVirtualPagination<T>(
  items: T[],
  { batchSize = 20, resetKey = '', rootMargin = '220px 0px' }: UseVirtualPaginationOptions = {}
) {
  const sentinelRef = useRef<HTMLDivElement | null>(null);
  const [visibleCount, setVisibleCount] = useState(batchSize);

  // Срез видимых элементов
  const visibleItems = useMemo(
    () => items.slice(0, visibleCount),
    [items, visibleCount],
  );

  const hasMore = visibleCount < items.length;

  const loadMore = useCallback(() => {
    setVisibleCount((prev) => Math.min(prev + batchSize, items.length));
  }, [batchSize, items.length]);

  // Reset при изменении данных/фильтров
  useEffect(() => {
    setVisibleCount(batchSize);
  }, [batchSize, resetKey]);

  // IntersectionObserver для автоматической подгрузки
  useEffect(() => {
    const node = sentinelRef.current;
    if (!node || !hasMore) return undefined;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0]?.isIntersecting) {
          loadMore();
        }
      },
      { root: null, rootMargin },
    );

    observer.observe(node);
    return () => observer.disconnect();
  }, [hasMore, loadMore, rootMargin]);

  return { visibleItems, hasMore, sentinelRef };
}

Использование:

const { visibleItems, hasMore, sentinelRef } = useVirtualPagination(items, {
  batchSize: 20,
  resetKey: filter,
});

return (
  <>
    {visibleItems.map(item => <ItemCard key={item.id} item={item} />)}
    {hasMore && <div ref={sentinelRef} />}
  </>
);


useDebounce

Файл: frontend/src/hooks/useDebounce.ts

import { useState, useEffect } from 'react';

export function useDebounce<T>(value: T, delay = 400): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debounced;
}

useServiceWorker

Файл: frontend/src/hooks/useServiceWorker.ts

Документирован в PWA → Update Flow.


Утилиты

compressImage

Файл: frontend/src/utils/compressImage.ts

Сжатие изображений перед загрузкой (Canvas API).

interface CompressImageOptions {
  maxWidth?: number;   // Max ширина (default: 896)
  maxHeight?: number;  // Max высота (default: 896)
  quality?: number;    // JPEG quality (default: 0.8)
  format?: 'jpeg' | 'webp';
}

export async function compressImage(file: File, options?: CompressImageOptions): Promise<File>

Алгоритм: 1. Загрузить файл в <img> через URL.createObjectURL 2. Вычислить целевой размер (сохранение aspect ratio) 3. Нарисовать на <canvas> в целевом размере 4. Экспортировать через canvas.toBlob() с указанным quality 5. Вернуть новый File объект


media.ts

Файл: frontend/src/utils/media.ts

URL-утилиты для медиафайлов.

Функция Описание
getItemImageUrl(mediaId, mediaUrl) URL фото вещи (через /api/media/{id}/content)
getOutfitImageUrl(mediaId, mediaUrl) URL обложки образа
getOutfitHeroImageUrl(outfit) Лучшее доступ��ое изображение для экспорта
getAvatarUrl(mediaId, mediaUrl) URL аватара
getCollectionCoverUrl(code) URL SVG-обложки коллекции
handleImgError(e) Обработчик ошибки загрузки → 1×1 transparent PNG
isEmptyPlaceholderImage(url) Проверка на placeholder

generateOutfitStoryImage

Файл: frontend/src/utils/generateOutfitStoryImage.ts

Генерация вертикального изображения 9:16 (1080×1920) для Stories.

export async function generateOutfitStoryJpeg(options: {
  outfitImageSrc: string;
  logoSrc?: string;
}): Promise<Blob>

Алгоритм: 1. Создать canvas 1080×1920 2. Нарисовать градиентный фон (#f7f4ef → #fff5ee → #f0e9e0) 3. Загрузить и масштабировать фото образа (центрирование) 4. Загрузить и разместить логотип внизу 5. Экспортировать как JPEG (quality 0.92)


feedSessionCache

Файл: frontend/src/utils/feedSessionCache.ts

Кеширование бесконечной ленты в sessionStorage.

Параметр Значение
TTL 48 часов
Max pages 8
Storage sessionStorage
Очистка При logout

ruPluralize

Файл: frontend/src/utils/ruPluralize.ts

Склонение су��ествительных после числительных.

export function pluralizeRu(count: number, forms: [string, string, string]): string
// pluralizeRu(1, ['вещь', 'вещи', 'вещей']) → 'вещь'
// pluralizeRu(3, ['вещь', 'вещи', 'вещей']) → 'вещи'
// pluralizeRu(5, ['вещь', 'вещи', 'вещей']) → 'вещей'

showAppConfirm

Файл: frontend/src/utils/showAppConfirm.ts

Обёртка над Ant Design Modal.confirm с дефолтами приложения.


Конфигурация

attrMappings

Файл: frontend/src/config/attrMappings.ts

Маппинг ML-меток на русские названия категорий и цветов.

// ML article_type → русская категория
export const articleTypeToCategoryName: Record<string, string> = {
  Backpacks: 'Сумка',
  Boots: 'Туфли',
  Coats: 'Куртка',
  Dresses: 'Платье',
  Jeans: 'Джинсы',
  Tshirts: 'Футболка',
  // ... 26 маппингов
};

// ML base_colour → русский цвет
export const colourToColorName: Record<string, string> = {
  Black: 'чёрный',
  Blue: 'синий',
  White: 'белый',
  // ... 18 маппингов
};

// ML season → API season value
export const seasonToValue: Record<string, Season> = {
  Summer: 'summer',
  Winter: 'winter',
  Fall: 'autumn',
  Spring: 'spring',
};

// Пороги уверенности для ML-подсказок
export const ML_MIN_ARTICLE_TYPE_CONF = 0.4;   // Категория
export const ML_MIN_BASE_COLOUR_CONF = 0.24;   // Цвет

Навигация