Files
portal/app/resources/js/components/layout/GuidedTour.vue
T
Дмитрий 3e9ce2091e fix(tours): карточка экскурсии не уезжает за экран и не тонет под виджетом чата
Владелец застрял на шаге «Цена» (экскурсия про сбор конкурентов): карточка встала
ниже видимой части окна и вдобавок была накрыта виджетом Jivo — кнопку «Далее»
нажать было нечем, экскурсия превращалась в тупик.

1. Позиция карточки прижимается к экрану (clampTop): она всегда целиком видна,
   какой бы низкой ни была цель шага.
2. z-index экскурсии поднят выше Jivo (виджет рисует себя поверх всего).

Оба бага сначала воспроизведены тестами. Vitest 1227/1227. Живьём: все 4 шага
экскурсии collect-competitors — карточка в экране, «Далее» кликабельна.

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

243 lines
8.3 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">
/**
* GuidedTour — обобщённый раннер экскурсий (спека ИИ-бота §4, этап 3).
* Отличия от WelcomeTour: шаги пропсом; цель шага может появиться ПОСЛЕ
* действия клиента (открыл диалог) — меряем с ретраем каждые 300мс до 15с.
* Разметка/стили — по образцу WelcomeTour (единый вид подсказок).
*/
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { TourStep } from '../../tours/catalog';
const props = defineProps<{ steps: TourStep[]; active: boolean }>();
const emit = defineEmits<{ finish: [] }>();
const router = useRouter();
const route = useRoute();
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 step = currentStep.value;
const sel = step?.target;
if (!sel) return;
// «Не только кнопку показать, а и что за ней» (владелец, 12.07.2026): шаг может
// сам открыть окно/вкладку. Жмём один раз и только если начинки ещё нет на экране —
// иначе повторный клик закроет уже открытое окно.
const opener = step?.open;
let opened = opener === undefined || document.querySelector(sel) !== null;
let attempts = 0;
const tryMeasure = (): void => {
if (!opened && opener !== undefined) {
const btn = document.querySelector(opener);
if (btn instanceof HTMLElement) {
btn.click();
opened = true;
}
}
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 CARD_W = 300;
/** Запас по высоте карточки (заголовок + текст + кнопки). Живой баг 12.07.2026: */
/** карточка уезжала ниже экрана, и кнопку «Далее» было не нажать. */
const CARD_H = 220;
/** Держим карточку в пределах окна: иначе кнопки «Далее»/«Закрыть» недоступны. */
function clampTop(top: number): number {
const vh = window.innerHeight;
return Math.max(12, Math.min(top, vh - CARD_H - 12));
}
const tooltipStyle = computed(() => {
const r = targetRect.value;
if (!r) return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
// Карточка 300px: справа от цели; не влезает — слева; и там тесно — под целью
// (цель у правого края экрана, напр. кнопка «Создать проект»).
const vw = window.innerWidth;
const rightX = r.left + r.width + 16;
if (rightX + CARD_W + 12 <= vw) {
return { top: `${clampTop(r.top)}px`, left: `${rightX}px` };
}
const leftX = r.left - CARD_W - 16;
if (leftX >= 12) {
return { top: `${clampTop(r.top)}px`, left: `${leftX}px` };
}
const belowX = Math.min(Math.max(12, r.left), vw - CARD_W - 12);
return { top: `${clampTop(r.top + r.height + 12)}px`, left: `${belowX}px` };
});
function next(): void {
if (isLast.value) {
finish();
return;
}
stepIndex.value += 1;
// Шаг живёт на другом экране — ведём клиента туда сами (живой баг 12.07.2026:
// экскурсия «застревала» на первом экране, шаг про «Проекты» висел над «Биллингом»).
// Цель появится после перехода — раннер её дождётся (RETRY_MS × RETRY_MAX).
const target = currentStep.value?.route;
if (target !== undefined && target !== route.path) {
void router.push(target);
}
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);
});
/** Виджет чата Jivo рисует себя поверх всего (z-index ~2147483000) — экскурсия должна быть выше, */
/** иначе её карточку нечем закрыть и некуда нажать (живой баг 12.07.2026). */
const Z_INDEX = 2147483640;
defineExpose({ stepIndex, targetRect, next, finish, tooltipStyle, Z_INDEX });
</script>
<template>
<div v-if="active && currentStep" class="guided-tour" data-testid="guided-tour" :style="{ zIndex: Z_INDEX }">
<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>