docs(tours): план этапа 3 — экскурсии «Показать на портале» (6 задач TDD)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-03 03:19:57 +03:00
parent 6841492226
commit 94e5828fbc
@@ -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: '<button @click="$emit(\'click\')"><slot /></button>' } } },
});
}
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
<script setup lang="ts">
/**
* GuidedTour — обобщённый раннер экскурсий (спека ИИ-бота §4, этап 3).
* Отличия от WelcomeTour: шаги пропсом; цель шага может появиться ПОСЛЕ
* действия клиента (открыл диалог) — меряем с ретраем каждые 300мс до 15с.
* Разметка/стили — по образцу WelcomeTour (единый вид подсказок).
*/
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { TourStep } from '../../tours/catalog';
const props = defineProps<{ steps: TourStep[]; active: boolean }>();
const emit = defineEmits<{ finish: [] }>();
const RETRY_MS = 300;
const RETRY_MAX = 50; // 15 сек
const stepIndex = ref(0);
const targetRect = ref<{ top: number; left: number; width: number; height: number } | null>(null);
let retryTimer: ReturnType<typeof setInterval> | null = null;
const currentStep = computed(() => props.steps[stepIndex.value]);
const isLast = computed(() => stepIndex.value === props.steps.length - 1);
function stopRetry(): void {
if (retryTimer !== null) {
clearInterval(retryTimer);
retryTimer = null;
}
}
function measure(): void {
stopRetry();
targetRect.value = null;
const sel = currentStep.value?.target;
if (!sel) return;
let attempts = 0;
const tryMeasure = (): void => {
const el = document.querySelector(sel);
if (el) {
const r = el.getBoundingClientRect();
targetRect.value = { top: r.top, left: r.left, width: r.width, height: r.height };
stopRetry();
return;
}
attempts += 1;
if (attempts >= RETRY_MAX) stopRetry();
};
tryMeasure();
if (targetRect.value === null) {
retryTimer = setInterval(tryMeasure, RETRY_MS);
}
}
const highlightStyle = computed(() => {
const r = targetRect.value;
if (!r) return { display: 'none' };
const pad = 6;
return {
top: `${r.top - pad}px`,
left: `${r.left - pad}px`,
width: `${r.width + pad * 2}px`,
height: `${r.height + pad * 2}px`,
};
});
const tooltipStyle = computed(() => {
const r = targetRect.value;
if (!r) return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
return { top: `${Math.max(12, r.top)}px`, left: `${r.left + r.width + 16}px` };
});
function next(): void {
if (isLast.value) {
finish();
return;
}
stepIndex.value += 1;
measure();
}
function finish(): void {
stopRetry();
emit('finish');
}
function onResize(): void {
if (props.active) measure();
}
watch(
() => props.active,
(on) => {
if (on) {
stepIndex.value = 0;
requestAnimationFrame(() => measure());
window.addEventListener('resize', onResize);
} else {
stopRetry();
window.removeEventListener('resize', onResize);
}
},
{ immediate: true },
);
onBeforeUnmount(() => {
stopRetry();
window.removeEventListener('resize', onResize);
});
defineExpose({ stepIndex, targetRect, next, finish });
</script>
<template>
<div v-if="active && currentStep" class="guided-tour" data-testid="guided-tour">
<div class="guided-tour__backdrop" />
<div v-if="targetRect" class="guided-tour__highlight" :style="highlightStyle" />
<div class="guided-tour__card" :style="tooltipStyle" role="dialog" aria-modal="true">
<div class="guided-tour__step">Шаг {{ stepIndex + 1 }} из {{ steps.length }}</div>
<h3 class="guided-tour__title">{{ currentStep.title }}</h3>
<p class="guided-tour__text">{{ currentStep.text }}</p>
<div class="guided-tour__actions">
<v-btn variant="text" size="small" data-testid="tour-skip" @click="finish">Закрыть</v-btn>
<v-btn color="primary" variant="flat" size="small" data-testid="tour-next" @click="next">
{{ isLast ? 'Готово' : 'Далее' }}
</v-btn>
</div>
</div>
</div>
</template>
<style scoped>
.guided-tour {
position: fixed;
inset: 0;
z-index: 3000;
pointer-events: none;
}
.guided-tour__backdrop {
position: absolute;
inset: 0;
background: rgba(1, 32, 25, 0.55);
pointer-events: auto;
}
.guided-tour__highlight {
position: absolute;
border: 2px solid var(--liderra-teal, #0f6e56);
border-radius: 8px;
box-shadow: 0 0 0 9999px rgba(1, 32, 25, 0.55);
transition: all 200ms cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: none;
}
.guided-tour__card {
position: absolute;
width: 300px;
max-width: calc(100vw - 24px);
background: #fff;
border-radius: 12px;
padding: 16px 18px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
pointer-events: auto;
}
.guided-tour__step {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #6b7470;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.guided-tour__title {
font-size: 16px;
font-weight: 600;
margin: 4px 0 6px;
color: #081319;
}
.guided-tour__text {
font-size: 13px;
line-height: 1.45;
color: #3a423f;
margin: 0 0 14px;
}
.guided-tour__actions {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
```
- [ ] **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<string, string>) {
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<string, unknown>;
}
export function useTourLauncher(route: Ref<RouteLike>, router: Router) {
const activeTour = ref<TourScenario | null>(null);
async function checkQuery(): Promise<void> {
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`:
в `<script setup>` (рядом с импортом WelcomeTour, ~строка 23):
```ts
import GuidedTour from '../components/layout/GuidedTour.vue';
import { useTourLauncher } from '../composables/useTourLauncher';
```
после объявления `route`/`router` (найти существующие `useRoute()/useRouter()`; если роутер не заведён — добавить):
```ts
const tourLauncher = useTourLauncher(computed(() => route) as never, router);
onMounted(() => {
void tourLauncher.checkQuery();
});
watch(
() => route.query.tour,
() => {
void tourLauncher.checkQuery();
},
);
```
в `<template>` рядом с `<WelcomeTour />` (~строка 94):
```html
<GuidedTour
v-if="tourLauncher.activeTour.value"
:steps="tourLauncher.activeTour.value.steps"
:active="true"
@finish="tourLauncher.finishTour()"
/>
```
NB: точную форму привязки (`computed(() => route)` vs `toRef`) подогнать под фактический код AppLayout — там уже есть `route` (см. строку 91 `route.meta.devIndex`). Прогнать существующий `tests/Frontend/AppLayout.spec.ts`, если есть — не сломать.
- [ ] **Step 6: Прогнать смежные Vitest:** `npm run test:vue -- --run tests/Frontend/TourLauncher.spec.ts tests/Frontend/GuidedTour.spec.ts` → PASS; плюс `npm run type-check` по проекту → 0 новых ошибок.
- [ ] **Step 7: Commit**
```bash
git add app/resources/js/composables/useTourLauncher.ts app/resources/js/layouts/AppLayout.vue app/tests/Frontend/TourLauncher.spec.ts
git commit -m "feat(tours): запуск экскурсии по ?tour= из ссылки бота"
```
---
### Task 4: Якоря `data-tour` на целях
**Files:**
- Modify: `app/resources/js/views/ProjectsView.vue` (~строка 5 — кнопка «Создать проект»)
- Modify: `app/resources/js/views/BillingView.vue` (~строка 89 — кнопка «Пополнить баланс»)
- [ ] **Step 1: ProjectsView** — добавить атрибут кнопке:
```html
<v-btn color="primary" prepend-icon="mdi-plus" data-tour="projects-create" @click="openCreate">Создать проект</v-btn>
```
- [ ] **Step 2: BillingView** — добавить `data-tour="billing-topup"` кнопке «Пополнить баланс» (найти `>Пополнить баланс</v-btn` ~строка 89, атрибут в открывающий тег).
- [ ] **Step 3: Прогнать существующие спеки этих экранов:** `npm run test:vue -- --run tests/Frontend/ProjectsView.spec.ts tests/Frontend/BillingView.spec.ts` (если файлы существуют; иначе пропустить с пометкой) → PASS.
- [ ] **Step 4: Commit**
```bash
git add app/resources/js/views/ProjectsView.vue app/resources/js/views/BillingView.vue
git commit -m "feat(tours): data-tour якоря — кнопки создания проекта и пополнения"
```
---
### Task 5: Кросс-проверка frontmatter ↔ каталог (Pest)
**Files:**
- Test: `app/tests/Feature/Bot/HelpTourNamesTest.php`
- Modify (если надо): `app/resources/help/*.md`
- [ ] **Step 1: Написать тест** `app/tests/Feature/Bot/HelpTourNamesTest.php`:
```php
<?php
declare(strict_types=1);
use App\Support\Help\HelpArticleParser;
it('каждый tour из статей resources/help существует в каталоге экскурсий', function () {
$catalog = (string) file_get_contents(resource_path('js/tours/catalog.ts'));
preg_match_all("/name: '([a-z0-9\\-]+)'/", $catalog, $m);
$known = $m[1];
expect($known)->not->toBeEmpty();
$parser = new HelpArticleParser();
foreach (glob(resource_path('help').'/*.md') ?: [] as $file) {
$article = $parser->parse('help/'.basename($file), (string) file_get_contents($file));
if ($article->tour !== null) {
expect($known)->toContain($article->tour);
}
}
});
```
- [ ] **Step 2: Прогнать:** `DB_DATABASE=liderra_testing_jivo php -d memory_limit=2G artisan test --filter=HelpTourNamesTest` — ожидается PASS сразу (статьи ссылаются на create-project/tariffs/top-up-balance — все в каталоге). Если RED — исправить рассинхрон имени в статье или каталоге, НЕ ослаблять тест.
- [ ] **Step 3: Commit**
```bash
git add app/tests/Feature/Bot/HelpTourNamesTest.php
git commit -m "test(tours): frontmatter tour статей сверяется с каталогом экскурсий"
```
---
### Task 6: Финал — прогоны и синхрон документов
**Files:**
- Modify: `ПРОТОКОЛ-ии-дживосайт.md` (шаг «Этап 3 — экскурсии» → выполнен)
- Modify: `docs/superpowers/specs/2026-07-02-jivo-ai-support-bot-design.md` §4 (пометка «построено 03.07.2026»)
- [ ] **Step 1: Полный Vitest:** `npm run test:vue -- --run` → 0 новых красных против базы (база: 10 пред-сущ. красных фронт-файлов упоминались в памяти launch-gate — сверить фактически, наши файлы зелёные).
- [ ] **Step 2: Bot-часть Pest:** `DB_DATABASE=liderra_testing_jivo php -d memory_limit=2G artisan test --filter="Bot|Tour|Help"` → все зелёные.
- [ ] **Step 3: type-check + pint:** `npm run type-check` (0 новых) + `composer pint` (только наши файлы).
- [ ] **Step 4: Протокол** — отметить «[x] Этап 3 — экскурсии построены (03.07.2026, worktree)»; спека §4 — приписка «Построено 03.07.2026; включение ссылок в ответы бота — флаг JIVO_BOT_TOURS_ENABLED при деплое».
- [ ] **Step 5: Commit**
```bash
git add ПРОТОКОЛ-ии-дживосайт.md docs/superpowers/specs/2026-07-02-jivo-ai-support-bot-design.md
git commit -m "docs(tours): этап 3 построен — протокол и спека синхронизированы"
```
---
## Вне рамок (после этого плана)
1. Полный последовательный Pest-прогон — ДО 21:00 МСК (грабля времени суток — память `feedback-worktree-test-bootstrap-recipe`).
2. Живая проверка глазами (`/run` или вручную): открыть `/?tour=create-project` на локальном стенде.
3. Деплой + включение флага + подключение Jivo Bot API — только с разрешения владельца (О-2/О-3).
## Self-review (выполнен при написании)
- Спека §4 покрыта: каталог (Task 1), раннер (Task 2), `?tour=` + логин-редирект (Task 3), якоря (Task 4), «ИИ не сочиняет шаги» (Task 5 — сверка имён). Кнопка в ответе бота — уже в ядре (BotAnswerService, флаг).
- Плейсхолдеров нет; код полный в каждом шаге; известные неточности среды (наличие AppLayout.spec.ts, ProjectsView.spec.ts, форма привязки route) помечены явно с fallback-инструкцией.
- Типы сквозные: `TourStep{route,target,title,text}`, `TourScenario{name,steps}`, `findTour(): TourScenario|null`, `useTourLauncher(){activeTour,checkQuery,finishTour}` — согласованы между задачами 1–3.