docs(tours): план этапа 3 — экскурсии «Показать на портале» (6 задач TDD)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user