# MVP Spec 17 — Cross-Cutting: SEO & i18n

## Паспорт документа

- Статус документа: working spec
- Актуально на: 28 марта 2026 года
- Владелец: backend/platform-команда
- Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
- Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
- Связанные документы:
  - [Product roadmap и delivery checklist](../product-roadmap.md)
  - [Roadmap](../../project/roadmap.md)
  - [Карта API-маршрутов](../../architecture/api-routes.md)

> Version: MVP · Priority: P1 · Phase: A (Supply)
> Status: Draft v1

---

## 1. Контекст и цель

SEO и интернационализация (i18n) — сквозные архитектурные решения, которые влияют на каждую публичную страницу платформы. Этот документ определяет требования, правила и реализационные паттерны, которым должен следовать каждый разработчик при добавлении новой страницы или нового переводимого контента.

**SEO-цель:** Обеспечить индексацию всех публичных страниц платформы поисковиками (Google, Yandex, Bing). Каждая страница курса и профиля продавца должна получать органический трафик.

**i18n-цель:** Поддержать 3 языка интерфейса — узбекский (uz, основной), русский (ru), английский (en) — с корректным отображением у пользователей любой локали.

**Что входит в этот модуль:**
- SEO-требования по каждому типу страниц
- Правила генерации slug (транслитерация)
- Генерация sitemap.xml
- JSON-LD структурированные данные (Course, Organization)
- Архитектура i18n (next-intl, ICU pluralization)
- Соглашения по ключам переводов
- Паттерны добавления нового переводимого контента

**Что не входит:**
- Сами переводы контента продавцов → хранится в языке продавца, не переводится
- Дизайн страниц → отдельный дизайн-документ
- SEO-анализ и A/B тесты → вне скоупа MVP

---

## 2. Роли пользователей

Этот спек ориентирован на разработчиков платформы, а не на конечных пользователей.

| Роль | Задача в этом контексте |
|------|------------------------|
| **Frontend-разработчик** | Добавляет новую страницу: обязан следовать чеклисту SEO и i18n |
| **Backend-разработчик** | Генерирует slug при создании сущностей, инвалидирует sitemap |
| **Content / Marketer** | Заполняет title/description для новых типов страниц |

---

## 3. Use Cases

Use cases в этом спеке — это паттерны для разработчиков.

---

### UC-01: Разработчик добавляет новую публичную страницу (SEO checklist)

**Актор:** Frontend-разработчик
**Предусловие:** Создаётся новый Route в Next.js App Router (/app/[locale]/...)
**Триггер:** Разработчик реализует новый тип публичной страницы

**Полный поток:**

```
[Точка входа]
→ Разработчик создаёт файл /app/[locale]/example/[slug]/page.tsx

───────────────────────────────────────────────────────
ШАГ 1 — generateMetadata
───────────────────────────────────────────────────────
→ Экспортировать async function generateMetadata({ params }) из page.tsx
→ Функция должна возвращать Metadata со следующими полями:

    title: string             // Обязательно (формат: см. раздел 4.1)
    description: string       // Обязательно, 120–160 символов
    openGraph: {
      title: string           // = title
      description: string     // = description
      url: string             // canonical URL с локалью
      type: 'website'         // или 'article'
      images: [{
        url: string           // абсолютный URL изображения
        width: number
        height: number
        alt: string
      }]
      siteName: 'Qadam'
      locale: string          // 'uz', 'ru', 'en'
    }
    robots: {
      index: boolean          // false для авторизованных зон
      follow: boolean
    }
    alternates: {
      canonical: string       // абсолютный URL без locale (canonical)
      languages: {
        'uz': '/uz/path',
        'ru': '/ru/path',
        'en': '/en/path',
      }
    }

───────────────────────────────────────────────────────
ШАГ 2 — JSON-LD (для страниц с structured data)
───────────────────────────────────────────────────────
→ Добавить <script type="application/ld+json"> в <head>
→ Использовать компонент JsonLd из /components/seo/JsonLd.tsx
→ Передать соответствующую schema (Course или Organization — см. раздел 4.4)

───────────────────────────────────────────────────────
ШАГ 3 — Canonical URL
───────────────────────────────────────────────────────
→ canonical должен указывать на версию страницы WITHOUT locale в URL
   Пример: https://qadam.uz/item/english-uchun-kurs
   НЕ: https://qadam.uz/uz/item/english-uchun-kurs
→ Для страниц с фильтрами (?subject=&city=): canonical = URL без query params

───────────────────────────────────────────────────────
ШАГ 4 — noindex для защищённых зон
───────────────────────────────────────────────────────
→ Страницы /seller/*, /me/*, /admin/*, /login, /register, /forgot-password:
   robots: { index: false, follow: false }
→ Использовать middleware или layout для автоматической установки для всей зоны

───────────────────────────────────────────────────────
ШАГ 5 — SSR
───────────────────────────────────────────────────────
→ Все публичные страницы — Server Components
→ Данные для generateMetadata получаются через тот же API, что и основной контент
→ НЕ использовать 'use client' на уровне page.tsx для публичных страниц
```

---

### UC-02: Разработчик добавляет новый переводимый UI-контент

**Актор:** Frontend-разработчик
**Предусловие:** Нужно добавить новый текст интерфейса на 3 языках
**Триггер:** Разработчик добавляет новый компонент с текстовым содержимым

**Полный поток:**

```
[Точка входа]
→ Разработчик определяет: это UI string (перевести на все языки)
  или Content string (хранить в языке продавца, не переводить)?

───────────────────────────────────────────────────────
ШАГ 1 — Определить пространство имён (namespace)
───────────────────────────────────────────────────────
→ Определить модуль которому принадлежит строка
→ Выбрать соответствующий namespace (см. раздел 5.3)
→ Примеры:
    "auth.login.title"          → auth
    "catalog.filter.subjects"   → catalog
    "seller.items.create.step1" → seller

───────────────────────────────────────────────────────
ШАГ 2 — Добавить ключ во все 3 языковых файла
───────────────────────────────────────────────────────
→ /messages/uz.json  — обязательно
→ /messages/ru.json  — обязательно
→ /messages/en.json  — обязательно (если перевод недоступен — скопировать ru)
→ Соблюдать naming convention (см. раздел 5.3)
→ НИКОГДА не добавлять ключ только в один файл

───────────────────────────────────────────────────────
ШАГ 3 — Использовать в компоненте
───────────────────────────────────────────────────────
Server Component:
    import { getTranslations } from 'next-intl/server'
    const t = await getTranslations('auth')
    return <h1>{t('login.title')}</h1>

Client Component:
    'use client'
    import { useTranslations } from 'next-intl'
    const t = useTranslations('auth')
    return <button>{t('login.submit')}</button>

───────────────────────────────────────────────────────
ШАГ 4 — ICU Pluralization (если нужно)
───────────────────────────────────────────────────────
→ Для числовых форм использовать ICU message format (uz и ru)
→ Пример для uz.json:
    "leads_count": "{count, plural, one {# ta ariza} other {# ta ariza}}"
→ Пример для ru.json:
    "leads_count": "{count, plural, one {# заявка} few {# заявки} many {# заявок} other {# заявки}}"
→ В компоненте: t('leads_count', { count: 5 })

───────────────────────────────────────────────────────
ШАГ 5 — Проверить fallback
───────────────────────────────────────────────────────
→ Если ключ отсутствует в en.json — next-intl использует uz (fallback locale)
→ Убедиться что uz.json содержит перевод для ключа
```

**Альтернативные потоки:**

**2a. Ключ уже существует в uz.json, но отсутствует в ru.json:**
```
Поведение:
→ next-intl логирует warning: "Missing message: ..."
→ Возвращает ключ как строку вместо перевода
→ Действие: немедленно добавить перевод в ru.json до мержа
→ CI/CD: lint-check должен выявить missing keys (см. раздел 5.5)
```

**2b. Разработчик использует хардкодированный текст вместо t():**
```
Проблема:
→ Текст не переводится, пользователи других локалей видят неправильный язык
→ Code review должен выявить и отклонить PR с хардкодом в UI
→ Правило: ВСЕ UI-строки через t(). Исключения: числа, даты, технические строки
```

---

### UC-03: Backend-разработчик создаёт сущность со slug

**Актор:** Backend-разработчик
**Предусловие:** Создаётся новая сущность с slug (Item, Seller, Subject, ...)
**Триггер:** POST /api/... создаёт сущность

**Полный поток:**

```
[Точка входа]
→ Backend получает название сущности (например, Item.name = "Ingliz tili kursi")

───────────────────────────────────────────────────────
ШАГ 1 — Транслитерация
───────────────────────────────────────────────────────
→ Применить функцию transliterate(text: string): string
→ Правила транслитерации (кириллица → латиница, UZ):
    а→a, б→b, в→v, г→g, д→d, е→e, ё→yo, ж→j, з→z
    и→i, й→y, к→k, л→l, м→m, н→n, о→o, п→p, р→r
    с→s, т→t, у→u, ф→f, х→x, ц→ts, ч→ch, ш→sh, щ→sh
    ъ→(убрать), ы→i, ь→(убрать), э→e, ю→yu, я→ya
    ғ→g, қ→q, ҳ→h, ӯ→u, ў→o
→ Привести к нижнему регистру
→ Заменить пробелы и спецсимволы на дефис
→ Убрать последовательные дефисы (-- → -)
→ Убрать дефисы в начале и конце

───────────────────────────────────────────────────────
ШАГ 2 — Проверка уникальности и дополнение суффиксом
───────────────────────────────────────────────────────
→ Проверить уникальность slug в нужной таблице
→ Если slug занят: добавить числовой суффикс
    "ingliz-tili-kursi" → занят
    "ingliz-tili-kursi-2" → свободен → использовать

───────────────────────────────────────────────────────
ШАГ 3 — Сохранить в БД
───────────────────────────────────────────────────────
→ slug сохраняется как уникальное поле
→ slug неизменяем в MVP (изменение slug = разрушение ссылок)
   Исключение: Admin может изменить через специальный endpoint с предупреждением
```

**Альтернативные потоки:**

**3a. Название целиком на кириллице — результат slug некорректный:**
```
Поведение:
→ transliterate() применяется к полю name поочерёдно ко всем языкам
→ Приоритет: uz → ru → en
→ Если uz-название есть — берём его для slug
→ Если только ru — применяем таблицу русской транслитерации (ru → lat)
```

**3b. Название содержит цифры и спецсимволы:**
```
Поведение:
→ Цифры 0-9 сохраняются как есть
→ Спецсимволы (!@#$%...) удаляются
→ Знак "&" → "and" (или "-and-")
→ Slug не может начинаться с цифры: добавляется префикс "item-"
```

---

### UC-04: Система генерирует и обновляет sitemap.xml

**Актор:** Система (автоматически)
**Предусловие:** Существуют активные Item и/или активные Seller
**Триггер:** Запрос к /sitemap.xml или событие изменения статуса

**Полный поток:**

```
[Генерация по запросу — Next.js sitemap.ts]
→ GET /sitemap.xml (или /sitemap-0.xml, /sitemap-1.xml для large sitemaps)
→ Next.js вызывает /app/sitemap.ts (или /app/[locale]/sitemap.ts)
→ Функция запрашивает из БД:
    - Все Item WHERE status = 'active' → /item/{slug}
    - Все Seller WHERE account_status = 'active' → /sellers/{seller_id}
    - Статические страницы: / (каталог), /about, /for-sellers
→ Возвращает SitemapEntry[] с полями:
    url: string         // абсолютный URL
    lastModified: Date  // updated_at сущности
    changeFrequency: 'daily' | 'weekly' | 'monthly'
    priority: number    // 1.0 для главной, 0.8 для item, 0.6 для seller

[Исключения из sitemap]
→ НЕ включать: /me/*, /seller/*, /admin/*
→ НЕ включать: /login, /register, /forgot-password
→ НЕ включать: Item со статусом draft / pending / rejected / archived
→ НЕ включать: Seller со статусом under_review / blocked

[Автоматическая инвалидация]
→ При переходе Item.status → 'active': sitemap должен обновиться
→ При переходе Seller.account_status → 'active': аналогично
→ В MVP: sitemap.ts всегда запрашивает актуальные данные из БД (no caching)
→ Для производительности: Next.js revalidatePath('/sitemap.xml') при изменении статуса
```

---

## 4. SEO-требования по типу страниц

### 4.1 Title и Description по типу страниц

| Страница | Title pattern | Description pattern | robots |
|----------|--------------|---------------------|--------|
| `/` (каталог) | `Курсы и кружки в Ташкенте \| Qadam` | `Найдите лучшие курсы, секции и репетиторов в Ташкенте. {N}+ предложений от проверенных школ.` | index, follow |
| `/` с фильтром по предмету | `{subject_name} — курсы в Ташкенте \| Qadam` | `Курсы по {subject_name} в Ташкенте. Сравнивайте цены и отзывы.` | index, follow |
| `/` с фильтром по городу | `Курсы в {city_name} \| Qadam` | `Курсы и секции в {city_name}. {N}+ предложений от школ и репетиторов.` | index, follow |
| `/item/[slug]` | `{item_name} — {org_name} \| Qadam` | `{item.short_desc} (до 155 символов)` | index, follow |
| `/sellers/[id]` | `{org_name} — курсы и занятия \| Qadam` | `{profile.short_desc} (до 155 символов)` | index, follow |
| `/register` | `Регистрация \| Qadam` | — | noindex |
| `/login` | `Войти \| Qadam` | — | noindex |
| `/forgot-password` | `Восстановить пароль \| Qadam` | — | noindex |
| `/seller/*` | — | — | noindex |
| `/me/*` | — | — | noindex |
| `/admin/*` | — | — | noindex |

### 4.2 Open Graph

Обязательные поля для всех публичных страниц:

```
og:title        = page title
og:description  = page description
og:url          = canonical URL
og:type         = 'website' (для большинства) / 'article' (для news, если будет)
og:image        = URL изображения 1200×630 px
og:site_name    = 'Qadam'
og:locale       = текущая локаль ('uz_UZ', 'ru_RU', 'en_US')
og:locale:alternate = остальные локали
```

Для `/item/[slug]`:
```
og:image = item.thumbnail_url (если нет — og:image seller logo, если нет — default brand image)
```

Для `/sellers/[id]`:
```
og:image = seller.logo_url (если нет — default brand image)
```

### 4.3 Canonical URL и hreflang

```
Canonical: всегда без locale-префикса
  Правильно:  https://qadam.uz/item/ingliz-tili-kursi
  Неправильно: https://qadam.uz/uz/item/ingliz-tili-kursi

hreflang alternates (в <head>):
  <link rel="alternate" hreflang="uz" href="https://qadam.uz/uz/item/slug" />
  <link rel="alternate" hreflang="ru" href="https://qadam.uz/ru/item/slug" />
  <link rel="alternate" hreflang="en" href="https://qadam.uz/en/item/slug" />
  <link rel="alternate" hreflang="x-default" href="https://qadam.uz/item/slug" />

Canonical для фильтрованных страниц каталога:
  URL с query params: /catalog?subject=math&city=tashkent
  canonical = /catalog (без params)
  Исключение: если subject задан — canonical = /catalog?subject={slug}
```

### 4.4 JSON-LD Structured Data

**Для `/item/[slug]` — Schema Course:**

```json
{
  "@context": "https://schema.org",
  "@type": "Course",
  "name": "{item.name}",
  "description": "{item.full_desc (до 500 символов)}",
  "provider": {
    "@type": "Organization",
    "name": "{seller.org_name}",
    "sameAs": "{seller.website_url или null}"
  },
  "url": "https://qadam.uz/item/{item.slug}",
  "image": "{item.thumbnail_url}",
  "offers": {
    "@type": "Offer",
    "price": "{item.price_min}",
    "priceCurrency": "UZS",
    "availability": "https://schema.org/InStock"
  },
  "inLanguage": "uz",
  "educationalLevel": "{item.age_from ? 'children' : 'all'}",
  "hasCourseInstance": {
    "@type": "CourseInstance",
    "courseMode": "{item.format}"
  }
}
```

**Для `/sellers/[id]` — Schema Organization:**

```json
{
  "@context": "https://schema.org",
  "@type": "EducationalOrganization",
  "name": "{seller.org_name}",
  "description": "{seller.short_desc}",
  "url": "https://qadam.uz/sellers/{seller.seller_id}",
  "logo": "{seller.logo_url}",
  "address": {
    "@type": "PostalAddress",
    "addressLocality": "{seller.primary_address.city}",
    "addressCountry": "UZ",
    "streetAddress": "{seller.primary_address.full_address (если display_publicly)}"
  },
  "telephone": "{seller.contact_phone}",
  "email": "{seller.contact_email}",
  "sameAs": ["{seller.website_url}", "{seller.instagram_url}"]
}
```

---

## 5. i18n Архитектура

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

```
Файл конфигурации: /i18n.json (корень проекта)
Библиотека: next-intl
Поддерживаемые локали: ['uz', 'ru', 'en']
Дефолтная локаль: 'uz'
Fallback: uz
Файлы переводов: /messages/uz.json, /messages/ru.json, /messages/en.json

Роутинг с локалью:
  /uz/item/slug  — узбекский
  /ru/item/slug  — русский
  /en/item/slug  — английский
  /item/slug     — редиректит на /uz/item/slug (дефолтная локаль)
```

### 5.2 Два типа контента

| Тип | Описание | Хранение | Перевод |
|-----|----------|----------|---------|
| **UI strings** | Элементы интерфейса: кнопки, лейблы, сообщения об ошибках, заголовки | /messages/{locale}.json | Переводится на все 3 языка |
| **Content strings** | Контент продавца: название курса, описание, адрес | БД (в языке продавца) | НЕ переводится. Отображается как есть |

Правило: если текст вводит продавец → Content string. Если текст написан командой Qadam → UI string.

### 5.3 Naming Convention для ключей переводов

Структура ключа: `{namespace}.{feature}.{element}`

```
Пространства имён (namespaces):
  common        — общие элементы (кнопки, статусы, валидационные сообщения)
  auth          — регистрация, вход, восстановление пароля
  catalog       — каталог, фильтры, карточки
  item          — страница курса
  seller        — личный кабинет продавца
  admin         — панель Admin
  billing       — биллинг (Spec 16)
  reference     — справочные данные (Spec 15)
  errors        — общие сообщения об ошибках
  meta          — SEO title/description шаблоны

Примеры:
  common.button.save           = "Сохранить"
  common.button.cancel         = "Отмена"
  common.validation.required   = "Это поле обязательно для заполнения"
  auth.register.title          = "Регистрация"
  auth.login.email.placeholder = "Email или телефон"
  catalog.filter.subjects.label = "Направление"
  seller.items.create.step1.title = "Основная информация"
  billing.invoice.status.paid  = "Оплачен"
  errors.network               = "Не удалось выполнить запрос. Проверьте соединение."
```

### 5.4 ICU Pluralization

Правила pluralization для uz и ru:

```
Узбекский (uz) — только 2 формы: one (1), other (остальные):
  "{count, plural, one {# ta natija} other {# ta natija}}"
  Примечание: в узбекском one и other часто совпадают, но шаблон обязателен

Русский (ru) — 4 формы: one (1), few (2-4), many (5+), other:
  "{count, plural, one {# заявка} few {# заявки} many {# заявок} other {# заявки}}"

Английский (en) — 2 формы: one (1), other:
  "{count, plural, one {# result} other {# results}}"
```

Примеры ключей с pluralization:

```json
// uz.json
"results_count": "{count, plural, one {# ta natija topildi} other {# ta natija topildi}}",
"leads_count": "{count, plural, one {# ta ariza} other {# ta ariza}}",
"items_count": "{count, plural, one {# ta kurs} other {# ta kurs}}"

// ru.json
"results_count": "{count, plural, one {Найден # результат} few {Найдено # результата} many {Найдено # результатов} other {Найдено # результата}}",
"leads_count": "{count, plural, one {# заявка} few {# заявки} many {# заявок} other {# заявки}}",
"items_count": "{count, plural, one {# курс} few {# курса} many {# курсов} other {# курса}}"
```

### 5.5 Правила добавления нового контента

```
Чеклист при добавлении нового UI-текста:

[ ] Определить namespace
[ ] Добавить ключ в /messages/uz.json
[ ] Добавить ключ в /messages/ru.json
[ ] Добавить ключ в /messages/en.json (или скопировать из ru если перевода нет)
[ ] Использовать t('namespace.key') в компоненте (НЕ хардкод)
[ ] Если есть числа — использовать ICU plural
[ ] Если есть переменные — использовать ICU interpolation: t('key', { name: value })

CI/CD проверки (в планах для v1.0):
[ ] Lint-check: все ключи в uz.json присутствуют в ru.json и en.json
[ ] Type-check: TypeScript типизация ключей через next-intl codegen
[ ] Missing translations: fail CI если ключ отсутствует хотя бы в одном файле
```

---

## 6. Технические контракты

### 6.1 Prisma Schema

Spec 17 не вводит новых моделей данных. Slug-поля уже определены в:
- `Item.slug` → Spec 02
- `Subject.slug`, `SubjectGroup.slug` → Spec 15
- `Seller` (slug через seller_id или отдельное поле) → Spec 01

### 6.2 TypeScript — Утилиты SEO и Slug

```typescript
// ─── /lib/seo/transliterate.ts ────────────────────────────────────────────

const CYR_TO_LAT_MAP: Record<string, string> = {
  'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd',
  'е': 'e', 'ё': 'yo', 'ж': 'j', 'з': 'z', 'и': 'i',
  'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n',
  'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't',
  'у': 'u', 'ф': 'f', 'х': 'x', 'ц': 'ts', 'ч': 'ch',
  'ш': 'sh', 'щ': 'sh', 'ъ': '', 'ы': 'i', 'ь': '',
  'э': 'e', 'ю': 'yu', 'я': 'ya',
  // Узбекские специфичные
  'ғ': 'g', 'қ': 'q', 'ҳ': 'h', 'ӯ': 'u', 'ў': 'o',
}

export function transliterate(text: string): string {
  return text
    .toLowerCase()
    .split('')
    .map(char => CYR_TO_LAT_MAP[char] ?? char)
    .join('')
}

export function generateSlug(text: string): string {
  return transliterate(text)
    .replace(/[^a-z0-9\s-]/g, '')  // убрать спецсимволы
    .replace(/\s+/g, '-')           // пробелы → дефис
    .replace(/-+/g, '-')            // множественные дефисы → один
    .replace(/^-|-$/g, '')          // убрать дефисы по краям
    .substring(0, 80)               // максимальная длина
}

// Генерация уникального slug с суффиксом при коллизии
export async function generateUniqueSlug(
  base: string,
  checkExists: (slug: string) => Promise<boolean>
): Promise<string> {
  const baseSlug = generateSlug(base)
  if (!(await checkExists(baseSlug))) return baseSlug

  let counter = 2
  while (counter <= 99) {
    const candidate = `${baseSlug}-${counter}`
    if (!(await checkExists(candidate))) return candidate
    counter++
  }
  // Fallback: добавить timestamp
  return `${baseSlug}-${Date.now()}`
}

// ─── /lib/seo/jsonld.ts ───────────────────────────────────────────────────

export interface CourseJsonLd {
  '@context': 'https://schema.org'
  '@type': 'Course'
  name: string
  description: string
  provider: {
    '@type': 'Organization'
    name: string
    sameAs?: string
  }
  url: string
  image?: string
  offers?: {
    '@type': 'Offer'
    price: number
    priceCurrency: string
    availability: string
  }
  inLanguage: string
}

export interface OrganizationJsonLd {
  '@context': 'https://schema.org'
  '@type': 'EducationalOrganization'
  name: string
  description: string
  url: string
  logo?: string
  address?: {
    '@type': 'PostalAddress'
    addressLocality: string
    addressCountry: 'UZ'
    streetAddress?: string
  }
  telephone?: string
  email?: string
  sameAs?: string[]
}

export function buildCourseJsonLd(item: ItemDetailDto, seller: SellerPublicDto): CourseJsonLd {
  return {
    '@context': 'https://schema.org',
    '@type': 'Course',
    name: item.name,
    description: item.full_desc.substring(0, 500),
    provider: {
      '@type': 'Organization',
      name: seller.org_name,
      sameAs: seller.website_url ?? undefined,
    },
    url: `https://qadam.uz/item/${item.slug}`,
    image: item.thumbnail_url ?? undefined,
    offers: item.price_min != null ? {
      '@type': 'Offer',
      price: item.price_min,
      priceCurrency: 'UZS',
      availability: 'https://schema.org/InStock',
    } : undefined,
    inLanguage: 'uz',
  }
}

// ─── /components/seo/JsonLd.tsx ──────────────────────────────────────────

interface JsonLdProps {
  data: CourseJsonLd | OrganizationJsonLd
}

export function JsonLd({ data }: JsonLdProps) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  )
}

// ─── /app/sitemap.ts ──────────────────────────────────────────────────────

import { MetadataRoute } from 'next'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const BASE_URL = 'https://qadam.uz'

  const [activeItems, activeSellers] = await Promise.all([
    prisma.item.findMany({
      where: { status: 'active' },
      select: { slug: true, updated_at: true },
    }),
    prisma.seller.findMany({
      where: { account: { account_status: 'active' } },
      select: { seller_id: true, updated_at: true },
    }),
  ])

  const staticPages: MetadataRoute.Sitemap = [
    {
      url: BASE_URL,
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1.0,
    },
    {
      url: `${BASE_URL}/for-sellers`,
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 0.5,
    },
  ]

  const itemPages: MetadataRoute.Sitemap = activeItems.map(item => ({
    url: `${BASE_URL}/item/${item.slug}`,
    lastModified: item.updated_at,
    changeFrequency: 'weekly',
    priority: 0.8,
  }))

  const sellerPages: MetadataRoute.Sitemap = activeSellers.map(seller => ({
    url: `${BASE_URL}/sellers/${seller.seller_id}`,
    lastModified: seller.updated_at,
    changeFrequency: 'weekly',
    priority: 0.6,
  }))

  return [...staticPages, ...itemPages, ...sellerPages]
}
```

### 6.3 API Endpoints

Spec 17 не вводит собственных HTTP-endpoint. Вместо этого определяет:

```
────────────────────────────────────────────────────────────────
NEXT.JS ROUTES (не API, но требуют явного документирования)
────────────────────────────────────────────────────────────────

GET /sitemap.xml
→ Автоматически генерируется из /app/sitemap.ts
→ Включает: все active Items + active Sellers + статические страницы
→ Исключает: /me/*, /seller/*, /admin/*, noindex-страницы

GET /robots.txt
→ /app/robots.ts
→ Allow: / (всё)
→ Disallow: /me/, /seller/, /admin/
→ Sitemap: https://qadam.uz/sitemap.xml

────────────────────────────────────────────────────────────────
ВНУТРЕННИЕ ХУКИ (вызываются из других сервисов)
────────────────────────────────────────────────────────────────

// SitemapService.invalidate() — вызывается при:
//   - Item.status → 'active'  (Spec 02 / Spec 04)
//   - Seller.account_status → 'active'  (Spec 01 / Spec 04)
// Реализация: Next.js revalidatePath('/sitemap.xml')

// SlugService.generate(text, table) — вызывается при создании Item и Subject
```

---

## 7. Edge Cases

| Сценарий | Поведение |
|----------|----------|
| Item name содержит только кириллицу (например, "Курс") | transliterate() возвращает "kurs"; slug = "kurs" |
| Item name содержит только латиницу | transliterate() возвращает как есть (lowercase) |
| Item name содержит только цифры ("3D") | slug = "3d"; не валиден как начало slug → добавляем префикс: "item-3d" |
| Два айтема с одинаковым названием | generateUniqueSlug() → первый = "ingliz-kursi", второй = "ingliz-kursi-2" |
| Продавец меняет название курса | slug НЕ меняется автоматически (immutable после создания). Это защита SEO |
| Slug изменён вручную Admin | Ответственность Admin: старый URL перестаёт работать (нет 301 в MVP) |
| Sitemap содержит > 50,000 URL | Next.js автоматически разбивает на sitemap-0.xml, sitemap-1.xml, ... + sitemapindex.xml |
| generateMetadata не может получить данные (Item не найден) | Возвращает notFound() → Next.js показывает 404. SEO не нарушается |
| Пользователь меняет локаль на en, а Item name только на ru/uz | Отображается оригинальное название (content string, не переводится) |
| Ключ перевода отсутствует в en.json | next-intl возвращает ключ как строку (dev warning). В prod: fallback на uz |
| ICU plural: count = 0 | "{count, plural, zero {нет курсов} one {# курс} ...}" — zero форма опциональна |
| og:image URL недоступен (CDN сбой) | og:image просто не рендерится; страница не ломается |
| Canonical URL содержит UTM-параметры | canonical всегда без UTM и без query params |

---

## 8. TBD / Сознательно опущено

| Тема | Статус | Примечание |
|------|--------|-----------|
| 301-редиректы при смене slug | Исключено из MVP | Нет механизма хранения старых slug. Риск: входящие ссылки ломаются. Реализовать в v1.0 |
| Автоматический CI lint для missing translations | TBD | Инструмент не выбран. Кандидаты: i18n-ally, next-intl-ts-codegen. Реализовать до релиза |
| Переводы на узбекский (латиница) vs узбекский (кириллица) | Исключено из MVP | В MVP — только латинский узбекский (uz). Кириллический вариант не поддерживается |
| Перевод контента продавца (машинный перевод) | Вне скоупа | Автоперевод описаний курсов не планируется |
| AMP (Accelerated Mobile Pages) | Вне скоупа | Не нужен: Next.js SSR достаточно для Core Web Vitals |
| Structured data для reviews / ratings | TBD | Когда появятся отзывы (v1.5) — добавить Review и AggregateRating в JSON-LD |
| Canonical для multilingual (hreflang x-default) | Реализовать до релиза | Базовая реализация описана выше, нужна проверка через Google Search Console |
| Web scraping защита (robots.txt расширенные правила) | TBD | Базовые Disallow добавлены; нужна ли защита от определённых ботов? |
| Core Web Vitals мониторинг | TBD | Метрики LCP, FID, CLS не отслеживаются в MVP. Инструмент: Google Search Console, Lighthouse CI |
| Динамическая og:image генерация (Vercel OG) | TBD | Красивые og:image с логотипом и названием курса. Красиво, но не критично для MVP |

---

## 9. Зависимости

| Модуль | Связь |
|--------|-------|
| **Spec 02** (Item Management) | Item.slug генерируется по правилам этого спека; sitemap включает все active Items |
| **Spec 01** (Seller Onboarding) | Seller публичная страница требует JSON-LD Organization; sitemap включает active Sellers |
| **Spec 15** (Reference Data) | Subject.slug генерируется по тем же правилам транслитерации |
| **Spec 04** (Admin Moderation) | При изменении статуса Item/Seller → инвалидация sitemap |
| **Spec 05** (Catalog) | Catalog filterred pages требуют canonical URL без params |
| **next-intl** | Библиотека i18n. Версия: latest compatible с Next.js App Router |
| **Next.js App Router** | generateMetadata, sitemap.ts, robots.ts — встроенные механизмы |
| **CDN** | og:image и item.thumbnail_url должны быть абсолютными URL доступными извне |
