Files
portal/app/resources/js/components/layout/WelcomeTour.vue
T
Дмитрий 813b58447e feat/onboarding: приветственный тур при первом входе Фаза 3
Лёгкий тур без сторонних зависимостей: подсвечивает пункты меню по порядку
пополнить→создать проект→где заявки→помощь. Показывается один раз localStorage,
можно пропустить. Якоря data-tour на AppSidebar, монтируется в AppLayout.
Тест WelcomeTour 4/4. NB: пиксельное позиционирование проверить визуально на выкате.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:36:15 +03:00

212 lines
7.2 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;
}
}
// Инициализируем синхронно в setup (а не в onMounted) — детерминированно для тестов
// и без мигания: тур либо сразу активен новому пользователю, либо нет.
const active = ref(!alreadySeen());
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>