@@ -5,7 +5,7 @@ import StatePanel from "../../components/StatePanel.astro";
import Pagination from "../../components/Pagination.astro";
import TackleGlyph from "../../components/TackleGlyph.astro";
import DataPassport from "../../components/DataPassport.astro";
import { api, type PaginatedRigs, type PaginatedTackleItems } from "../../lib/api";
import { api, ApiError, type PaginatedRigs, type PaginatedTackleItems } from "../../lib/api";
const params = Astro.url.searchParams;
const category = params.get("category") ?? "";
@@ -14,23 +14,34 @@ const brand = params.get("brand") ?? "";
const family = params.get("family") ?? "";
const unlockLevelParam = params.get("unlock_level") ?? "";
const unlockLevel = /^\d+$/.test(unlockLevelParam) ? unlockLevelParam : "";
const requestedOffset = Number(params.get("offset") ?? 0);
const requestedItems Offset = Number(params.get("items_ offset") ?? 0);
const requestedRigsOffset = Number(params.get("rigs_offset") ?? 0);
const limit = 48;
const o ffset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
const query = new URLSearchParams({ limit: String(limit), offset: String(o ffset) }) ;
const itemsO ffset = Number.isInteger(requestedItems Offset) && requestedItems Offset >= 0 ? requestedItems Offset : 0;
const rigsOffset = Number.isInteger(requestedRigsOffset) && requestedRigsOffset >= 0 ? requestedRigsO ffset : 0 ;
const query = new URLSearchParams({ limit: String(limit), offset: String(itemsOffset) });
if (category) query.set("category", category);
if (search) query.set("q", search);
if (brand) query.set("brand", brand);
if (family) query.set("family", family);
if (unlockLevel) query.set("unlock_level", unlockLevel);
let result: PaginatedTackleItems = { items: [], total: 0, limit, offset }, rigs: PaginatedRigs = { items: [], total: 0, limit, offset }, unavailable = false, rigsUnavailable = false ;
let result: PaginatedTackleItems = { items: [], total: 0, limit, offset: itemsOffset }, rigs: PaginatedRigs = { items: [], total: 0, limit, offset: rigsOffset } ;
const [itemsResult, rigsResult] = await Promise.allSettled([
api<PaginatedTackleItems>(`/api/v1/tackle/items?${query}`),
api<PaginatedRigs>(`/api/v1/tackle/rigs?limit=${limit}&offset=${o ffset}${search ? `&q=${encodeURIComponent(search)}` : ""}`),
api<PaginatedRigs>(`/api/v1/tackle/rigs?limit=${limit}&offset=${rigsO ffset}${search ? `&q=${encodeURIComponent(search)}` : ""}`),
]);
if ( itemsResult.status === "fulfilled") result = itemsResult.value; else unavailable = true ;
if ( rigsResult.status === "fulfilled") rigs = rigsResult.value; else rigsUnavailable = true ;
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); }
const itemsError = itemsResult.status === "rejected" ? itemsResult.reason : null ;
const rigsError = rigsResult.status === "rejected" ? rigsResult.reason : null ;
const itemsInvalid = itemsError instanceof ApiError && itemsError.status === 422;
const rigsInvalid = rigsError instanceof ApiError && rigsError.status === 422;
const itemsUnavailable = Boolean(itemsError) && !itemsInvalid;
const rigsUnavailable = Boolean(rigsError) && !rigsInvalid;
const invalidFilters = itemsInvalid || rigsInvalid;
const unavailable = itemsUnavailable || rigsUnavailable;
if (itemsResult.status === "fulfilled") result = itemsResult.value;
if (rigsResult.status === "fulfilled") rigs = rigsResult.value;
if (invalidFilters) Astro.response.status = 422;
else 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 filterParams = new URLSearchParams();
if (category) filterParams.set("category", category);
@@ -38,8 +49,10 @@ if (search) filterParams.set("q", search);
if (brand) filterParams.set("brand", brand);
if (family) filterParams.set("family", family);
if (unlockLevel) filterParams.set("unlock_level", unlockLevel);
const itemsParams = new URLSearchParams(filterParams);
const rigsParams = new URLSearchParams(search ? { q: search } : {});
---
<Layout title="Снасти и приманки RF4 — RF4 Spotter" description="Канонический каталог снастей, приманок и сборок Russian Fishing 4 с источниками и отметками неполноты." noindex={unavailable} errorPage={unavailabl e}>
<Layout title="Снасти и приманки RF4 — RF4 Spotter" description="Канонический каталог снастей, приманок и сборок Russian Fishing 4 с источниками и отметками неполноты." noindex={unavailable || invalidFilters } errorPage={fals e}>
<PageHero eyebrow="Канонический справочник" title="Снасти и приманки" description="Показываем только подтверждённые карточки. Пустые поля явно отмечены и не заменяются догадками." variant="fish" count={result.total} />
<form class="record-filters" method="get" aria-label="Фильтры каталога снастей">
<label>Категория<select name="category"><option value="">Все категории</option>{Object.entries(categoryLabels).map(([value, label]) => <option value={value} selected={category === value}>{label}</option>)}</select></label>
@@ -51,7 +64,10 @@ if (unlockLevel) filterParams.set("unlock_level", unlockLevel);
{(category || search || brand || family || unlockLevel) && <a data-action="quiet" href="/tackle">Сбросить</a>}
<a data-action="quiet" href="/tackle/analytics">Сочетания снастей</a>
</form>
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенные карточки. Попробуйте обновить страницу позже." /> : result.items.length ? <section class="catalog-grid content-grid" aria-label="Карточки снастей">{result.items.map(item => <a href={`/tackle/items/${item.id}`}><TackleGlyph name={item.name} size={32} /><span>{categoryLabels[item.category] ?? item.category}</span><strong>{item.name}</strong><small>{[item.brand, item.family].filter(Boolean).join(" · ") || "Характеристики уточняются"}</small>{item.unlock_level !== null && <small>Открывается с уровня {item.unlock_level}</small>}<div class="tackle-card-passport"><DataPassport sources={item.source_system ? [item.source_system] : []} sourceUrl={item.source_url} observedAt={item.source_checked_at} completeness={item.missing_fields.length ? null : 100} status={item.missing_fields.length ? "incomplete" : item.source_checked_at ? "verified" : "unverified"}/></div></a>)}</section> : <StatePanel title="Подтверждённых карточек пока нет" description="Каталог заполнится после разрешённой загрузки и ручной проверки источник ов." />}
{!unavailable && <section class="rig-catalog content-grid" aria-labelledby="rig-catalog-title"><div class="section-heading"><div><span class="overline">Сборки снастей</span><h2 id="rig-catalog-title">Сборки</h2></div><span class="result-count">{rigs.total} всего</span></div>{rig sUnavailable ? <StatePanel contained={false} tone="unavailable" title="Сборки временно недоступны " description="Карточки отдельных снастей продолжают раб отать независимо." /> : rigs .items.length ? <div class="catalog-grid">{rigs .items.map(rig => <a href={`/tackle/rigs/${rig.id}`} ><TackleGlyph name={rig .name} size={32} /><span>Сборка · {rig.component_count} компонентов</span ><strong>{rig .name}</strong><small>{rig.source_system ?? "Источник не указан" }</small><div class="tackle-card-passport"><DataPassport sources={rig .source_system ? [rig .source_system] : []} sourceUrl={rig .source_url} observedAt={rig .source_checked_at} completeness={rig .missing_fields.length ? null : 100} status={rig .missing_fields.length ? "incomplete" : rig .source_checked_at ? "verified" : "unverified"}/></div></a>)}</div> : <StatePanel contained={false} title= "Подтверждённых сборо к пока нет" description="Сборки появя тся после разрешённой загрузки и проверки источников." />}</section >}
{!u navailable && <Pagination path="/tackle" params={filter Params} total={result.total} limit={limit} offset={offset} itemLabel="карточек" />}
{invalidFilters ? <StatePanel tone="error" title="Проверьте фильтры каталога" description="Один из параметров поиска имеет недопустимое значение. Измените фильтры и попробуйте сн ова ." /> : <>
{item sUnavailable ? <StatePanel tone="unavailable" title="Каталог снастей временно недоступе н" description="Сборки продолжают отображ аться независимо. Попробуйте обновить карточки снастей позже. " /> : result .items.length ? <section class="catalog-grid content-grid" aria-label="Карточки снастей">{result .items.map(item => <article class="catalog-card" ><TackleGlyph name={item .name} size={32} /><span>{categoryLabels[item.category] ?? item.category}</span><a class="catalog-card__title" href={`/tackle/items/${item.id}`} ><strong>{item .name}</strong></a>< small>{[item.brand, item.family].filter(Boolean).join(" · ") || "Характеристики уточняются"}</small>{item.unlock_level !== null && <small>Открывается с уровня {item.unlock_level }</small>} <div class="tackle-card-passport"><DataPassport sources={item .source_system ? [item .source_system] : []} sourceUrl={item .source_url} observedAt={item .source_checked_at} completeness={item .missing_fields.length ? null : 100} status={item .missing_fields.length ? "incomplete" : item .source_checked_at ? "verified" : "unverified"}/></div></article>)}</section> : <StatePanel title={search ? "По запросу ничего не найдено" : "Подтверждённых карточе к пока нет"} description={search ? "Измените запрос или сбросьте фильтры." : "Каталог заполни тся после разрешённой загрузки и ручной проверки источников."} />}
{!itemsU navailable && <Pagination path="/tackle" params={items Params} total={result.total} limit={limit} offset={itemsOffset} offsetParam="items_offset" itemLabel="карточек" />}
<section class="rig-catalog content-grid" aria-labelledby="rig-catalog-title"><div class="section-heading"><div><span class="overline">Сборки снастей</span><h2 id="rig-catalog-title">Сборки</h2></div><span class="result-count">{rigs.total} всего</span></div>{rigsUnavailable ? <StatePanel contained={false} tone="unavailable" title="Сборки временно недоступны" description="Карточки отдельных снастей продолжают работать независимо." /> : rigs.items.length ? <div class="catalog-grid">{rigs.items.map(rig => <article class="catalog-card"><TackleGlyph name={rig.name} size={32} /><span>Сборка · {rig.component_count} компонентов</span><a class="catalog-card__title" href={`/tackle/rigs/${rig.id}`}><strong>{rig.name}</strong></a><small>{rig.source_system ?? "Источник не указан"}</small><div class="tackle-card-passport"><DataPassport sources={rig.source_system ? [rig.source_system] : []} sourceUrl={rig.source_url} observedAt={rig.source_checked_at} completeness={rig.missing_fields.length ? null : 100} status={rig.missing_fields.length ? "incomplete" : rig.source_checked_at ? "verified" : "unverified"}/></div></article>)}</div> : <StatePanel contained={false} title={search ? "По запросу сборки не найдены" : "Подтверждённых сборок пока нет"} description={search ? "Измените запрос или сбросьте фильтры." : "Сборки появятся после разрешённой загрузки и проверки источников."} />}</section>
{!rigsUnavailable && <Pagination path="/tackle" params={rigsParams} total={rigs.total} limit={limit} offset={rigsOffset} offsetParam="rigs_offset" itemLabel="сборок" />}
</>}
</Layout>