# Design Spec: Lighthouse Optimization (~100)

**Date:** 2026-05-15  
**Status:** Draft — awaiting user review

## Problem

Lighthouse score далеко от 100 из-за:
- Изображения загружаются в полном размере (7–15 МБ файлы для карточек 200px)
- Нет адаптивных изображений (srcset/sizes)
- Нет lazy loading на большинстве изображений
- Нет Cache-Control для `/uploads/`
- JS бандл 2.3 МБ без code splitting
- Минимальные meta-теги в HTML

## Architecture

### 1. Серверный ресайз изображений

#### Интеграция с Fastify

Регистрируем маршрут `GET /uploads-resized/*` ПЕРЕД `fastify-static`:
- Если запрошен `.avif` или `.webp` с `?w=` → sharp конвертирует
- Если запрошен оригинал (без `?w=` или с неподдерживаемым форматом) → редирект на `/uploads/...`
- `fastify-static` остаётся для прямой отдачи `/uploads/` (обратная совместимость)

**URL формат:** `/uploads-resized/:relativePath/:filename.:format?w=WIDTH`

- `:relativePath` — опциональная подпапка (например `reviews/`)
- `:filename` — UUID оригинала
- `:format` — `avif` или `webp`
- `?w=` — целевая ширина (320, 640, 1024, 1600)

**Примеры:**
- `/uploads-resized/abc123.avif?w=640` — AVIF, 640px
- `/uploads-resized/reviews/def456.webp?w=320` — отзыв, WebP, 320px

**Логика:**
1. Парсим URL → находим оригинал в `/uploads/:relativePath/:filename.*`
2. Проверяем кеш: `/uploads/.cache/:relativePath/:filename_w{WIDTH}.{format}`
3. Если кеш есть — отдаём с `Cache-Control: public, max-age=31536000, immutable`
4. Если нет — sharp конвертирует, сохраняет в кеш, отдаёт
5. Без `?w=` — отдаёт оригинал с `Cache-Control: public, max-age=86400`

**Зависимость:** `sharp` (npm package)

#### Разделение папок

| Источник | Путь сохранения |
|---|---|
| Админка (товары, галерея) | `/uploads/` (без изменений) |
| Отзывы | `/uploads/reviews/` |

**Изменения:**
- `server/src/routes/api/public-reviews.js` — `POST /api/reviews/upload-image` → `saveImageBufferToUploads` в `/uploads/reviews/`
- `server/src/lib/upload-images.js` — `saveImageBufferToUploads` принимает опциональный `subdir` параметр

### 2. Клиент: `<OptimizedImage>` компонент

**Расположение:** `client/src/shared/ui/OptimizedImage.tsx`

**Props:**
```ts
type OptimizedImageProps = {
  src: string              // оригинальный URL (/uploads/...)
  alt: string
  widths?: number[]        // по умолчанию [320, 640, 1024, 1600]
  sizes?: string           // HTML sizes attribute
  aspectRatio?: string     // CSS aspect-ratio
  sx?: SxProps<Theme>      // MUI sx
  priority?: boolean       // true = loading="eager", false = "lazy"
  isReview?: boolean       // true = путь /uploads/reviews/
}
```

**Генерация srcset:**
```
/uploads-resized/abc.avif?w=320 320w,
/uploads-resized/abc.avif?w=640 640w,
/uploads-resized/abc.avif?w=1024 1024w,
/uploads-resized/abc.avif?w=1600 1600w
```

**Fallback для Safari без AVIF:** `<picture>` с `<source type="image/avif">` + `<img type="image/webp">`

**Места замены:**
- `ProductCard` — карточки товаров (mediaHeight определяет нужный size)
- `ProductPage` — страница товара + отзывы
- `CatalogSlider` — слайдер каталога
- `GalleryGrid` — галерея админки
- `AdminProductsPage` / `AdminPage` — превью в админке
- `GallerySliderSection` — превью слайдов

### 3. Cache-Control для статики

**Server (`server/src/index.js`):**
- `/uploads/` (оригиналы) → `Cache-Control: public, max-age=86400`
- `/uploads-resized/` → `Cache-Control: public, max-age=31536000, immutable`
- `/uploads/.cache/` → `Cache-Control: public, max-age=31536000, immutable`

### 4. Code splitting

**Vite (`client/vite.config.ts`):**
```
manualChunks:
- vendor-react: react, react-dom, react-router-dom
- vendor-mui: @mui/*, @emotion/*
- vendor-swiper: swiper
- vendor-map: maplibre-gl, react-map-gl
- vendor-tiptap: @tiptap/*
- vendor-effector: effector, effector-react
- vendor-query: @tanstack/react-query
```

**Route-level lazy loading:**
- `/admin/*` → `React.lazy()` — админка не нужна обычным пользователям
- `/me/*` → `React.lazy()` — личный кабинет

### 5. HTML meta оптимизации

**`client/index.html`:**
- `<meta name="description" content="...">`
- `<link rel="preconnect" href="...">` для внешних ресурсов
- OG tags (title, description, image)
- Canonical URL

## Data Flow

```
[Browser] → GET /uploads-resized/abc.avif?w=640
                ↓
[Fastify route /uploads-resized/*] → файл есть в .cache? 
                ↓ NO
    → читаем оригинал /uploads/abc.{png|jpg|webp}
    → sharp конвертирует в AVIF, width=640
    → сохраняем в /uploads/.cache/abc_w640.avif
                ↓ YES
    → читаем из .cache
                ↓
    → отдаём с Cache-Control: public, max-age=31536000, immutable
```

```
[ProductCard] → <OptimizedImage src="/uploads/abc.png" widths={[320,640,1024]} />
                ↓
[Browser] → выбирает нужный размер из srcset по viewport
                ↓
[Server] → отдаёт AVIF из кеша или генерирует
```

## Error Handling

- Оригинал не найден → 404
- Sharp недоступен (не установлен) → fallback на оригинал + warn в лог
- Конвертация失败 → fallback на оригинал
- Невалидный `?w=` → игнорировать, использовать оригинал

## Testing

- Server: тест ресайза — файл создаётся, формат правильный, размер соответствует
- Server: тест кеша — повторный запрос не вызывает повторную конвертацию
- Client: OptimizedImage рендерится с правильным srcset
- Client: fallback для non-AVIF браузеров

## Migration Notes

- Старые файлы в `/uploads/` остаются без изменений
- Новые отзывы → `/uploads/reviews/`
- Конвертация происходит on-demand при первом запросе
- `.gitignore`: `/uploads/.cache/`
