diff --git a/apps/web/src/components/Pagination.astro b/apps/web/src/components/Pagination.astro
index 4b0aa21..4aae2d9 100644
--- a/apps/web/src/components/Pagination.astro
+++ b/apps/web/src/components/Pagination.astro
@@ -7,21 +7,22 @@ interface Props {
total: number;
limit: number;
offset: number;
+ offsetParam?: string;
anchor?: string;
itemLabel?: string;
}
-const { path, params, total, limit, offset, anchor = "", itemLabel = "записей" } = Astro.props;
+const { path, params, total, limit, offset, offsetParam = "offset", anchor = "", itemLabel = "записей" } = Astro.props;
const state = pageWindow(total, limit, offset);
---
{total > limit && }
diff --git a/apps/web/src/lib/pagination.ts b/apps/web/src/lib/pagination.ts
index 6b80996..9767c21 100644
--- a/apps/web/src/lib/pagination.ts
+++ b/apps/web/src/lib/pagination.ts
@@ -22,10 +22,10 @@ export const pageWindow = (total: number, limit: number, offset: number): PageWi
};
};
-export const pageHref = (path: string, search: URLSearchParams, offset: number, anchor = "") => {
+export const pageHref = (path: string, search: URLSearchParams, offset: number, anchor = "", offsetParam = "offset") => {
const params = new URLSearchParams(search);
- if (offset > 0) params.set("offset", String(offset));
- else params.delete("offset");
+ if (offset > 0) params.set(offsetParam, String(offset));
+ else params.delete(offsetParam);
const query = params.toString();
return `${path}${query ? `?${query}` : ""}${anchor}`;
};
diff --git a/apps/web/src/pages/tackle/index.astro b/apps/web/src/pages/tackle/index.astro
index 02fbe24..b7c08d0 100644
--- a/apps/web/src/pages/tackle/index.astro
+++ b/apps/web/src/pages/tackle/index.astro
@@ -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 requestedItemsOffset = Number(params.get("items_offset") ?? 0);
+const requestedRigsOffset = Number(params.get("rigs_offset") ?? 0);
const limit = 48;
-const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
-const query = new URLSearchParams({ limit: String(limit), offset: String(offset) });
+const itemsOffset = Number.isInteger(requestedItemsOffset) && requestedItemsOffset >= 0 ? requestedItemsOffset : 0;
+const rigsOffset = Number.isInteger(requestedRigsOffset) && requestedRigsOffset >= 0 ? requestedRigsOffset : 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(`/api/v1/tackle/items?${query}`),
- api(`/api/v1/tackle/rigs?limit=${limit}&offset=${offset}${search ? `&q=${encodeURIComponent(search)}` : ""}`),
+ api(`/api/v1/tackle/rigs?limit=${limit}&offset=${rigsOffset}${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 = { 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 } : {});
---
-
+
- {unavailable ? : result.items.length ? : }
- {!unavailable && Сборки снастей
Сборки
{rigs.total} всего {rigsUnavailable ? : rigs.items.length ? : }}
- {!unavailable && }
+ {invalidFilters ? : <>
+ {itemsUnavailable ? : result.items.length ? {result.items.map(item => {categoryLabels[item.category] ?? item.category}{item.name}{[item.brand, item.family].filter(Boolean).join(" · ") || "Характеристики уточняются"}{item.unlock_level !== null && Открывается с уровня {item.unlock_level}}
)} : }
+ {!itemsUnavailable && }
+ Сборки снастей
Сборки
{rigs.total} всего {rigsUnavailable ? : rigs.items.length ? {rigs.items.map(rig =>
Сборка · {rig.component_count} компонентов{rig.name}{rig.source_system ?? "Источник не указан"}
)}
: }
+ {!rigsUnavailable && }
+ >}
diff --git a/apps/web/src/styles/catalog.css b/apps/web/src/styles/catalog.css
index 31c700c..379493e 100644
--- a/apps/web/src/styles/catalog.css
+++ b/apps/web/src/styles/catalog.css
@@ -3,3 +3,13 @@
/* Editorial atlas: catalog pages read as a field guide, not a utility list. */
.catalog-hero--illustrated{position:relative;display:grid;grid-template-columns:minmax(0,1fr) 210px;align-items:center;gap:48px;min-height:310px;overflow:hidden}.catalog-hero--illustrated:before{content:"";position:absolute;right:102px;width:330px;height:190px;border:1px solid color-mix(in srgb,var(--teal) 11%,transparent);border-radius:50%;box-shadow:0 0 0 32px color-mix(in srgb,var(--teal) 5%,transparent),0 0 0 64px color-mix(in srgb,var(--teal) 3%,transparent);transform:rotate(-9deg);pointer-events:none}.catalog-hero__copy{position:relative;z-index:1}.catalog-hero__copy p{max-width:660px;line-height:1.6}.catalog-hero__seal{position:relative;z-index:1;width:176px;height:176px;justify-self:end;display:grid;place-items:center;align-content:center;border:1px solid #345155;border-radius:50%;background:var(--deep);color:var(--lime);box-shadow:0 18px 48px #16383c1b}.catalog-hero__seal>span{height:60px;display:grid;place-items:center}.catalog-hero__seal .fish-silhouette{color:var(--lime)}.catalog-hero__seal strong{margin-top:5px;font:400 32px/1 Georgia,serif;color:var(--white)}.catalog-hero__seal small{margin-top:4px;color:#a9b8b5;font-size:9px;text-transform:uppercase;letter-spacing:.13em}.catalog-grid{grid-template-columns:repeat(auto-fit,minmax(min(100%,330px),1fr));gap:14px}.catalog-grid>a{min-height:146px;align-content:start;padding:24px 25px;border-color:var(--border-soft);background:linear-gradient(145deg,var(--surface),var(--surface-soft))}.catalog-grid>a:before{content:"";position:absolute;inset:0;border-top:3px solid transparent;transition:border-color var(--motion-base) var(--ease-out)}.catalog-grid>a:hover:before{border-color:var(--lime)}.catalog-grid>a>span{color:var(--text-muted)}.catalog-grid>a>strong{position:relative;z-index:1;margin-top:10px;font-size:28px}.catalog-grid>a>i{position:relative;z-index:1;align-self:end;display:flex;align-items:center;gap:8px;margin-top:20px;color:var(--text-muted);font:700 10px Inter,sans-serif;text-transform:uppercase;letter-spacing:.09em}.catalog-grid>a>i b{color:#6d8d2a;font-size:16px}.catalog-grid>a>.fish-silhouette{right:20px;bottom:12px;opacity:.095}.waterbody-mark{position:absolute;right:14px;bottom:8px;fill:none;stroke:#315f63;stroke-width:1.2;opacity:.12;pointer-events:none}.waterbody-mark__shore{stroke-width:2}.waterbody-mark circle{fill:var(--lime);stroke:none}
@media(max-width:720px){.catalog-hero--illustrated{grid-template-columns:1fr 106px;gap:18px;min-height:230px}.catalog-hero--illustrated:before{right:-35px;width:210px;height:120px}.catalog-hero__seal{width:96px;height:96px}.catalog-hero__seal>span{height:38px}.catalog-hero__seal .fish-icon{width:36px;height:36px}.catalog-hero__seal .fish-silhouette{width:62px}.catalog-hero__seal strong{font-size:20px}.catalog-hero__seal small{display:none}.catalog-grid>a{min-height:132px}.catalog-grid>a>strong{font-size:25px}}
+
+.catalog-grid>.catalog-card{display:grid;grid-template-columns:1fr auto;gap:7px;min-height:146px;align-content:start;padding:24px 25px;position:relative;overflow:hidden;border:1px solid var(--border-soft);border-radius:14px;background:linear-gradient(145deg,var(--surface),var(--surface-soft));transition:transform var(--motion-base) var(--ease-out),border-color var(--motion-base) var(--ease-out),box-shadow var(--motion-base) var(--ease-out)}
+.catalog-grid>.catalog-card:hover{transform:translateY(-2px);border-color:var(--border-strong);box-shadow:0 14px 30px var(--shadow-color)}
+.catalog-card>span{grid-column:1/-1;color:var(--text-muted)}
+.catalog-card__title{grid-column:1/-1;margin-top:10px;color:inherit;text-decoration:none}
+.catalog-card__title strong{font:400 28px Georgia,serif}
+.catalog-card__title:hover{text-decoration:underline;text-underline-offset:4px}
+.catalog-card>small{color:var(--text-muted)}
+.catalog-card .tackle-card-passport{grid-column:1/-1;min-width:0}
+@media(max-width:720px){.catalog-grid>.catalog-card{min-height:132px}.catalog-card__title strong{font-size:25px}}
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 48bf188..53335ba 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -27,7 +27,7 @@ R-пункты уточняют критерии существующих B/G/U/
- [x] **R02 · P1 · Публикация выбранных media и защита от гонок (A04/B23).** Admin API получил preview и явный набор SHA-256 asset IDs; publish больше не выбирает скрытые `upgrade_stored`, проверяет целостность выбранных файлов/вариантов, сверяет monotonic manifest version и возвращает `409` для устаревшей вкладки. Publish/rollback и CLI-совместимый путь сериализованы общим lock-файлом; решения пишутся атомарно и увеличивают версию. Regression покрывает выбор одного кандидата из двух и stale-version conflict; web admin передаёт ID и версию из заголовка manifest. Browser/production acceptance A04/A06/A07 остаётся отдельным gate.
- [x] **R03 · P1 · Идемпотентная отправка улова.** Единый replay-контракт теперь сверяет payload hash и TTL также после `IntegrityError`; старый уникальный ключ получает `409`, а завершённый screenshot больше не ломает повтор исходной отправки. Spot/Bait создаются через savepoint и повторный select после unique-конфликта, поэтому ожидаемые гонки не дают необработанный `500`. Regression покрывает одинаковый повтор, изменённый payload, завершённый upload и просроченный ключ.
- [ ] **R04 · P1 · Восстановление загрузки скриншота.** Атомарное одноразовое сохранение, повтор после потерянного ответа, очистка idempotency-cookie после восстановления. Критерий: нет лишних объектов, retry завершает форму и следующий новый улов отправляется.
-- [ ] **R05 · P1 · Полноценный каталог снастей (G06/G09).** Отдельная пагинация сборок/предметов, независимые ошибки, валидация URL-фильтров, empty search отдельно от незаполненного каталога. Критерий: 49+ сборок достижимы при 0 предметов, 422 не маскируется под 503. Убрать вложенные ссылки: карточка оборачивает DataPassport с source-link; проверить итоговый DOM и keyboard наполненных карточек.
+- [x] **R05 · P1 · Полноценный каталог снастей (G06/G09).** Предметы и сборки получили независимые offsets и пагинаторы (`items_offset`/`rigs_offset`), один сбой endpoint больше не скрывает второй, 422 фильтров отделён от 503, пустой поиск отделён от незаполненного каталога. Карточки стали article-блоками с отдельной ссылкой названия и валидным source-link внутри паспорта без вложенных ссылок. `astro check` и production build проходят; browser/keyboard acceptance наполненного каталога остаётся частью G06/G09.
- [ ] **R06 · P1 · Надёжный перенос плана (U05).** Preview, объединение/замена с восстановлением, однократный импорт, storage errors, вычисляемая свежесть и точное описание передачи share-данных. Критерий: ссылка не стирает план без выбора, reload не возвращает удалённое, старые данные отмечены.
- [ ] **R07 · P1 · Схема координат по водоёмам (U04).** Разделить системы координат, показать точность и водоём, обработать совпадения/обрезку. Критерий: все точки достижимы на 320 px и с клавиатуры, включая одинаковые координаты и длинные названия.
- [ ] **R08 · P1 · Достоверная аналитика снастей (G07).** Зафиксировать публичный минимум 3 наблюдения/2 игрока, разделить время улова и импорта, conservative canonical grouping, ограничить ответ. Критерий: 1/1 не рекомендация, unresolved не получает ложную привязку, старый улов не выглядит свежим.