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 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:
{
"@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 02Subject.slug,SubjectGroup.slug→ Spec 15Seller(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 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 доступными извне |