feat: share local fishing plans
This commit is contained in:
@@ -3,7 +3,7 @@ import Layout from "../layouts/Layout.astro";
|
|||||||
---
|
---
|
||||||
<Layout title="Мой план рыбалки — RF4 Spotter" description="Сохранённые точки рыбалки RF4 без аккаунта: координаты, наживки, свежесть и доверие." noindex>
|
<Layout title="Мой план рыбалки — RF4 Spotter" description="Сохранённые точки рыбалки RF4 без аккаунта: координаты, наживки, свежесть и доверие." noindex>
|
||||||
<section class="plan-hero content-grid"><div><span class="eyebrow">Локально на этом устройстве</span><h1>Мой план<br/><em>рыбалки.</em></h1><p>Соберите до пяти точек, сравните их перед выездом и распечатайте список. Данные не отправляются на сервер.</p></div><div class="plan-hero__mark" aria-hidden="true">♢</div></section>
|
<section class="plan-hero content-grid"><div><span class="eyebrow">Локально на этом устройстве</span><h1>Мой план<br/><em>рыбалки.</em></h1><p>Соберите до пяти точек, сравните их перед выездом и распечатайте список. Данные не отправляются на сервер.</p></div><div class="plan-hero__mark" aria-hidden="true">♢</div></section>
|
||||||
<section class="plan-toolbar content-grid" aria-label="Действия с планом"><span data-plan-count>0 из 5 точек</span><div><button data-action="secondary" type="button" data-plan-print>Печать / PDF</button><button data-action="quiet" type="button" data-plan-clear>Очистить план</button></div></section>
|
<section class="plan-toolbar content-grid" aria-label="Действия с планом"><span data-plan-count>0 из 5 точек</span><div><button data-action="secondary" type="button" data-plan-share>Поделиться планом</button><button data-action="secondary" type="button" data-plan-print>Печать / PDF</button><button data-action="quiet" type="button" data-plan-clear>Очистить план</button><small data-plan-share-status aria-live="polite"></small></div></section>
|
||||||
<section class="plan-content content-grid" data-plan-page aria-live="polite"><div class="plan-grid" data-plan-list></div><div class="state" data-plan-empty><h2>План пока пуст</h2><p>Сохраните точку на её detail-странице — она появится здесь.</p><a data-action="secondary" href="/">Посмотреть горячие точки</a></div></section>
|
<section class="plan-content content-grid" data-plan-page aria-live="polite"><div class="plan-grid" data-plan-list></div><div class="state" data-plan-empty><h2>План пока пуст</h2><p>Сохраните точку на её detail-странице — она появится здесь.</p><a data-action="secondary" href="/">Посмотреть горячие точки</a></div></section>
|
||||||
</Layout>
|
</Layout>
|
||||||
<script>
|
<script>
|
||||||
@@ -11,10 +11,20 @@ import Layout from "../layouts/Layout.astro";
|
|||||||
const list = document.querySelector<HTMLElement>("[data-plan-list]");
|
const list = document.querySelector<HTMLElement>("[data-plan-list]");
|
||||||
const empty = document.querySelector<HTMLElement>("[data-plan-empty]");
|
const empty = document.querySelector<HTMLElement>("[data-plan-empty]");
|
||||||
const count = document.querySelector<HTMLElement>("[data-plan-count]");
|
const count = document.querySelector<HTMLElement>("[data-plan-count]");
|
||||||
|
const shareStatus = document.querySelector<HTMLElement>("[data-plan-share-status]");
|
||||||
|
const normalisePlan = (value: unknown): Array<Record<string, string>> => {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter(item => item && typeof item === "object" && typeof item.key === "string" && item.key.startsWith("/spots/")).slice(0, 5).map(item => {
|
||||||
|
const source = item as Record<string, unknown>;
|
||||||
|
return Object.fromEntries(["key", "waterbody", "coordinates", "baits", "freshness", "confidence"].map(key => [key, typeof source[key] === "string" ? source[key] : ""]));
|
||||||
|
});
|
||||||
|
};
|
||||||
const readPlan = (): Array<Record<string, string>> => {
|
const readPlan = (): Array<Record<string, string>> => {
|
||||||
try { const value = JSON.parse(localStorage.getItem(planStorageKey) || "[]"); return Array.isArray(value) ? value : []; }
|
try { return normalisePlan(JSON.parse(localStorage.getItem(planStorageKey) || "[]")); }
|
||||||
catch { return []; }
|
catch { return []; }
|
||||||
};
|
};
|
||||||
|
const shared = new URLSearchParams(location.search).get("plan");
|
||||||
|
if (shared) { try { const imported = normalisePlan(JSON.parse(shared)); if (imported.length) localStorage.setItem(planStorageKey, JSON.stringify(imported)); } catch { if (shareStatus) shareStatus.textContent = "Ссылка плана не распознана"; } }
|
||||||
const render = () => {
|
const render = () => {
|
||||||
if (!list || !empty) return;
|
if (!list || !empty) return;
|
||||||
const plan = readPlan();
|
const plan = readPlan();
|
||||||
@@ -33,6 +43,14 @@ import Layout from "../layouts/Layout.astro";
|
|||||||
card.append(heading, coordinate, meta, facts, actions); list.append(card);
|
card.append(heading, coordinate, meta, facts, actions); list.append(card);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
document.querySelector<HTMLButtonElement>("[data-plan-share]")?.addEventListener("click", async () => {
|
||||||
|
const url = new URL(location.href); url.search = ""; url.searchParams.set("plan", JSON.stringify(readPlan()));
|
||||||
|
try {
|
||||||
|
if (navigator.share) await navigator.share({ title: "Мой план рыбалки RF4", url: url.toString() });
|
||||||
|
else await navigator.clipboard.writeText(url.toString());
|
||||||
|
if (shareStatus) shareStatus.textContent = "Ссылка готова";
|
||||||
|
} catch { if (shareStatus) shareStatus.textContent = "Скопируйте ссылку из адресной строки"; }
|
||||||
|
});
|
||||||
document.querySelector<HTMLButtonElement>("[data-plan-print]")?.addEventListener("click", () => window.print());
|
document.querySelector<HTMLButtonElement>("[data-plan-print]")?.addEventListener("click", () => window.print());
|
||||||
document.querySelector<HTMLButtonElement>("[data-plan-clear]")?.addEventListener("click", () => { localStorage.removeItem(planStorageKey); render(); });
|
document.querySelector<HTMLButtonElement>("[data-plan-clear]")?.addEventListener("click", () => { localStorage.removeItem(planStorageKey); render(); });
|
||||||
render();
|
render();
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
.plan-hero{padding:70px 0 45px;display:flex;justify-content:space-between;align-items:center;gap:40px}.plan-hero h1{margin:18px 0 20px;font:400 clamp(58px,7vw,100px)/.88 Georgia,serif;letter-spacing:-.06em}.plan-hero h1 em{color:#497074;font-style:normal}.plan-hero p{max-width:620px;color:var(--text-muted);font-size:17px;line-height:1.6}.plan-hero__mark{width:150px;height:150px;display:grid;place-items:center;border:1px solid #345155;border-radius:50%;background:var(--deep);color:var(--lime);font-size:76px;transform:rotate(45deg)}.plan-toolbar{display:flex;justify-content:space-between;align-items:center;padding:18px 0;border-top:1px solid var(--border);border-bottom:1px solid var(--border);color:var(--text-muted);font-size:13px}.plan-toolbar>div{display:flex;gap:8px}.plan-content{padding:30px 0 100px}.plan-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:14px}.plan-card{display:flex;flex-direction:column;gap:8px;min-height:220px;padding:22px;border:1px solid var(--border-soft);border-radius:15px;background:var(--surface);box-shadow:0 12px 30px var(--shadow-color)}.plan-card h2{margin:0;font:400 27px Georgia,serif}.plan-card>strong{font:400 22px Georgia,serif;color:var(--success)}.plan-card p{margin:5px 0 0;color:var(--text-muted);font-size:13px}.plan-card small{color:var(--text-subtle);font-size:11px}.plan-card__actions{display:flex;align-items:center;gap:15px;margin-top:auto}.plan-card__actions a{color:var(--text);font-size:13px;text-underline-offset:3px}.plan-card__actions button{border:0;background:none;color:var(--text-muted);font-size:12px;text-decoration:underline;text-underline-offset:3px}.plan-content>.state[hidden]{display:none!important}@media(max-width:720px){.plan-hero{padding:36px 0 25px}.plan-hero__mark{width:90px;height:90px;font-size:46px}.plan-toolbar{display:block}.plan-toolbar>div{margin-top:12px;flex-wrap:wrap}.plan-content{padding-top:20px}}@media print{.topbar,.alpha-banner,footer,.plan-hero__mark,.plan-toolbar button,.plan-card__actions button{display:none!important}.plan-hero{padding:0 0 20px}.plan-content{padding-top:0}.plan-card{break-inside:avoid;box-shadow:none}}
|
.plan-hero{padding:70px 0 45px;display:flex;justify-content:space-between;align-items:center;gap:40px}.plan-hero h1{margin:18px 0 20px;font:400 clamp(58px,7vw,100px)/.88 Georgia,serif;letter-spacing:-.06em}.plan-hero h1 em{color:#497074;font-style:normal}.plan-hero p{max-width:620px;color:var(--text-muted);font-size:17px;line-height:1.6}.plan-hero__mark{width:150px;height:150px;display:grid;place-items:center;border:1px solid #345155;border-radius:50%;background:var(--deep);color:var(--lime);font-size:76px;transform:rotate(45deg)}.plan-toolbar{display:flex;justify-content:space-between;align-items:center;padding:18px 0;border-top:1px solid var(--border);border-bottom:1px solid var(--border);color:var(--text-muted);font-size:13px}.plan-toolbar>div{display:flex;gap:8px}.plan-content{padding:30px 0 100px}.plan-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:14px}.plan-card{display:flex;flex-direction:column;gap:8px;min-height:220px;padding:22px;border:1px solid var(--border-soft);border-radius:15px;background:var(--surface);box-shadow:0 12px 30px var(--shadow-color)}.plan-card h2{margin:0;font:400 27px Georgia,serif}.plan-card>strong{font:400 22px Georgia,serif;color:var(--success)}.plan-card p{margin:5px 0 0;color:var(--text-muted);font-size:13px}.plan-card small{color:var(--text-subtle);font-size:11px}.plan-card__actions{display:flex;align-items:center;gap:15px;margin-top:auto}.plan-card__actions a{color:var(--text);font-size:13px;text-underline-offset:3px}.plan-card__actions button{border:0;background:none;color:var(--text-muted);font-size:12px;text-decoration:underline;text-underline-offset:3px}.plan-content>.state[hidden]{display:none!important}@media(max-width:720px){.plan-hero{padding:36px 0 25px}.plan-hero__mark{width:90px;height:90px;font-size:46px}.plan-toolbar{display:block}.plan-toolbar>div{margin-top:12px;flex-wrap:wrap}.plan-content{padding-top:20px}}@media print{.topbar,.alpha-banner,footer,.plan-hero__mark,.plan-toolbar button,.plan-card__actions button{display:none!important}.plan-hero{padding:0 0 20px}.plan-content{padding-top:0}.plan-card{break-inside:avoid;box-shadow:none}}
|
||||||
|
.plan-toolbar>div{align-items:center;flex-wrap:wrap}.plan-toolbar [data-plan-share-status]{color:var(--text-muted);font-size:11px}
|
||||||
|
|||||||
@@ -17,3 +17,12 @@ test("saved spot appears on the plan page and can be removed or cleared", async
|
|||||||
await page.getByRole("button", { name: "Очистить план" }).click();
|
await page.getByRole("button", { name: "Очистить план" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "План пока пуст" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "План пока пуст" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("plan can be restored from a shareable URL", async ({ page }) => {
|
||||||
|
const shared = encodeURIComponent(JSON.stringify([{ key: "/spots/vyunok-6331x6332", waterbody: "Вьюнок", coordinates: "6331:6332", baits: "Тестовая приманка", freshness: "", confidence: "" }]));
|
||||||
|
await page.goto(`/plan?plan=${shared}`);
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Вьюнок" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Поделиться планом" })).toBeVisible();
|
||||||
|
await expect(page.getByText("1 из 5 точек")).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
+1
-1
@@ -96,7 +96,7 @@
|
|||||||
- [ ] **U02 · Главный сценарий «рыба → водоём → точка → снасть».** Главная уже сохраняет рыбу, водоём, период и сортировку в shareable URL и после поиска явно показывает контекст запроса, включая мобильный режим; empty-state предлагает вернуться к полному набору данных. Осталось показать 3–5 вариантов с понятным CTA и добавить режим map. Acceptance: первый полезный вариант виден без регистрации, back/refresh сохраняют контекст, mobile не теряет фильтры.
|
- [ ] **U02 · Главный сценарий «рыба → водоём → точка → снасть».** Главная уже сохраняет рыбу, водоём, период и сортировку в shareable URL и после поиска явно показывает контекст запроса, включая мобильный режим; empty-state предлагает вернуться к полному набору данных. Осталось показать 3–5 вариантов с понятным CTA и добавить режим map. Acceptance: первый полезный вариант виден без регистрации, back/refresh сохраняют контекст, mobile не теряет фильтры.
|
||||||
- [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness, completeness, confidence/статус, source badges и доступные доменные поля; targeted E2E проверяет публичные пути. Осталось добавить период выборки, конфликт источников и правило минимальной выборки для любых рекомендаций. Нельзя показывать «лучшую точку» или AI-like рекомендацию без объяснения и минимальной выборки.
|
- [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness, completeness, confidence/статус, source badges и доступные доменные поля; targeted E2E проверяет публичные пути. Осталось добавить период выборки, конфликт источников и правило минимальной выборки для любых рекомендаций. Нельзя показывать «лучшую точку» или AI-like рекомендацию без объяснения и минимальной выборки.
|
||||||
- [ ] **U04 · List/map и progressive disclosure.** List остаётся честным базовым режимом: фильтры, сортировка, URL-состояние и evidence-карточки уже работают без имитации координатной карты. Следующий шаг — единый list/map-контракт после подтверждения геометрии; на mobile карта должна открываться отдельным действием. Вторичные raw/provenance-поля не исчезают и раскрываются по запросу.
|
- [ ] **U04 · List/map и progressive disclosure.** List остаётся честным базовым режимом: фильтры, сортировка, URL-состояние и evidence-карточки уже работают без имитации координатной карты. Следующий шаг — единый list/map-контракт после подтверждения геометрии; на mobile карта должна открываться отдельным действием. Вторичные raw/provenance-поля не исчезают и раскрываются по запросу.
|
||||||
- [ ] **U05 · Mobile-first и сохранённый план рыбалки.** Добавлен отдельный `/plan`: список до 5 локальных вариантов, удаление одной точки, очистка всего плана, переход обратно к точке и print/PDF-режим без аккаунта. Detail-кнопка сохраняет данные с `aria-pressed` и восстанавливается после reload. Осталось сделать shareable URL/полноценное сравнение и отдельную проверку print на 320/390 px.
|
- [ ] **U05 · Mobile-first и сохранённый план рыбалки.** `/plan` поддерживает список до 5 локальных вариантов, удаление, очистку, переход к точке, print/PDF и восстановление из shareable URL; кнопка «Поделиться планом» использует native share или clipboard fallback. Detail-кнопка сохраняет данные с `aria-pressed` и восстанавливается после reload. Осталось сделать отдельную проверку print на 320/390 px и расширить сравнение полями метода/риска.
|
||||||
- [ ] **U06 · Контентная и визуальная иерархия.** Для detail точки действие «Что взять» выделено отдельным заголовком, а provenance и качество собраны в общем паспорте данных; статусы дополнительно передаются текстом, не только цветом. Осталось сократить конкурирующие цифры на карточках и провести ручной review на 5 ключевых маршрутах.
|
- [ ] **U06 · Контентная и визуальная иерархия.** Для detail точки действие «Что взять» выделено отдельным заголовком, а provenance и качество собраны в общем паспорте данных; статусы дополнительно передаются текстом, не только цветом. Осталось сократить конкурирующие цифры на карточках и провести ручной review на 5 ключевых маршрутах.
|
||||||
- [ ] **U07 · UX-приёмка и измерения.** Добавить Playwright-контракты для полного journey, list/map, evidence states, saved plan, stale/blocked/insufficient data и share/print. Зафиксировать baseline Lighthouse/axe/CLS/INP и time-to-first-useful-answer; закрывать U01–U06 только по evidence, screenshots и обратной связи пилота.
|
- [ ] **U07 · UX-приёмка и измерения.** Добавить Playwright-контракты для полного journey, list/map, evidence states, saved plan, stale/blocked/insufficient data и share/print. Зафиксировать baseline Lighthouse/axe/CLS/INP и time-to-first-useful-answer; закрывать U01–U06 только по evidence, screenshots и обратной связи пилота.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user