813b58447e
Лёгкий тур без сторонних зависимостей: подсвечивает пункты меню по порядку пополнить→создать проект→где заявки→помощь. Показывается один раз localStorage, можно пропустить. Якоря data-tour на AppSidebar, монтируется в AppLayout. Тест WelcomeTour 4/4. NB: пиксельное позиционирование проверить визуально на выкате. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
212 lines
7.2 KiB
Vue
212 lines
7.2 KiB
Vue
<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>
|