3e9ce2091e
Владелец застрял на шаге «Цена» (экскурсия про сбор конкурентов): карточка встала ниже видимой части окна и вдобавок была накрыта виджетом Jivo — кнопку «Далее» нажать было нечем, экскурсия превращалась в тупик. 1. Позиция карточки прижимается к экрану (clampTop): она всегда целиком видна, какой бы низкой ни была цель шага. 2. z-index экскурсии поднят выше Jivo (виджет рисует себя поверх всего). Оба бага сначала воспроизведены тестами. Vitest 1227/1227. Живьём: все 4 шага экскурсии collect-competitors — карточка в экране, «Далее» кликабельна. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
243 lines
8.3 KiB
Vue
243 lines
8.3 KiB
Vue
<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>
|