From 8d7538d423808f7558623a3394a541e30a061676 Mon Sep 17 00:00:00 2001 From: IK Date: Mon, 21 Sep 2026 08:02:31 +0700 Subject: [PATCH] feat: add public tackle detail route --- apps/web/src/pages/tackle/items/[id].astro | 39 ++++++++++++++++++++++ apps/web/tests/tackle.spec.ts | 7 ++++ docs/ROADMAP.md | 2 +- 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/pages/tackle/items/[id].astro diff --git a/apps/web/src/pages/tackle/items/[id].astro b/apps/web/src/pages/tackle/items/[id].astro new file mode 100644 index 0000000..0ce3941 --- /dev/null +++ b/apps/web/src/pages/tackle/items/[id].astro @@ -0,0 +1,39 @@ +--- +import AtlasBreadcrumbs from "../../../components/AtlasBreadcrumbs.astro"; +import DataPassport from "../../../components/DataPassport.astro"; +import Layout from "../../../layouts/Layout.astro"; +import PageHero from "../../../components/PageHero.astro"; +import StatePanel from "../../../components/StatePanel.astro"; +import TackleGlyph from "../../../components/TackleGlyph.astro"; +import { ApiError, api, type TackleItem } from "../../../lib/api"; + +const { id } = Astro.params; +let item: TackleItem | undefined; +let unavailable = false; +try { + item = await api(`/api/v1/tackle/items/${encodeURIComponent(id ?? "")}`); +} catch (error) { + unavailable = !(error instanceof ApiError) || error.status >= 500; +} +if (!item && !unavailable) Astro.response.status = 404; +if (unavailable) { + Astro.response.status = 503; + Astro.response.headers.set("Retry-After", "60"); + Astro.response.headers.set("Cache-Control", "no-store"); +} + +const categoryLabels: Record = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Монтаж", float: "Поплавок", sinker: "Груз", other: "Другое" }; +const missingLabels: Record = { subcategory: "подкатегория", brand: "бренд", family: "семейство", unlock_level: "уровень открытия", source_url: "ссылка на источник", source_checked_at: "дата проверки" }; +const missing = item?.missing_fields.map((field) => missingLabels[field] ?? field) ?? []; +--- + + + + {unavailable ? : !item ? : <> +
+
{categoryLabels[item.category] ?? item.category}

{item.name}

{[item.brand, item.family].filter(Boolean).join(" · ") || "Бренд и семейство не указаны."}

+
Подкатегория
{item.subcategory ?? "Не указана"}
Уровень открытия
{item.unlock_level ?? "Не указан"}
Источник
{item.source_system ?? "Не указан"}
{missing.length > 0 &&

Не хватает: {missing.join(", ")}.

}{item.source_url && Открыть первоисточник}
+
+ + } +
diff --git a/apps/web/tests/tackle.spec.ts b/apps/web/tests/tackle.spec.ts index d7de6e3..9be650c 100644 --- a/apps/web/tests/tackle.spec.ts +++ b/apps/web/tests/tackle.spec.ts @@ -16,3 +16,10 @@ test("tackle catalog exposes an explicit empty state for unmatched filters", asy })); expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth); }); + +test("unknown tackle detail has a navigable not-found state", async ({ page }) => { + await page.goto("/tackle/items/00000000-0000-0000-0000-000000000000"); + + await expect(page.getByRole("heading", { name: "Такой карточки нет в каталоге" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Открыть каталог" })).toHaveAttribute("href", "/tackle"); +}); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 898dae0..41ab0d5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -71,7 +71,7 @@ - [ ] **G03 · Модель и provenance.** Добавлены отдельные `tackle_item`, `rig` и `rig_component` с миграцией `0021`; legacy `bait` и `catch_report.bait_id` не изменялись. `tackle_item` хранит `category`, `subcategory`, `brand`, `family`, `unlock_level`, source identity, timestamp и `raw_payload`; `rig_component` хранит роль, порядок, исходное значение и optional canonical item. Связи с catch-потоком добавлены в G05; безопасный backfill остаётся только после реального crosswalk. - [ ] **G04 · Crosswalk и нормализация.** Добавлен offline `gear_crosswalk`: нормализация регистра/пробелов/`е/ё`, точное имя или alias плюс совместимая категория, консервативная проверка brand/family. Неоднозначные, несовместимые, брендовые и unmatched-строки получают review-статус без canonical key; исходное значение сохраняется. Остаётся подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты. - [ ] **G05 · Связи с уловами и источниками.** Добавлены `catch_tackle_component` и offline `gear_components`: можно сохранять несколько unresolved/canonical компонентов с ролью, порядком, исходным значением, source identity и `raw_payload`; legacy `bait_id` не меняется. Parser сохраняет порядок оборудования из detail и разделяет bait/rig в catch-полях. Запись компонентов подключена к community import, официальному импорту и пользовательской форме; повторная обработка идемпотентна. Canonical-привязка и безопасный backfill остаются только после подтверждённого crosswalk. -- [ ] **G06 · API и публичный каталог.** Добавлены пагинированный `/api/v1/tackle/items` с фильтрами по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Остаётся подключить публичные Astro-карточки и ссылки из всех нужных представлений улова. +- [ ] **G06 · API и публичный каталог.** Добавлены пагинированный `/api/v1/tackle/items` с фильтрами по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Публичный каталог теперь имеет detail-route `/tackle/items/:id` с 404/unavailable-различением, паспортом данных и явным списком недостающих полей; остаётся подключить канонические ссылки из представлений улова после подтверждения crosswalk. - [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Пустая или малая выборка получает `insufficient_data`; decay по свежести и отдельные UI-состояния остаются следующим шагом. - [ ] **G08 · Медиа и качество.** Добавлены отдельные reviewed-роли `tackle_card`, `tackle_detail`, `rig_diagram`, `tackle_screenshot` для `tackle`, а также CLI-параметр `--media-role`; offline audit отклоняет неизвестную роль и несовпадение роли с entity type. Существующие dimensions, MIME, SHA-256, прозрачность, aspect ratio, provenance и атомарное продвижение сохраняются. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации. - [ ] **G09 · Приёмка и эксплуатация.** Добавлены fixture/regression tests для crosswalk, media roles, идемпотентных компонентов, пустых результатов и многокомпонентных наблюдений; каталог `/tackle` включён в visual-matrix, narrow smoke и accessibility routes. Offline catalog/media audits и сохранение старых данных при сбое импорта проходят. TEMP-only query-plan gate для фильтра каталога и группировки сочетаний использует индексы и укладывается в 250 мс. Chromium подтвердил empty-state публичного каталога и отсутствие overflow на 320 px; обычный E2E пропускает bootstrap без явных переменных, отдельный bootstrap с токеном проходит. Остаются HTTP/browser acceptance для неоднозначных и многокомпонентных комплектов, ручной visual review и проверяемый счётчик по категориям либо явный `unknown`. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff.