import { useMemo, useRef, useState } from 'react'; export type Step = { id: string; title: string; description: string; // percentage-based hotspot region over the base image (left, top, width, height) region: { leftPct: number; topPct: number; widthPct: number; heightPct: number }; media?: { type: 'image' | 'video'; src: string; alt?: string }; }; type OnboardingTourProps = { imageSrc: string; steps: Step[]; editable?: boolean; onStepsChange?: (steps: Step[]) => void; }; const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); export default function OnboardingTour({ imageSrc, steps, editable = false, onStepsChange }: OnboardingTourProps) { const [activeIndex, setActiveIndex] = useState(0); const containerRef = useRef(null); const activeStep = steps[activeIndex]; const handlePrev = () => setActiveIndex((i) => clamp(i - 1, 0, steps.length - 1)); const handleNext = () => setActiveIndex((i) => clamp(i + 1, 0, steps.length - 1)); const handleSkip = () => setActiveIndex(steps.length - 1); const progressPct = useMemo(() => ((activeIndex + 1) / steps.length) * 100, [activeIndex, steps.length]); const updateRegion = (delta: Partial) => { if (!editable) return; const next = steps.map((s, i) => { if (i !== activeIndex) return s; const r = s.region; const newRegion = { leftPct: clamp((delta.leftPct ?? r.leftPct), 0, 100), topPct: clamp((delta.topPct ?? r.topPct), 0, 100), widthPct: clamp((delta.widthPct ?? r.widthPct), 1, 100), heightPct: clamp((delta.heightPct ?? r.heightPct), 1, 100), }; return { ...s, region: newRegion }; }); onStepsChange?.(next); }; const nudge = (dx = 0, dy = 0) => { const r = activeStep.region; updateRegion({ leftPct: clamp(r.leftPct + dx, 0, 100), topPct: clamp(r.topPct + dy, 0, 100) }); }; const resize = (dw = 0, dh = 0) => { const r = activeStep.region; updateRegion({ widthPct: clamp(r.widthPct + dw, 1, 100), heightPct: clamp(r.heightPct + dh, 1, 100) }); }; const POP_W = 24; // approximate popover width in percent of canvas const POP_H = 22; // approximate popover height in percent of canvas const GAP = 2; // gap between region and popover in percent const computePopoverPlacement = () => { const r = activeStep.region; const spaceBelow = 100 - (r.topPct + r.heightPct); const spaceAbove = r.topPct; const spaceRight = 100 - (r.leftPct + r.widthPct); // const spaceLeft = r.leftPct; // not needed currently // Special case: Step 5 (globe) must always be to the LEFT, stuck to region's left side if (activeStep.id === 'globe') { // Shift a bit more to the left to sit clearer outside of the region const left = clamp(r.leftPct - POP_W - 3, 2, 100 - POP_W - 2); const top = clamp(r.topPct, 2, 100 - POP_H - 2); return { left, top, cls: '' }; } // Prefer bottom → top → right → left, never overlap the region and never leave the canvas if (spaceBelow >= POP_H) { return { left: clamp(r.leftPct, 2, 100 - POP_W - 2), top: r.topPct + r.heightPct + GAP, cls: '', }; } if (spaceAbove >= POP_H) { return { left: clamp(r.leftPct, 2, 100 - POP_W - 2), top: clamp(r.topPct - POP_H - GAP, 2, 100 - POP_H - 2), cls: '', }; } if (spaceRight >= POP_W) { return { left: r.leftPct + r.widthPct + GAP, top: clamp(r.topPct, 2, 100 - POP_H - 2), cls: 'side-right', }; } // fallback: place to left const left = clamp(r.leftPct - POP_W - GAP, 2, 100 - POP_W - 2); return { left, top: clamp(r.topPct, 2, 100 - POP_H - 2), cls: 'side-left', }; }; return (
Dashboard overview {steps.map((step, index) => { const isActive = index === activeIndex; return (
{JSON.stringify(activeStep.region, null, 2)}
) : null}
); })()}
{steps.map((s, i) => (
); }