Qadam Roadmap
проектdocs/Agents/specs/2026-03-24-mvp-spec-17-seo-i18n.md

MVP Spec 17 — Cross-Cutting: SEO & i18n

Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев

MVP Spec 17 — Cross-Cutting: SEO & i18n

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

  • Статус документа: working spec
  • Актуально на: 28 марта 2026 года
  • Владелец: backend/platform-команда
  • Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
  • Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
  • Связанные документы:

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 patternDescription patternrobots
/ (каталог)Курсы и кружки в Ташкенте | 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Регистрация | Qadamnoindex
/loginВойти | Qadamnoindex
/forgot-passwordВосстановить пароль | Qadamnoindex
/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:

{
  "@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:

{
  "@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:

// 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

// ─── /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 URLNext.js автоматически разбивает на sitemap-0.xml, sitemap-1.xml, ... + sitemapindex.xml
generateMetadata не может получить данные (Item не найден)Возвращает notFound() → Next.js показывает 404. SEO не нарушается
Пользователь меняет локаль на en, а Item name только на ru/uzОтображается оригинальное название (content string, не переводится)
Ключ перевода отсутствует в en.jsonnext-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 translationsTBDИнструмент не выбран. Кандидаты: i18n-ally, next-intl-ts-codegen. Реализовать до релиза
Переводы на узбекский (латиница) vs узбекский (кириллица)Исключено из MVPВ MVP — только латинский узбекский (uz). Кириллический вариант не поддерживается
Перевод контента продавца (машинный перевод)Вне скоупаАвтоперевод описаний курсов не планируется
AMP (Accelerated Mobile Pages)Вне скоупаНе нужен: Next.js SSR достаточно для Core Web Vitals
Structured data для reviews / ratingsTBDКогда появятся отзывы (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 RoutergenerateMetadata, sitemap.ts, robots.ts — встроенные механизмы
CDNog:image и item.thumbnail_url должны быть абсолютными URL доступными извне