diff --git a/README.md b/README.md index 45da93c..6013973 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. -Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card, фирменное изображение 1200×630 и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Индексируемые страницы рыб/водоёмов остаются следующим пакетом в roadmap. +Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card, фирменное изображение 1200×630 и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Индексируемые каталоги рыб и водоёмов, detail-страницы и сочетания водоём + рыба строятся из актуального разрешённого справочника и включаются в sitemap. Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md). diff --git a/apps/web/src/layouts/Layout.astro b/apps/web/src/layouts/Layout.astro index a37c712..6adc139 100644 --- a/apps/web/src/layouts/Layout.astro +++ b/apps/web/src/layouts/Layout.astro @@ -1,5 +1,6 @@ --- import "../styles/global.css"; +import "../styles/catalog.css"; import FishingIcon from "../components/FishingIcon.astro"; const { title = "RF4 Spotter — свежие точки клёва Russian Fishing 4", @@ -51,7 +52,7 @@ const jsonLd = JSON.stringify({
Ни хвоста,ни чешуи - +

Свежие данные и честная оценка

diff --git a/apps/web/src/pages/fish/[slug].astro b/apps/web/src/pages/fish/[slug].astro new file mode 100644 index 0000000..aa7c3f1 --- /dev/null +++ b/apps/web/src/pages/fish/[slug].astro @@ -0,0 +1,20 @@ +--- +import ActivityCard from "../../components/ActivityCard.astro"; +import Layout from "../../layouts/Layout.astro"; +import { api, plural, type Activity, type DictionaryItem } from "../../lib/api"; +const { slug } = Astro.params; +let fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false; +try { + const fishes = await api("/api/v1/fishes?limit=500"); + fish = fishes.find(item => item.slug === slug); + if (fish) items = await api(`/api/v1/activity?hours=72&fish=${encodeURIComponent(fish.slug)}&limit=100`); +} catch { unavailable = true; } +if (!fish && !unavailable) Astro.response.status = 404; +const waters = [...new Map(items.map(item => [item.waterbody_slug, item.waterbody])).entries()]; +const schema = fish ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Где ловить ${fish.name_ru} в RF4`, url:`https://rf4spotter.ru/fish/${fish.slug}` } : null; +--- + + ← Все виды рыб +
Свежие данные за 72 часа

{fish?.name_ru ?? "Рыба не найдена"}

{fish &&

{items.length} {plural(items.length,["активная точка","активные точки","активных точек"])} на {waters.length} {plural(waters.length,["водоёме","водоёмах","водоёмах"])}.

}
+ {unavailable ?

Данные временно недоступны

: !fish ?

Такой рыбы нет в справочнике

Открыть каталог
:
{items.length ? items.map(item => ) :

Свежих точек пока нет

Проверьте позже или посмотрите полевые сигналы на главной.

}
} +
diff --git a/apps/web/src/pages/fish/index.astro b/apps/web/src/pages/fish/index.astro new file mode 100644 index 0000000..215fb6e --- /dev/null +++ b/apps/web/src/pages/fish/index.astro @@ -0,0 +1,10 @@ +--- +import Layout from "../../layouts/Layout.astro"; +import { api, type DictionaryItem } from "../../lib/api"; +let fishes: DictionaryItem[] = [], unavailable = false; +try { fishes = await api("/api/v1/fishes?limit=500"); } catch { unavailable = true; } +--- + +
Справочник RF4

Рыбы

Выберите вид, чтобы увидеть свежие подтверждённые точки и полевые сигналы.

+ {unavailable ?

Каталог временно недоступен

: } +
diff --git a/apps/web/src/pages/sitemap.xml.ts b/apps/web/src/pages/sitemap.xml.ts index f1844e1..ba1ea85 100644 --- a/apps/web/src/pages/sitemap.xml.ts +++ b/apps/web/src/pages/sitemap.xml.ts @@ -1,5 +1,5 @@ import type { APIRoute } from "astro"; -import { api, type Activity } from "../lib/api"; +import { api, type Activity, type DictionaryItem } from "../lib/api"; const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, character => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ })[character] ?? character); @@ -7,8 +7,12 @@ export const GET: APIRoute = async ({ site }) => { const origin = site?.origin ?? import.meta.env.PUBLIC_SITE_URL ?? "https://rf4spotter.ru"; const paths = new Set(["/", "/records", "/report", "/rules", "/privacy"]); try { - const activity = await api("/api/v1/activity?hours=72&limit=100"); + const [activity, fishes, waters] = await Promise.all([api("/api/v1/activity?hours=72&limit=100"), api("/api/v1/fishes?limit=500"), api("/api/v1/waterbodies?limit=500")]); + paths.add("/fish"); paths.add("/waterbodies"); + fishes.forEach(item => paths.add(`/fish/${item.slug}`)); + waters.forEach(item => paths.add(`/waterbodies/${item.slug}`)); activity.forEach(item => paths.add(`/spots/${item.spot_id}`)); + activity.forEach(item => paths.add(`/waterbodies/${item.waterbody_slug}/${item.fish_slug}`)); } catch { // A temporary API outage must not make the static part of the sitemap unavailable. } diff --git a/apps/web/src/pages/waterbodies/[slug].astro b/apps/web/src/pages/waterbodies/[slug].astro new file mode 100644 index 0000000..92ab87a --- /dev/null +++ b/apps/web/src/pages/waterbodies/[slug].astro @@ -0,0 +1,20 @@ +--- +import ActivityCard from "../../components/ActivityCard.astro"; +import Layout from "../../layouts/Layout.astro"; +import { api, plural, type Activity, type DictionaryItem } from "../../lib/api"; +const { slug } = Astro.params; +let water: DictionaryItem | undefined, items: Activity[] = [], unavailable = false; +try { + const waters = await api("/api/v1/waterbodies?limit=500"); + water = waters.find(item => item.slug === slug); + if (water) items = await api(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&limit=100`); +} catch { unavailable = true; } +if (!water && !unavailable) Astro.response.status = 404; +const fishes = [...new Map(items.map(item => [item.fish_slug, item.fish])).entries()]; +const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Что ловить на ${water.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water.slug}` } : null; +--- + + ← Все водоёмы +
Свежие данные за 72 часа

{water?.name_ru ?? "Водоём не найден"}

{water &&

{items.length} {plural(items.length,["активная точка","активные точки","активных точек"])} для {fishes.length} {plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.

}
+ {unavailable ?

Данные временно недоступны

: !water ?

Такого водоёма нет в справочнике

Открыть каталог
:
{items.length ? items.map(item => ) :

Свежих точек пока нет

Проверьте позже или посмотрите полевые сигналы на главной.

}
} +
diff --git a/apps/web/src/pages/waterbodies/[slug]/[fish].astro b/apps/web/src/pages/waterbodies/[slug]/[fish].astro new file mode 100644 index 0000000..278a249 --- /dev/null +++ b/apps/web/src/pages/waterbodies/[slug]/[fish].astro @@ -0,0 +1,20 @@ +--- +import ActivityCard from "../../../components/ActivityCard.astro"; +import Layout from "../../../layouts/Layout.astro"; +import { api, plural, type Activity, type DictionaryItem } from "../../../lib/api"; +const { slug, fish: fishSlug } = Astro.params; +let water: DictionaryItem | undefined, fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false; +try { + const [waters, fishes] = await Promise.all([api("/api/v1/waterbodies?limit=500"), api("/api/v1/fishes?limit=500")]); + water = waters.find(item => item.slug === slug); fish = fishes.find(item => item.slug === fishSlug); + if (water && fish) items = await api(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&fish=${encodeURIComponent(fish.slug)}&limit=100`); +} catch { unavailable = true; } +if ((!water || !fish) && !unavailable) Astro.response.status = 404; +const valid = Boolean(water && fish); +const schema = valid ? { "@type":"CollectionPage", name:`${fish!.name_ru} на ${water!.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water!.slug}/${fish!.slug}` } : null; +--- + + ← К водоёму +
{water?.name_ru ?? "Каталог RF4"}

{fish?.name_ru ?? "Данные не найдены"}

{valid &&

{items.length} {plural(items.length,["свежая точка","свежие точки","свежих точек"])} за последние 72 часа.

}
+ {unavailable ?

Данные временно недоступны

: !valid ?

Такого сочетания нет в справочнике

Вернуться на главную
:
{items.length ? items.map(item => ) :

Свежих точек пока нет

Данные появятся после новых подтверждённых наблюдений.

}
} +
diff --git a/apps/web/src/pages/waterbodies/index.astro b/apps/web/src/pages/waterbodies/index.astro new file mode 100644 index 0000000..501a320 --- /dev/null +++ b/apps/web/src/pages/waterbodies/index.astro @@ -0,0 +1,10 @@ +--- +import Layout from "../../layouts/Layout.astro"; +import { api, type DictionaryItem } from "../../lib/api"; +let waters: DictionaryItem[] = [], unavailable = false; +try { waters = await api("/api/v1/waterbodies?limit=500"); } catch { unavailable = true; } +--- + +
Карта водоёмов RF4

Водоёмы

Откройте водоём, чтобы увидеть активные виды рыб и последние подтверждённые точки.

+ {unavailable ?

Каталог временно недоступен

: } +
diff --git a/apps/web/src/styles/catalog.css b/apps/web/src/styles/catalog.css new file mode 100644 index 0000000..079816f --- /dev/null +++ b/apps/web/src/styles/catalog.css @@ -0,0 +1 @@ +.catalog-hero{padding:68px 0 42px;border-bottom:1px solid #cbd6ce}.catalog-hero h1{margin:12px 0 10px;font:400 clamp(58px,7vw,94px)/.9 Georgia,serif;letter-spacing:-.055em}.catalog-hero p{color:#4d605d;font-size:16px}.catalog-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;padding:34px 0 100px}.catalog-grid>a{display:grid;grid-template-columns:1fr auto;gap:7px;padding:22px;border:1px solid #d3ddd5;border-radius:14px;background:#fbfcf9;text-decoration:none;transition:.18s ease}.catalog-grid>a:hover{transform:translateY(-2px);border-color:#81958a;box-shadow:0 14px 30px #14333812}.catalog-grid span{grid-column:1/-1;color:#657673;font-size:10px;text-transform:uppercase;letter-spacing:.1em}.catalog-grid strong{font:400 22px Georgia,serif}.catalog-grid i{font-style:normal;color:#6d8d2a}.catalog-results{display:grid;grid-template-columns:230px 1fr;gap:26px;padding:34px 0 100px}.catalog-results aside{padding:20px;background:#fff;border:1px solid #d6dfd7;border-radius:14px;align-self:start}.catalog-results aside nav{display:flex;flex-direction:column;margin-top:12px}.catalog-results aside a{padding:10px 0;border-bottom:1px solid #e1e8e2;color:#405552;font-size:13px}.catalog-results>div{display:grid;gap:12px}.catalog-results--single{display:block;max-width:960px}.catalog-results .spot-card{grid-template-columns:45px 1fr 190px}@media(max-width:900px){.catalog-grid{grid-template-columns:repeat(2,1fr)}.catalog-results{grid-template-columns:1fr}.catalog-results aside nav{display:grid;grid-template-columns:repeat(2,1fr);gap:0 16px}}@media(max-width:720px){.catalog-grid{grid-template-columns:1fr;padding-bottom:70px}.catalog-results{padding-bottom:70px}.catalog-results .spot-card{grid-template-columns:34px 1fr}.topbar nav{gap:8px}.topbar nav a{font-size:12px}} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d0f15fc..0003427 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -119,7 +119,7 @@ - [x] Сделать собственную полезную страницу 404 с возвратом к свежим точкам (7 сентября 2026). - [x] Явно отдавать `noindex` на административных страницах и состояниях ошибок (7 сентября 2026). - [ ] Добавить человекочитаемые постоянные URL точек без поломки существующих UUID-ссылок. -- [ ] Создать индексируемые страницы `/fish/{slug}`, `/waterbodies/{slug}` и сочетания водоём + рыба с уникальным содержимым. +- [x] Создать индексируемые каталоги и страницы `/fish/{slug}`, `/waterbodies/{slug}` и сочетания водоём + рыба с уникальными метаданными и включением в sitemap (7 сентября 2026). ### Графические элементы и объяснение данных