Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions app/src/components/walkthrough/AppWalkthrough.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { useEffect, useMemo, useState } from 'react';
import { type EventData, EVENTS, Joyride, STATUS } from 'react-joyride';
import { type Controls, type EventData, EVENTS, Joyride, STATUS } from 'react-joyride';
import { useNavigate } from 'react-router-dom';

import { getStepGate } from './interactiveGates';
import { createWalkthroughSteps } from './walkthroughSteps';
import WalkthroughTooltip from './WalkthroughTooltip';

// ── localStorage keys ──────────────────────────────────────────────────────

const WALKTHROUGH_KEY = 'openhuman:walkthrough_completed';
const WALKTHROUGH_PENDING_KEY = 'openhuman:walkthrough_pending';
export const WALKTHROUGH_STEP_KEY = 'openhuman:walkthrough_step';

/**
* Returns `true` when the walkthrough should be shown. This is true when:
Expand Down Expand Up @@ -58,6 +60,7 @@ export function markWalkthroughComplete(): void {
try {
localStorage.setItem(WALKTHROUGH_KEY, 'true');
localStorage.removeItem(WALKTHROUGH_PENDING_KEY);
localStorage.removeItem(WALKTHROUGH_STEP_KEY);
console.debug('[walkthrough] marked as complete');
} catch (e) {
console.warn('[walkthrough] could not mark walkthrough complete in localStorage', e);
Expand All @@ -75,6 +78,7 @@ export function markWalkthroughComplete(): void {
export function resetWalkthrough(): void {
try {
localStorage.removeItem(WALKTHROUGH_KEY);
localStorage.removeItem(WALKTHROUGH_STEP_KEY);
localStorage.setItem(WALKTHROUGH_PENDING_KEY, 'true');
console.debug('[walkthrough] reset — pending flag set, completed flag removed');
} catch (e) {
Expand All @@ -83,6 +87,33 @@ export function resetWalkthrough(): void {
window.dispatchEvent(new CustomEvent('walkthrough:restart'));
}

// ── Step persistence helpers ───────────────────────────────────────────────

function getSavedStepIndex(): number {
try {
const saved = localStorage.getItem(WALKTHROUGH_STEP_KEY);
return saved ? Math.max(0, parseInt(saved, 10) || 0) : 0;
} catch {
return 0;
}
}

function saveStepIndex(index: number): void {
try {
localStorage.setItem(WALKTHROUGH_STEP_KEY, String(index));
} catch (e) {
console.warn('[walkthrough] could not save step index', e);
}
}

function clearStepIndex(): void {
try {
localStorage.removeItem(WALKTHROUGH_STEP_KEY);
} catch (e) {
console.warn('[walkthrough] could not clear step index', e);
}
}

// ── Component ──────────────────────────────────────────────────────────────

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

// Track the current step index for controlled mode — enables resume support.
const [stepIndex, setStepIndex] = useState<number>(() => getSavedStepIndex());

// Memoize steps so they are only recreated when `navigate` identity changes.
const steps = useMemo(() => createWalkthroughSteps(navigate), [navigate]);

Expand All @@ -111,6 +145,8 @@ const AppWalkthrough = ({ onboarded = false }: { onboarded?: boolean }) => {
useEffect(() => {
const handleRestart = () => {
console.debug('[walkthrough] restart event received — restarting tour');
clearStepIndex();
setStepIndex(0);
setRun(true);
};
window.addEventListener('walkthrough:restart', handleRestart);
Expand All @@ -119,14 +155,33 @@ const AppWalkthrough = ({ onboarded = false }: { onboarded?: boolean }) => {
};
}, []);

const handleEvent = (data: EventData) => {
const handleEvent = (data: EventData, controls: Controls) => {
const { type, status } = data;
console.debug('[walkthrough] event', { type, status, index: data.index });

// STEP_BEFORE: auto-skip gated steps whose gate is already satisfied.
if (type === EVENTS.STEP_BEFORE) {
const gate = getStepGate(steps[data.index]);
if (gate && gate.isComplete()) {
console.debug('[walkthrough] gate already complete, auto-skipping step', data.index);
// Use setTimeout to avoid calling controls.next() during the event handler.
setTimeout(() => controls.next(), 0);
return;
}
}

// STEP_AFTER: persist the next step index so the tour can resume.
if (type === EVENTS.STEP_AFTER) {
const nextIndex = data.index + 1;
setStepIndex(nextIndex);
saveStepIndex(nextIndex);
}

// TOUR_END fires when the tour finishes or is skipped.
if (type === EVENTS.TOUR_END) {
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
markWalkthroughComplete();
clearStepIndex();
setRun(false);
}
}
Expand All @@ -139,6 +194,7 @@ const AppWalkthrough = ({ onboarded = false }: { onboarded?: boolean }) => {
<Joyride
steps={steps}
run={run}
stepIndex={stepIndex}
continuous={true}
tooltipComponent={WalkthroughTooltip}
onEvent={handleEvent}
Expand Down
33 changes: 32 additions & 1 deletion app/src/components/walkthrough/WalkthroughTooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { TooltipRenderProps } from 'react-joyride';

import { getStepGate } from './interactiveGates';
import { useGatePoller } from './useGatePoller';

/** Emoji accents per step — adds visual personality to each tooltip.
* 10 entries map to: home-card, home-cta, chat, integrations, channels,
* intelligence, settings, quick-access tabs, notifications, final. */
Expand All @@ -26,6 +29,11 @@ const WalkthroughTooltip = ({
const progress = ((index + 1) / size) * 100;
const icon = STEP_ICONS[index] ?? '✨';

const gate = getStepGate(step);
const gateComplete = useGatePoller(gate);
const isGated = gate !== null;
const gateBlocking = isGated && !gateComplete;

return (
<div
{...tooltipProps}
Expand Down Expand Up @@ -62,6 +70,11 @@ const WalkthroughTooltip = ({
{/* Body */}
<div className="text-[13px] text-stone-600 leading-relaxed mb-5">{step.content}</div>

{/* Gate prompt */}
{gateBlocking && (
<div className="text-[12px] text-amber-600 font-medium mb-3">{gate.label}</div>
)}

{/* Actions */}
<div className="flex items-center gap-2">
{/* Skip tour */}
Expand All @@ -75,6 +88,21 @@ const WalkthroughTooltip = ({

<div className="flex-1" />

{/* Gate status */}
{isGated && (
<div className="flex items-center gap-2">
{gateBlocking ? (
<button
{...primaryProps}
className="text-[11px] text-stone-400 hover:text-stone-600 transition-colors px-2 py-1.5 rounded-lg hover:bg-stone-100">
{gate.skipLabel}
</button>
) : (
<span className="text-[11px] text-emerald-600 font-medium">✓ Done!</span>
)}
</div>
)}

{/* Back */}
{index > 0 && (
<button
Expand All @@ -88,7 +116,10 @@ const WalkthroughTooltip = ({
{continuous && (
<button
{...primaryProps}
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">
disabled={gateBlocking}
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${
gateBlocking ? ' opacity-50 cursor-not-allowed' : ''
}`}>
{isLastStep ? "Let's go!" : 'Next →'}
</button>
)}
Expand Down
Loading
Loading