From 94e5828fbcfaf9ca4792de4d2c60d84de0824520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 3 Jul 2026 03:19:57 +0300 Subject: [PATCH] =?UTF-8?q?docs(tours):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BF=D0=B0=203=20=E2=80=94=20=D1=8D=D0=BA?= =?UTF-8?q?=D1=81=D0=BA=D1=83=D1=80=D1=81=D0=B8=D0=B8=20=C2=AB=D0=9F=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=B0=D1=82=D1=8C=20=D0=BD=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=82=D0=B0=D0=BB=D0=B5=C2=BB=20(6=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D1=87=20TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-03-jivo-bot-tours.md | 702 ++++++++++++++++++ 1 file changed, 702 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-03-jivo-bot-tours.md diff --git a/docs/superpowers/plans/2026-07-03-jivo-bot-tours.md b/docs/superpowers/plans/2026-07-03-jivo-bot-tours.md new file mode 100644 index 00000000..32b33453 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-jivo-bot-tours.md @@ -0,0 +1,702 @@ +# Jivo Bot Tours (этап 3 спеки ИИ-бота) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Кнопка «Показать на портале» из ответа бота реально работает: ссылка `/?tour=<имя>` открывает нужный экран и ведёт клиента экскурсией с подсветкой полей (по образцу WelcomeTour). + +**Architecture:** `tours/catalog.ts` (реестр сценариев: имя → шаги {route, target, title, text}) → `GuidedTour.vue` (обобщённый раннер: подсветка цели, «ждущие» шаги — цель может появиться после действия клиента, напр. открытия диалога) → запуск из `AppLayout` по `route.query.tour` (после логина query переживает redirect — роутер уже сохраняет `to.fullPath`). Якоря шагов — существующие `[data-tour]`/`[data-testid]` + 2 новых `data-tour`. Бот уже умеет прикладывать ссылку (флаг `JIVO_BOT_TOURS_ENABLED`, BotAnswerService — этап 2). + +**Tech Stack:** Vue 3 + Vuetify 3, vue-router, Vitest (tests/Frontend), Pest (кросс-проверка frontmatter↔каталог). + +**Спека:** `docs/superpowers/specs/2026-07-02-jivo-ai-support-bot-design.md` §4 · **Ядро (этап 2):** план `2026-07-02-jivo-bot-core.md`, выполнен 02.07.2026. + +**Правила окружения (те же, что в плане ядра):** worktree `jivo-bot-core`, ветка `worktree-jivo-bot-core`; Vitest: `npm run test:vue -- --run <файл>`; Pest: `DB_DATABASE=liderra_testing_jivo php -d memory_limit=2G artisan test --filter=<Имя>`, НИКОГДА полный набор/--parallel в задачах; TDD строго; коммиты явными путями. + +**Файловая карта:** + +| Файл | Что | Ответственность | +|---|---|---| +| `app/resources/js/tours/catalog.ts` | Создать | реестр экскурсий (типы + 5 сценариев) | +| `app/resources/js/components/layout/GuidedTour.vue` | Создать | обобщённый раннер (подсветка/шаги/ожидание цели) | +| `app/resources/js/composables/useTourLauncher.ts` | Создать | чтение `?tour=`, запуск, очистка query | +| `app/resources/js/layouts/AppLayout.vue` | Изменить | подключить GuidedTour + launcher рядом с WelcomeTour | +| `app/resources/js/views/ProjectsView.vue` | Изменить | `data-tour="projects-create"` на кнопку «Создать проект» (~строка 5) | +| `app/resources/js/views/BillingView.vue` | Изменить | `data-tour="billing-topup"` на кнопку «Пополнить баланс» (~строка 89) | +| `app/tests/Frontend/TourCatalog.spec.ts`, `GuidedTour.spec.ts`, `TourLauncher.spec.ts` | Создать | Vitest | +| `app/tests/Feature/Bot/HelpTourNamesTest.php` | Создать | каждый `tour:` из resources/help существует в каталоге | +| `ПРОТОКОЛ-ии-дживосайт.md`, спека §4 | Изменить | отметка «этап 3 построен» | + +--- + +### Task 1: Каталог экскурсий `tours/catalog.ts` + +**Files:** +- Create: `app/resources/js/tours/catalog.ts` +- Test: `app/tests/Frontend/TourCatalog.spec.ts` + +- [ ] **Step 1: Падающий тест** `app/tests/Frontend/TourCatalog.spec.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { TOURS, findTour, type TourScenario } from '../../resources/js/tours/catalog'; + +describe('каталог экскурсий', () => { + it('содержит 5 стартовых сценариев с уникальными именами', () => { + const names = TOURS.map((t: TourScenario) => t.name); + expect(names).toEqual([...new Set(names)]); + for (const required of ['create-project', 'top-up-balance', 'tariffs', 'change-source', 'notifications']) { + expect(names).toContain(required); + } + }); + + it('каждый шаг имеет route, target, title и text', () => { + for (const tour of TOURS) { + expect(tour.steps.length).toBeGreaterThan(0); + for (const s of tour.steps) { + expect(s.route.startsWith('/')).toBe(true); + expect(s.target).toBeTruthy(); + expect(s.title).toBeTruthy(); + expect(s.text).toBeTruthy(); + } + } + }); + + it('findTour находит по имени и отдаёт null на мусор', () => { + expect(findTour('create-project')?.name).toBe('create-project'); + expect(findTour('no-such-tour')).toBeNull(); + expect(findTour('')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Убедиться, что падает:** `npm run test:vue -- --run tests/Frontend/TourCatalog.spec.ts` → FAIL (module not found) + +- [ ] **Step 3: Реализация** `app/resources/js/tours/catalog.ts`: + +```ts +/** + * Каталог экскурсий «Показать на портале» (спека ИИ-бота §4). + * ИИ шаги НЕ сочиняет — только выбирает готовый сценарий по имени + * (frontmatter `tour:` статьи resources/help). Селекторы целей — существующие + * data-tour (sidebar: nav-*) и data-testid; target может появиться ПОСЛЕ + * действия клиента (открыл диалог) — раннер умеет ждать (см. GuidedTour). + */ +export interface TourStep { + /** Роут, на котором живёт цель шага; раннер переходит туда сам. */ + route: string; + /** CSS-селектор цели подсветки. */ + target: string; + title: string; + text: string; +} + +export interface TourScenario { + name: string; + steps: TourStep[]; +} + +export const TOURS: TourScenario[] = [ + { + name: 'create-project', + steps: [ + { + route: '/projects', + target: '[data-tour="nav-projects"]', + title: 'Раздел «Проекты»', + text: 'Здесь живут все ваши проекты — заявки на поток клиентов.', + }, + { + route: '/projects', + target: '[data-tour="projects-create"]', + title: 'Создать проект', + text: 'Нажмите эту кнопку — откроется форма нового проекта. Понадобятся название, источник и дневной лимит заявок.', + }, + ], + }, + { + name: 'top-up-balance', + steps: [ + { + route: '/billing', + target: '[data-tour="nav-billing"]', + title: 'Раздел «Биллинг»', + text: 'Баланс, история операций и пополнение — всё здесь.', + }, + { + route: '/billing', + target: '[data-tour="billing-topup"]', + title: 'Пополнить баланс', + text: 'Нажмите, чтобы выставить счёт на пополнение. После оплаты деньги зачислятся автоматически.', + }, + ], + }, + { + name: 'tariffs', + steps: [ + { + route: '/billing', + target: '[data-tour="nav-billing"]', + title: 'Тарифы — в «Биллинге»', + text: 'Вы платите только за полученные заявки. Актуальные цены и ваша тарифная ступень — в этом разделе.', + }, + ], + }, + { + name: 'change-source', + steps: [ + { + route: '/projects', + target: '[data-tour="nav-projects"]', + title: 'Смена источника — в «Проектах»', + text: 'Откройте нужный проект — в его настройках можно сменить источник без потери заявок.', + }, + ], + }, + { + name: 'notifications', + steps: [ + { + route: '/settings', + target: '[data-tour="nav-settings"]', + title: 'Уведомления — в «Настройках»', + text: 'Здесь включаются письма о новых заявках и другие уведомления.', + }, + ], + }, +]; + +export function findTour(name: string): TourScenario | null { + if (name === '') return null; + return TOURS.find((t) => t.name === name) ?? null; +} +``` + +- [ ] **Step 4: Зелёный:** `npm run test:vue -- --run tests/Frontend/TourCatalog.spec.ts` → PASS (3) + +- [ ] **Step 5: Проверить якорь nav-settings** — `grep -n "nav-settings\|/settings" app/resources/js/components/layout/AppSidebar.vue`: data-tour генерится как `nav-${item.to.replace('/', '')}` (AppSidebar.vue:106) → у пункта `/settings` якорь есть автоматически. Если пункта настроек в сайдбаре нет — заменить шаг notifications на target `[data-tour="nav-help"]` с текстом про «Помощь» и зафиксировать в каталоге комментарием. + +- [ ] **Step 6: Commit** + +```bash +git add app/resources/js/tours/catalog.ts app/tests/Frontend/TourCatalog.spec.ts +git commit -m "feat(tours): каталог экскурсий — 5 стартовых сценариев" +``` + +--- + +### Task 2: Раннер `GuidedTour.vue` + +**Files:** +- Create: `app/resources/js/components/layout/GuidedTour.vue` +- Test: `app/tests/Frontend/GuidedTour.spec.ts` + +Обобщение WelcomeTour (`components/layout/WelcomeTour.vue` — образец разметки/стилей): шаги приходят пропсом, тур активируется методом, цель шага может появиться позже (диалог) — меряем с ретраем. + +- [ ] **Step 1: Падающий тест** `app/tests/Frontend/GuidedTour.spec.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import GuidedTour from '../../resources/js/components/layout/GuidedTour.vue'; +import type { TourStep } from '../../resources/js/tours/catalog'; + +const steps: TourStep[] = [ + { route: '/projects', target: '[data-tour="a"]', title: 'Шаг 1', text: 'т1' }, + { route: '/projects', target: '[data-tour="b"]', title: 'Шаг 2', text: 'т2' }, +]; + +function mountTour() { + return mount(GuidedTour, { + props: { steps, active: true }, + global: { stubs: { 'v-btn': { template: '' } } }, + }); +} + +describe('GuidedTour', () => { + it('показывает первый шаг и счётчик', () => { + const w = mountTour(); + expect(w.text()).toContain('Шаг 1'); + expect(w.text()).toContain('1 из 2'); + }); + + it('Далее ведёт по шагам, на последнем — Готово и finish', async () => { + const w = mountTour(); + await w.find('[data-testid="tour-next"]').trigger('click'); + expect(w.text()).toContain('Шаг 2'); + await w.find('[data-testid="tour-next"]').trigger('click'); + expect(w.emitted('finish')).toBeTruthy(); + }); + + it('Пропустить завершает тур сразу', async () => { + const w = mountTour(); + await w.find('[data-testid="tour-skip"]').trigger('click'); + expect(w.emitted('finish')).toBeTruthy(); + }); + + it('цель не найдена → карточка по центру (targetRect null), без падения', () => { + const w = mountTour(); + expect(w.find('[data-testid="guided-tour"]').exists()).toBe(true); + }); + + it('ретрай измерения: цель появляется позже — подсветка находит её', async () => { + vi.useFakeTimers(); + const w = mountTour(); + const el = document.createElement('div'); + el.setAttribute('data-tour', 'a'); + document.body.appendChild(el); + await vi.advanceTimersByTimeAsync(1000); + expect((w.vm as any).targetRect).not.toBeNull(); + el.remove(); + vi.useRealTimers(); + }); +}); +``` + +- [ ] **Step 2: Падает:** `npm run test:vue -- --run tests/Frontend/GuidedTour.spec.ts` → FAIL + +- [ ] **Step 3: Реализация** `app/resources/js/components/layout/GuidedTour.vue`: + +```vue + + + + + +``` + +- [ ] **Step 4: Зелёный:** `npm run test:vue -- --run tests/Frontend/GuidedTour.spec.ts` → PASS (5) + +- [ ] **Step 5: Commit** + +```bash +git add app/resources/js/components/layout/GuidedTour.vue app/tests/Frontend/GuidedTour.spec.ts +git commit -m "feat(tours): GuidedTour — обобщённый раннер с ожиданием цели" +``` + +--- + +### Task 3: Запуск по `?tour=` — `useTourLauncher` + AppLayout + +**Files:** +- Create: `app/resources/js/composables/useTourLauncher.ts` +- Modify: `app/resources/js/layouts/AppLayout.vue` (импорты ~строка 23, template ~строка 94 рядом с WelcomeTour) +- Test: `app/tests/Frontend/TourLauncher.spec.ts` + +- [ ] **Step 1: Падающий тест** `app/tests/Frontend/TourLauncher.spec.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; +import { ref } from 'vue'; +import { useTourLauncher } from '../../resources/js/composables/useTourLauncher'; + +function makeRouterMocks(query: Record) { + const route = ref({ query, fullPath: '/x' }); + const router = { push: vi.fn().mockResolvedValue(undefined), replace: vi.fn().mockResolvedValue(undefined) }; + return { route, router }; +} + +describe('useTourLauncher', () => { + it('валидный ?tour= → активирует сценарий и ведёт на роут первого шага', async () => { + const { route, router } = makeRouterMocks({ tour: 'create-project' }); + const l = useTourLauncher(route as never, router as never); + await l.checkQuery(); + expect(l.activeTour.value?.name).toBe('create-project'); + expect(router.push).toHaveBeenCalledWith({ path: '/projects', query: {} }); + }); + + it('мусорный ?tour= → игнор без падения, query чистится', async () => { + const { route, router } = makeRouterMocks({ tour: 'no-such' }); + const l = useTourLauncher(route as never, router as never); + await l.checkQuery(); + expect(l.activeTour.value).toBeNull(); + expect(router.replace).toHaveBeenCalled(); + }); + + it('без ?tour= — ничего не делает', async () => { + const { route, router } = makeRouterMocks({}); + const l = useTourLauncher(route as never, router as never); + await l.checkQuery(); + expect(l.activeTour.value).toBeNull(); + expect(router.push).not.toHaveBeenCalled(); + }); + + it('finishTour гасит активный тур', async () => { + const { route, router } = makeRouterMocks({ tour: 'tariffs' }); + const l = useTourLauncher(route as never, router as never); + await l.checkQuery(); + l.finishTour(); + expect(l.activeTour.value).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Падает:** `npm run test:vue -- --run tests/Frontend/TourLauncher.spec.ts` → FAIL + +- [ ] **Step 3: Реализация** `app/resources/js/composables/useTourLauncher.ts`: + +```ts +/** + * Запуск экскурсии по ссылке из чата бота: /?tour=<имя> (спека ИИ-бота §4). + * Невошедшего роутер сам отправит на /login с redirect=fullPath — query + * переживает вход (router/index.ts beforeEach), поэтому отдельной логики + * логина здесь нет. Мусорное имя — молча чистим query (не пугаем клиента). + */ +import { ref, type Ref } from 'vue'; +import type { Router } from 'vue-router'; +import { findTour, type TourScenario } from '../tours/catalog'; + +interface RouteLike { + query: Record; +} + +export function useTourLauncher(route: Ref, router: Router) { + const activeTour = ref(null); + + async function checkQuery(): Promise { + const name = typeof route.value.query.tour === 'string' ? route.value.query.tour : ''; + if (name === '') return; + const tour = findTour(name); + if (tour === null) { + await router.replace({ query: { ...route.value.query, tour: undefined } }); + return; + } + activeTour.value = tour; + await router.push({ path: tour.steps[0].route, query: {} }); + } + + function finishTour(): void { + activeTour.value = null; + } + + return { activeTour, checkQuery, finishTour }; +} +``` + +- [ ] **Step 4: Зелёный:** `npm run test:vue -- --run tests/Frontend/TourLauncher.spec.ts` → PASS (4) + +- [ ] **Step 5: Подключить в AppLayout** — в `app/resources/js/layouts/AppLayout.vue`: + +в `