Files
portal/app/resources/js/components/layout/WelcomeTour.vue
T
Дмитрий d1f3b0581e fix(tours): ссылка бота на экскурсию переживает вход и не перебивается приветственным туром
Владелец нажал «Показать на портале» в чате и не смог посмотреть: его выкинуло
на «Вход», после входа увело на дашборд (адрес экскурсии потерян), а сверху сел
приветственный тур «Добро пожаловать в Лидерру».

1. LoginView теперь возвращает клиента по redirect (роутер его уже сохранял) —
   /dashboard?tour=dupes открывается сразу после входа. Чужой адрес в redirect
   игнорируется (защита от подмены ссылки).
2. WelcomeTour молчит, если в адресе есть ?tour= — экскурсия из чата важнее.

Оба бага сначала воспроизведены тестами. Vitest 1225/1225. Проверено живьём:
гость → ссылка бота → форма входа → сразу «Шаг 1 из 4» нужной экскурсии.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:10:00 +03:00

225 lines
7.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* WelcomeTour (Фаза 3, UX-аудит 25.06) — приветственный тур при первом входе.
*
* Ведёт новичка за руку: подсвечивает ключевые пункты меню по порядку
* (пополнить → создать проект → где заявки → помощь). Показывается ОДИН раз
* (флаг в localStorage), можно пропустить. Без сторонних зависимостей —
* лёгкий оверлей + позиционирование по getBoundingClientRect целевого элемента.
*
* Якоря целей — атрибуты data-tour на пунктах AppSidebar.
*/
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
interface TourStep {
/** CSS-селектор цели; null = центрированный экран без подсветки (интро). */
target: string | null;
title: string;
text: string;
}
const SEEN_KEY = 'liderra.welcomeTourSeen';
const steps: TourStep[] = [
{
target: null,
title: 'Добро пожаловать в Лидерру!',
text: 'Покажем за минуту, с чего начать. Можно пропустить и вернуться к подсказкам в разделе «Помощь».',
},
{
target: '[data-tour="nav-billing"]',
title: '1. Пополните баланс',
text: 'Из баланса оплачиваются заявки. «Биллинг» → «Пополнить».',
},
{
target: '[data-tour="nav-projects"]',
title: '2. Создайте проект',
text: 'Укажите, по какому сайту или телефону собирать клиентов и сколько заявок в день нужно.',
},
{
target: '[data-tour="nav-deals"]',
title: '3. Здесь будут заявки',
text: 'Все полученные заявки появятся в этом разделе — с телефоном, источником и статусом.',
},
{
target: '[data-tour="nav-help"]',
title: 'Запутались?',
text: 'В «Помощи» — ответы на частые вопросы и связь с нами. Удачного старта!',
},
];
function alreadySeen(): boolean {
try {
return localStorage.getItem(SEEN_KEY) === '1';
} catch {
return false;
}
}
/**
* Клиент пришёл по ссылке из чата бота («Показать на портале» → ?tour=имя) —
* тогда приветственный тур молчит, иначе он перекрывает нужную экскурсию
* (живой баг 12.07.2026).
*/
function tourRequestedFromChat(): boolean {
try {
return new URLSearchParams(window.location.search).has('tour');
} catch {
return false;
}
}
// Инициализируем синхронно в setup (а не в onMounted) — детерминированно для тестов
// и без мигания: тур либо сразу активен новому пользователю, либо нет.
const active = ref(!alreadySeen() && !tourRequestedFromChat());
const stepIndex = ref(0);
const targetRect = ref<{ top: number; left: number; width: number; height: number } | null>(null);
const currentStep = computed(() => steps[stepIndex.value]);
const isLast = computed(() => stepIndex.value === steps.length - 1);
function measure(): void {
const sel = currentStep.value?.target;
if (!sel) {
targetRect.value = null;
return;
}
const el = document.querySelector(sel);
if (!el) {
targetRect.value = null;
return;
}
const r = el.getBoundingClientRect();
targetRect.value = { top: r.top, left: r.left, width: r.width, height: r.height };
}
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 {
active.value = false;
try {
localStorage.setItem(SEEN_KEY, '1');
} catch {
// приватный режим / квота — не критично, тур просто покажется снова
}
}
function onResize(): void {
if (active.value) measure();
}
onMounted(() => {
if (!active.value) return;
// Ждём отрисовку sidebar, затем измеряем первую цель.
requestAnimationFrame(() => measure());
window.addEventListener('resize', onResize);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
});
defineExpose({ active, stepIndex, steps, next, finish });
</script>
<template>
<div v-if="active" class="welcome-tour" data-testid="welcome-tour">
<div class="welcome-tour__backdrop" />
<div v-if="targetRect" class="welcome-tour__highlight" :style="highlightStyle" />
<div class="welcome-tour__card" :style="tooltipStyle" role="dialog" aria-modal="true">
<div class="welcome-tour__step">Шаг {{ stepIndex + 1 }} из {{ steps.length }}</div>
<h3 class="welcome-tour__title">{{ currentStep.title }}</h3>
<p class="welcome-tour__text">{{ currentStep.text }}</p>
<div class="welcome-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>
.welcome-tour {
position: fixed;
inset: 0;
z-index: 3000;
pointer-events: none;
}
.welcome-tour__backdrop {
position: absolute;
inset: 0;
background: rgba(1, 32, 25, 0.55);
pointer-events: auto;
}
.welcome-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;
}
.welcome-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;
}
.welcome-tour__step {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #6b7470;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.welcome-tour__title {
font-size: 16px;
font-weight: 600;
margin: 4px 0 6px;
color: #081319;
}
.welcome-tour__text {
font-size: 13px;
line-height: 1.45;
color: #3a423f;
margin: 0 0 14px;
}
.welcome-tour__actions {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>