216 lines
8.5 KiB
TypeScript
216 lines
8.5 KiB
TypeScript
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<HTMLDivElement | null>(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<Step['region']>) => {
|
||
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 (
|
||
<div className="tour-root">
|
||
<div className="tour-canvas" ref={containerRef}>
|
||
<img className="tour-image" src={imageSrc} alt="Dashboard overview" />
|
||
|
||
{steps.map((step, index) => {
|
||
const isActive = index === activeIndex;
|
||
return (
|
||
<button
|
||
key={step.id}
|
||
className={`tour-hotspot ${isActive ? 'active' : ''}`}
|
||
style={{
|
||
left: `${step.region.leftPct}%`,
|
||
top: `${step.region.topPct}%`,
|
||
width: `${step.region.widthPct}%`,
|
||
height: `${step.region.heightPct}%`,
|
||
}}
|
||
onClick={() => setActiveIndex(index)}
|
||
aria-label={`Go to step ${index + 1}: ${step.title}`}
|
||
/>
|
||
);
|
||
})}
|
||
|
||
{/* Selective overlay - darkens everything except active step */}
|
||
<div className="tour-selective-overlay">
|
||
<div
|
||
className="tour-highlight-window"
|
||
style={{
|
||
left: `${activeStep.region.leftPct}%`,
|
||
top: `${activeStep.region.topPct}%`,
|
||
width: `${activeStep.region.widthPct}%`,
|
||
height: `${activeStep.region.heightPct}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{activeStep && (() => {
|
||
const pos = computePopoverPlacement();
|
||
const cls = `tour-popover${pos.cls ? ` ${pos.cls}` : ''}`;
|
||
return (
|
||
<div className={cls} style={{ left: `${pos.left}%`, top: `${pos.top}%` }}>
|
||
<div className="tour-popover-header">
|
||
<div className="tour-step-pill">Step {activeIndex + 1} / {steps.length}</div>
|
||
<div className="tour-title">{activeStep.title}</div>
|
||
</div>
|
||
<div className={`tour-body${activeStep.media ? (activeStep.id === 'globe' ? ' has-media-below' : ' has-media-left') : ''}`}>
|
||
{activeStep.media && activeStep.media.type === 'image' && (
|
||
<img
|
||
className={`tour-media${activeStep.id === 'globe' ? ' media-small-below' : ''}`}
|
||
src={activeStep.media.src}
|
||
alt={activeStep.media.alt ?? ''}
|
||
/>
|
||
)}
|
||
{activeStep.media && activeStep.media.type === 'video' && (
|
||
<video className="tour-media" src={activeStep.media.src} autoPlay loop muted playsInline />
|
||
)}
|
||
<div className="tour-text">
|
||
<p className="tour-desc">{activeStep.description}</p>
|
||
</div>
|
||
</div>
|
||
{editable ? (
|
||
<div className="tour-editor">
|
||
<div className="row">
|
||
<button className="btn" onClick={() => nudge(0, -1)}>▲</button>
|
||
</div>
|
||
<div className="row">
|
||
<button className="btn" onClick={() => nudge(-1, 0)}>◀</button>
|
||
<button className="btn" onClick={() => nudge(1, 0)}>▶</button>
|
||
</div>
|
||
<div className="row">
|
||
<button className="btn" onClick={() => nudge(0, 1)}>▼</button>
|
||
</div>
|
||
<div className="row" style={{ gap: 6, marginTop: 8 }}>
|
||
<button className="btn" onClick={() => resize(1, 0)}>genişlet ↔</button>
|
||
<button className="btn" onClick={() => resize(-1, 0)}>daralt ↔</button>
|
||
<button className="btn" onClick={() => resize(0, 1)}>uzat ↕</button>
|
||
<button className="btn" onClick={() => resize(0, -1)}>kısalt ↕</button>
|
||
</div>
|
||
<pre className="region-pre">{JSON.stringify(activeStep.region, null, 2)}</pre>
|
||
</div>
|
||
) : null}
|
||
<div className="tour-actions">
|
||
<button className="btn ghost" onClick={handlePrev} disabled={activeIndex === 0}>Previous</button>
|
||
<div className="grow" />
|
||
<button className="btn ghost" onClick={handleSkip}>Skip</button>
|
||
<button className="btn primary" onClick={handleNext} disabled={activeIndex === steps.length - 1}>
|
||
{activeIndex === steps.length - 1 ? 'Done' : 'Next'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
<div className="tour-progress">
|
||
<div className="tour-progress-bar" style={{ width: `${progressPct}%` }} />
|
||
</div>
|
||
|
||
<div className="tour-step-dots">
|
||
{steps.map((s, i) => (
|
||
<button key={s.id} className={`dot ${i === activeIndex ? 'active' : ''}`} onClick={() => setActiveIndex(i)} aria-label={`Step ${i + 1}`} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|