Skip to content

Commit fed5bbb

Browse files
committed
feat(walkthrough): interactive tour gates with resume support
Add 2 interactive gates to the guided tour that block progression until the user completes a real action (connect an app, send a message). Gates auto-skip when the action is already done, and each gate offers a skip button so users are never stuck. Tour progress persists in localStorage for resume after app restart. Closes #1215
1 parent 894485a commit fed5bbb

8 files changed

Lines changed: 700 additions & 14 deletions

File tree

app/src/components/walkthrough/AppWalkthrough.tsx

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { useEffect, useMemo, useState } from 'react';
2-
import { type EventData, EVENTS, Joyride, STATUS } from 'react-joyride';
2+
import { type Controls, type EventData, EVENTS, Joyride, STATUS } from 'react-joyride';
33
import { useNavigate } from 'react-router-dom';
44

5+
import { getStepGate } from './interactiveGates';
56
import { createWalkthroughSteps } from './walkthroughSteps';
67
import WalkthroughTooltip from './WalkthroughTooltip';
78

89
// ── localStorage keys ──────────────────────────────────────────────────────
910

1011
const WALKTHROUGH_KEY = 'openhuman:walkthrough_completed';
1112
const WALKTHROUGH_PENDING_KEY = 'openhuman:walkthrough_pending';
13+
export const WALKTHROUGH_STEP_KEY = 'openhuman:walkthrough_step';
1214

1315
/**
1416
* Returns `true` when the walkthrough should be shown. This is true when:
@@ -58,6 +60,7 @@ export function markWalkthroughComplete(): void {
5860
try {
5961
localStorage.setItem(WALKTHROUGH_KEY, 'true');
6062
localStorage.removeItem(WALKTHROUGH_PENDING_KEY);
63+
localStorage.removeItem(WALKTHROUGH_STEP_KEY);
6164
console.debug('[walkthrough] marked as complete');
6265
} catch (e) {
6366
console.warn('[walkthrough] could not mark walkthrough complete in localStorage', e);
@@ -75,6 +78,7 @@ export function markWalkthroughComplete(): void {
7578
export function resetWalkthrough(): void {
7679
try {
7780
localStorage.removeItem(WALKTHROUGH_KEY);
81+
localStorage.removeItem(WALKTHROUGH_STEP_KEY);
7882
localStorage.setItem(WALKTHROUGH_PENDING_KEY, 'true');
7983
console.debug('[walkthrough] reset — pending flag set, completed flag removed');
8084
} catch (e) {
@@ -83,6 +87,33 @@ export function resetWalkthrough(): void {
8387
window.dispatchEvent(new CustomEvent('walkthrough:restart'));
8488
}
8589

90+
// ── Step persistence helpers ───────────────────────────────────────────────
91+
92+
function getSavedStepIndex(): number {
93+
try {
94+
const saved = localStorage.getItem(WALKTHROUGH_STEP_KEY);
95+
return saved ? Math.max(0, parseInt(saved, 10) || 0) : 0;
96+
} catch {
97+
return 0;
98+
}
99+
}
100+
101+
function saveStepIndex(index: number): void {
102+
try {
103+
localStorage.setItem(WALKTHROUGH_STEP_KEY, String(index));
104+
} catch (e) {
105+
console.warn('[walkthrough] could not save step index', e);
106+
}
107+
}
108+
109+
function clearStepIndex(): void {
110+
try {
111+
localStorage.removeItem(WALKTHROUGH_STEP_KEY);
112+
} catch (e) {
113+
console.warn('[walkthrough] could not clear step index', e);
114+
}
115+
}
116+
86117
// ── Component ──────────────────────────────────────────────────────────────
87118

88119
/**
@@ -103,6 +134,9 @@ const AppWalkthrough = ({ onboarded = false }: { onboarded?: boolean }) => {
103134
// Using a lazy initializer keeps this stable across re-renders.
104135
const [run, setRun] = useState<boolean>(() => isWalkthroughPending(onboarded));
105136

137+
// Track the current step index for controlled mode — enables resume support.
138+
const [stepIndex, setStepIndex] = useState<number>(() => getSavedStepIndex());
139+
106140
// Memoize steps so they are only recreated when `navigate` identity changes.
107141
const steps = useMemo(() => createWalkthroughSteps(navigate), [navigate]);
108142

@@ -111,6 +145,8 @@ const AppWalkthrough = ({ onboarded = false }: { onboarded?: boolean }) => {
111145
useEffect(() => {
112146
const handleRestart = () => {
113147
console.debug('[walkthrough] restart event received — restarting tour');
148+
clearStepIndex();
149+
setStepIndex(0);
114150
setRun(true);
115151
};
116152
window.addEventListener('walkthrough:restart', handleRestart);
@@ -119,14 +155,33 @@ const AppWalkthrough = ({ onboarded = false }: { onboarded?: boolean }) => {
119155
};
120156
}, []);
121157

122-
const handleEvent = (data: EventData) => {
158+
const handleEvent = (data: EventData, controls: Controls) => {
123159
const { type, status } = data;
124160
console.debug('[walkthrough] event', { type, status, index: data.index });
125161

162+
// STEP_BEFORE: auto-skip gated steps whose gate is already satisfied.
163+
if (type === EVENTS.STEP_BEFORE) {
164+
const gate = getStepGate(steps[data.index]);
165+
if (gate && gate.isComplete()) {
166+
console.debug('[walkthrough] gate already complete, auto-skipping step', data.index);
167+
// Use setTimeout to avoid calling controls.next() during the event handler.
168+
setTimeout(() => controls.next(), 0);
169+
return;
170+
}
171+
}
172+
173+
// STEP_AFTER: persist the next step index so the tour can resume.
174+
if (type === EVENTS.STEP_AFTER) {
175+
const nextIndex = data.index + 1;
176+
setStepIndex(nextIndex);
177+
saveStepIndex(nextIndex);
178+
}
179+
126180
// TOUR_END fires when the tour finishes or is skipped.
127181
if (type === EVENTS.TOUR_END) {
128182
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
129183
markWalkthroughComplete();
184+
clearStepIndex();
130185
setRun(false);
131186
}
132187
}
@@ -139,6 +194,7 @@ const AppWalkthrough = ({ onboarded = false }: { onboarded?: boolean }) => {
139194
<Joyride
140195
steps={steps}
141196
run={run}
197+
stepIndex={stepIndex}
142198
continuous={true}
143199
tooltipComponent={WalkthroughTooltip}
144200
onEvent={handleEvent}

app/src/components/walkthrough/WalkthroughTooltip.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import type { TooltipRenderProps } from 'react-joyride';
22

3+
import { getStepGate } from './interactiveGates';
4+
import { useGatePoller } from './useGatePoller';
5+
36
/** Emoji accents per step — adds visual personality to each tooltip.
47
* 10 entries map to: home-card, home-cta, chat, integrations, channels,
58
* intelligence, settings, quick-access tabs, notifications, final. */
@@ -26,6 +29,11 @@ const WalkthroughTooltip = ({
2629
const progress = ((index + 1) / size) * 100;
2730
const icon = STEP_ICONS[index] ?? '✨';
2831

32+
const gate = getStepGate(step);
33+
const gateComplete = useGatePoller(gate);
34+
const isGated = gate !== null;
35+
const gateBlocking = isGated && !gateComplete;
36+
2937
return (
3038
<div
3139
{...tooltipProps}
@@ -62,6 +70,11 @@ const WalkthroughTooltip = ({
6270
{/* Body */}
6371
<div className="text-[13px] text-stone-600 leading-relaxed mb-5">{step.content}</div>
6472

73+
{/* Gate prompt */}
74+
{gateBlocking && (
75+
<div className="text-[12px] text-amber-600 font-medium mb-3">{gate.label}</div>
76+
)}
77+
6578
{/* Actions */}
6679
<div className="flex items-center gap-2">
6780
{/* Skip tour */}
@@ -75,6 +88,21 @@ const WalkthroughTooltip = ({
7588

7689
<div className="flex-1" />
7790

91+
{/* Gate status */}
92+
{isGated && (
93+
<div className="flex items-center gap-2">
94+
{gateBlocking ? (
95+
<button
96+
{...primaryProps}
97+
className="text-[11px] text-stone-400 hover:text-stone-600 transition-colors px-2 py-1.5 rounded-lg hover:bg-stone-100">
98+
{gate.skipLabel}
99+
</button>
100+
) : (
101+
<span className="text-[11px] text-emerald-600 font-medium">✓ Done!</span>
102+
)}
103+
</div>
104+
)}
105+
78106
{/* Back */}
79107
{index > 0 && (
80108
<button
@@ -88,7 +116,10 @@ const WalkthroughTooltip = ({
88116
{continuous && (
89117
<button
90118
{...primaryProps}
91-
className="text-[12px] text-white bg-[#2F6EF4] hover:bg-[#2563d4] active:scale-[0.97] transition-all px-4 py-2 rounded-xl font-medium shadow-sm hover:shadow-md">
119+
disabled={gateBlocking}
120+
className={`text-[12px] text-white bg-[#2F6EF4] hover:bg-[#2563d4] active:scale-[0.97] transition-all px-4 py-2 rounded-xl font-medium shadow-sm hover:shadow-md${
121+
gateBlocking ? ' opacity-50 cursor-not-allowed' : ''
122+
}`}>
92123
{isLastStep ? "Let's go!" : 'Next →'}
93124
</button>
94125
)}

0 commit comments

Comments
 (0)