Кастомные хуки и утилиты¶
Уровень: 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; // Цвет