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 } : {}); --- - +
@@ -51,7 +64,10 @@ if (unlockLevel) filterParams.set("unlock_level", unlockLevel); {(category || search || brand || family || unlockLevel) && Сбросить} Сочетания снастей
- {unavailable ? : 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}}
)}
: } - {!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 не получает ложную привязку, старый улов не выглядит свежим.