feat: add public tackle detail route

This commit is contained in:
ik
2026-09-21 08:02:31 +07:00
parent 86515a9da1
commit 8d7538d423
3 changed files with 47 additions and 1 deletions
@@ -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<TackleItem>(`/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<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Монтаж", float: "Поплавок", sinker: "Груз", other: "Другое" };
const missingLabels: Record<string, string> = { subcategory: "подкатегория", brand: "бренд", family: "семейство", unlock_level: "уровень открытия", source_url: "ссылка на источник", source_checked_at: "дата проверки" };
const missing = item?.missing_fields.map((field) => missingLabels[field] ?? field) ?? [];
---
<Layout title={item ? `${item.name} — снасти RF4` : "Снасть не найдена — RF4 Spotter"} description={item ? `Подтверждённая карточка ${item.name} в каталоге снастей RF4 с источником и отметками полноты.` : "Такой карточки нет в каталоге снастей RF4."} noindex={!item || unavailable} errorPage={!item || unavailable}>
<AtlasBreadcrumbs items={[{ label: "Снасти", href: "/tackle" }, { label: item?.name ?? "Не найдено" }]} />
<PageHero eyebrow={item ? categoryLabels[item.category] ?? "Каталог RF4" : "Каталог RF4"} title={item?.name ?? (unavailable ? "Каталог недоступен" : "Снасть не найдена")} description={item ? "Канонические характеристики и происхождение без догадок." : undefined} variant={item ? "fish" : undefined} identity={item?.name} />
{unavailable ? <StatePanel tone="unavailable" title="Карточка временно недоступна" description="Каталог не ответил. Непроверенные характеристики не показываем." /> : !item ? <StatePanel tone="error" title="Такой карточки нет в каталоге" actionHref="/tackle" actionLabel="Открыть каталог" /> : <>
<section class="tackle-detail content-grid" aria-label="Характеристики снасти">
<div class="tackle-detail__identity"><TackleGlyph name={item.name} size={72} /><span class="overline">{categoryLabels[item.category] ?? item.category}</span><h2>{item.name}</h2><p>{[item.brand, item.family].filter(Boolean).join(" · ") || "Бренд и семейство не указаны."}</p></div>
<div><dl><div><dt>Подкатегория</dt><dd>{item.subcategory ?? "Не указана"}</dd></div><div><dt>Уровень открытия</dt><dd>{item.unlock_level ?? "Не указан"}</dd></div><div><dt>Источник</dt><dd>{item.source_system ?? "Не указан"}</dd></div></dl>{missing.length > 0 && <p class="tackle-detail__missing"><strong>Не хватает:</strong> {missing.join(", ")}.</p>}{item.source_url && <a data-action="secondary" href={item.source_url} rel="noreferrer">Открыть первоисточник</a>}</div>
</section>
<DataPassport sources={item.source_system ? [item.source_system] : []} sourceUrl={item.source_url} observedAt={item.source_checked_at} completeness={missing.length ? null : 100} status={missing.length ? "incomplete" : item.source_checked_at ? "verified" : "unverified"} />
</>}
</Layout>
+7
View File
@@ -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");
});
+1 -1
View File
@@ -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.