feat(issue-17): add onboarding wizard for new users (#26)
* feat(issue-17): add onboarding wizard for new users
Adds a step-based onboarding wizard that guides new users through their
first Claw3D setup: welcome, prerequisites, gateway connection, agent
discovery, and a completion screen.
Architecture:
src/features/onboarding/ (new feature module):
- types.ts: Step definitions, navigation helpers (getNextStep/getPrevStep)
- useOnboardingState.ts: localStorage-backed persistence hook
- index.ts: Barrel exports for clean imports
- components/OnboardingWizard.tsx: Main wizard container with step
navigation, progress bar, and modal overlay
- components/WelcomeStep.tsx: Feature highlights grid
- components/PrerequisitesStep.tsx: Checklist with links/commands
- components/ConnectStep.tsx: Compact gateway connection form
- components/AgentsStep.tsx: Agent discovery feedback
- components/CompleteStep.tsx: Final screen with CTA
Design decisions:
- Modular step system: new steps can be added by extending
OnboardingStepId and registering a component in the switch
- localStorage persistence: wizard shows once per browser, resettable
from settings (future: wire into Studio settings API)
- Connect step gates forward navigation: users cannot skip connection
- Follows Claw3D conventions: feature-first module, no shared state
pollution, Tailwind utility classes, lucide-react icons
- Does NOT modify existing routes or components — zero-risk integration
(parent wiring left to maintainer preference)
Integration guide (for maintainer):
1. Import { OnboardingWizard, useOnboardingState } from the module
2. Add useOnboardingState() to the root layout or agents page
3. Render <OnboardingWizard /> when showOnboarding is true
4. Pass gateway connection props from existing store
tests/unit/onboardingTypes.test.ts (13 tests):
- Step structure validation, navigation helpers, ordering
tests/unit/onboardingState.test.ts (5 tests):
- localStorage persistence, show/hide/reset lifecycle
Addresses #17
* fix(onboarding): wire wizard launch into office UI
Mount the onboarding wizard in OfficeScreen and add a settings action that can re-open it so the new-user flow is reachable and testable.
Made-with: Cursor
---------
Co-authored-by: Neo <neo@openclaw.ai>
Co-authored-by: iamlukethedev <lucas.guilherme@smartwayslfl.com>
This commit is contained in:
committed by
GitHub
parent
ac30f71db0
commit
c2cbdeec44
@@ -6,6 +6,7 @@ type SettingsPanelProps = {
|
|||||||
gatewayStatus?: string;
|
gatewayStatus?: string;
|
||||||
gatewayUrl?: string;
|
gatewayUrl?: string;
|
||||||
onGatewayDisconnect?: () => void;
|
onGatewayDisconnect?: () => void;
|
||||||
|
onOpenOnboarding?: () => void;
|
||||||
officeTitle: string;
|
officeTitle: string;
|
||||||
officeTitleLoaded: boolean;
|
officeTitleLoaded: boolean;
|
||||||
onOfficeTitleChange: (title: string) => void;
|
onOfficeTitleChange: (title: string) => void;
|
||||||
@@ -23,6 +24,7 @@ export function SettingsPanel({
|
|||||||
gatewayStatus,
|
gatewayStatus,
|
||||||
gatewayUrl,
|
gatewayUrl,
|
||||||
onGatewayDisconnect,
|
onGatewayDisconnect,
|
||||||
|
onOpenOnboarding,
|
||||||
officeTitle,
|
officeTitle,
|
||||||
officeTitleLoaded,
|
officeTitleLoaded,
|
||||||
onOfficeTitleChange,
|
onOfficeTitleChange,
|
||||||
@@ -97,6 +99,23 @@ export function SettingsPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-medium text-white">Onboarding</div>
|
||||||
|
<div className="mt-1 text-[10px] text-white/75">
|
||||||
|
Re-open the onboarding wizard to test the new-user flow.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenOnboarding?.()}
|
||||||
|
className="rounded-md border border-emerald-500/20 bg-emerald-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-emerald-100 transition-colors hover:border-emerald-400/40 hover:bg-emerald-500/15"
|
||||||
|
>
|
||||||
|
Launch wizard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="ui-settings-row mt-3 flex min-h-[72px] items-center justify-between gap-6 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
|
<div className="ui-settings-row mt-3 flex min-h-[72px] items-center justify-between gap-6 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ import { SkillsMarketplacePanel } from "@/features/office/components/panels/Skil
|
|||||||
import { useOfficeSkillsMarketplace } from "@/features/office/hooks/useOfficeSkillsMarketplace";
|
import { useOfficeSkillsMarketplace } from "@/features/office/hooks/useOfficeSkillsMarketplace";
|
||||||
import { useOfficeStandupController } from "@/features/office/hooks/useOfficeStandupController";
|
import { useOfficeStandupController } from "@/features/office/hooks/useOfficeStandupController";
|
||||||
import { useRunLog } from "@/features/office/hooks/useRunLog";
|
import { useRunLog } from "@/features/office/hooks/useRunLog";
|
||||||
|
import {
|
||||||
|
OnboardingWizard,
|
||||||
|
useOnboardingState,
|
||||||
|
} from "@/features/onboarding";
|
||||||
import { useFinalizedAssistantReplyListener } from "@/hooks/useFinalizedAssistantReplyListener";
|
import { useFinalizedAssistantReplyListener } from "@/hooks/useFinalizedAssistantReplyListener";
|
||||||
import { useStudioOfficePreference } from "@/hooks/useStudioOfficePreference";
|
import { useStudioOfficePreference } from "@/hooks/useStudioOfficePreference";
|
||||||
import { useStudioVoiceRepliesPreference } from "@/hooks/useStudioVoiceRepliesPreference";
|
import { useStudioVoiceRepliesPreference } from "@/hooks/useStudioVoiceRepliesPreference";
|
||||||
@@ -758,6 +762,9 @@ export function OfficeScreen({
|
|||||||
const [activeSidebarTab, setActiveSidebarTab] =
|
const [activeSidebarTab, setActiveSidebarTab] =
|
||||||
useState<HQSidebarTab>("inbox");
|
useState<HQSidebarTab>("inbox");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { showOnboarding, completeOnboarding, resetOnboarding } =
|
||||||
|
useOnboardingState();
|
||||||
|
const [forceShowOnboarding, setForceShowOnboarding] = useState(false);
|
||||||
const {
|
const {
|
||||||
loaded: officeTitleLoaded,
|
loaded: officeTitleLoaded,
|
||||||
title: officeTitle,
|
title: officeTitle,
|
||||||
@@ -789,6 +796,15 @@ export function OfficeScreen({
|
|||||||
voiceId: voiceRepliesPreference.voiceId,
|
voiceId: voiceRepliesPreference.voiceId,
|
||||||
speed: voiceRepliesPreference.speed,
|
speed: voiceRepliesPreference.speed,
|
||||||
});
|
});
|
||||||
|
const showOnboardingWizard = showOnboarding || forceShowOnboarding;
|
||||||
|
const handleOpenOnboarding = useCallback(() => {
|
||||||
|
resetOnboarding();
|
||||||
|
setForceShowOnboarding(true);
|
||||||
|
}, [resetOnboarding]);
|
||||||
|
const handleCompleteOnboarding = useCallback(() => {
|
||||||
|
completeOnboarding();
|
||||||
|
setForceShowOnboarding(false);
|
||||||
|
}, [completeOnboarding]);
|
||||||
|
|
||||||
const handleAvatarProfileSave = useCallback(
|
const handleAvatarProfileSave = useCallback(
|
||||||
(agentId: string, profile: AgentAvatarProfile) => {
|
(agentId: string, profile: AgentAvatarProfile) => {
|
||||||
@@ -2795,6 +2811,7 @@ export function OfficeScreen({
|
|||||||
settingsCoordinator,
|
settingsCoordinator,
|
||||||
}}
|
}}
|
||||||
onGatewayDisconnect={disconnect}
|
onGatewayDisconnect={disconnect}
|
||||||
|
onOpenOnboarding={handleOpenOnboarding}
|
||||||
feedEvents={feedEvents}
|
feedEvents={feedEvents}
|
||||||
gatewayStatus={status}
|
gatewayStatus={status}
|
||||||
runCountByAgentId={runCountByAgentId}
|
runCountByAgentId={runCountByAgentId}
|
||||||
@@ -2924,6 +2941,23 @@ export function OfficeScreen({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{showOnboardingWizard ? (
|
||||||
|
<OnboardingWizard
|
||||||
|
gatewayConnected={status === "connected"}
|
||||||
|
agentCount={state.agents.length}
|
||||||
|
gatewayUrl={gatewayUrl}
|
||||||
|
token={token}
|
||||||
|
onGatewayUrlChange={setGatewayUrl}
|
||||||
|
onTokenChange={setToken}
|
||||||
|
onConnect={() => {
|
||||||
|
void connect();
|
||||||
|
}}
|
||||||
|
onComplete={handleCompleteOnboarding}
|
||||||
|
connectionError={gatewayError}
|
||||||
|
connecting={status === "connecting"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showOpenClawConsole ? (
|
{showOpenClawConsole ? (
|
||||||
<section className="pointer-events-auto fixed bottom-3 left-3 z-30 flex w-[520px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded border border-cyan-500/25 bg-black/78 shadow-2xl backdrop-blur">
|
<section className="pointer-events-auto fixed bottom-3 left-3 z-30 flex w-[520px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded border border-cyan-500/25 bg-black/78 shadow-2xl backdrop-blur">
|
||||||
<div className="flex items-center justify-between border-b border-cyan-500/15 px-3 py-2 font-mono text-[11px] uppercase tracking-[0.18em] text-cyan-200/80">
|
<div className="flex items-center justify-between border-b border-cyan-500/15 px-3 py-2 font-mono text-[11px] uppercase tracking-[0.18em] text-cyan-200/80">
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* AgentsStep — Shows discovered agents after gateway connection.
|
||||||
|
*/
|
||||||
|
import { Bot, Users, WifiOff } from "lucide-react";
|
||||||
|
|
||||||
|
export type AgentsStepProps = {
|
||||||
|
agentCount: number;
|
||||||
|
connected: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentsStep = ({ agentCount, connected }: AgentsStepProps) => {
|
||||||
|
if (!connected) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||||
|
<WifiOff className="h-8 w-8 text-white/30" />
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
Connect to your gateway first to discover agents.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentCount === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-6">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-white/5">
|
||||||
|
<Bot className="h-6 w-6 text-white/40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-white">No agents found</p>
|
||||||
|
<p className="max-w-xs text-center text-xs text-white/55">
|
||||||
|
Your gateway is connected, but no agents are configured yet.
|
||||||
|
You can create agents from the Claw3D fleet sidebar after
|
||||||
|
completing this wizard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-white/8 bg-white/[0.02] px-4 py-3">
|
||||||
|
<p className="text-xs font-medium text-white/80">Quick start:</p>
|
||||||
|
<ol className="mt-2 space-y-1.5 text-[11px] text-white/55">
|
||||||
|
<li>1. Click the + button in the fleet sidebar</li>
|
||||||
|
<li>2. Choose a name and model for your agent</li>
|
||||||
|
<li>3. Configure skills and personality</li>
|
||||||
|
<li>4. Watch your agent appear at their desk!</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-4 py-3">
|
||||||
|
<Users className="h-5 w-5 text-emerald-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">
|
||||||
|
{agentCount} agent{agentCount !== 1 ? "s" : ""} discovered
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-white/55">
|
||||||
|
Your AI team is ready and waiting in the office.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-white/70">
|
||||||
|
What you can do with agents:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{[
|
||||||
|
{ label: "Chat", desc: "Send messages and get responses" },
|
||||||
|
{ label: "Approve", desc: "Review and approve exec commands" },
|
||||||
|
{ label: "Configure", desc: "Edit brain files and settings" },
|
||||||
|
{ label: "Monitor", desc: "Watch runtime activity in real time" },
|
||||||
|
].map(({ label, desc }) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="rounded-md border border-white/5 bg-white/[0.02] px-3 py-2"
|
||||||
|
>
|
||||||
|
<p className="text-[11px] font-semibold text-white">{label}</p>
|
||||||
|
<p className="text-[10px] text-white/45">{desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* CompleteStep — Final wizard screen before entering the office.
|
||||||
|
*/
|
||||||
|
import { Building2, Rocket } from "lucide-react";
|
||||||
|
|
||||||
|
export const CompleteStep = () => (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-5 py-4">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/15">
|
||||||
|
<Rocket className="h-7 w-7 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<p className="text-base font-semibold text-white">
|
||||||
|
Welcome to your AI office
|
||||||
|
</p>
|
||||||
|
<p className="max-w-sm text-sm text-white/60">
|
||||||
|
Your gateway is connected and your agents are ready. Step inside and
|
||||||
|
explore the 3D workspace where your AI team operates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-xs space-y-2">
|
||||||
|
<div className="flex items-center gap-2.5 rounded-lg border border-white/8 bg-white/[0.03] px-3.5 py-2.5">
|
||||||
|
<Building2 className="h-4 w-4 shrink-0 text-emerald-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-white">Explore the Office</p>
|
||||||
|
<p className="text-[10px] text-white/45">
|
||||||
|
Navigate rooms, watch agents, and interact
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-white/35">
|
||||||
|
You can always re-run onboarding from Studio settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* ConnectStep — Gateway connection form within the onboarding wizard.
|
||||||
|
*
|
||||||
|
* Reuses the same fields as GatewayConnectScreen but in a more compact
|
||||||
|
* layout suited for the wizard modal.
|
||||||
|
*/
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CheckCircle2, Eye, EyeOff, Loader2, Wifi, WifiOff } from "lucide-react";
|
||||||
|
|
||||||
|
export type ConnectStepProps = {
|
||||||
|
gatewayUrl: string;
|
||||||
|
token: string;
|
||||||
|
onGatewayUrlChange: (value: string) => void;
|
||||||
|
onTokenChange: (value: string) => void;
|
||||||
|
onConnect: () => void;
|
||||||
|
connected: boolean;
|
||||||
|
connecting: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConnectStep = ({
|
||||||
|
gatewayUrl,
|
||||||
|
token,
|
||||||
|
onGatewayUrlChange,
|
||||||
|
onTokenChange,
|
||||||
|
onConnect,
|
||||||
|
connected,
|
||||||
|
connecting,
|
||||||
|
error,
|
||||||
|
}: ConnectStepProps) => {
|
||||||
|
const [showToken, setShowToken] = useState(false);
|
||||||
|
|
||||||
|
if (connected) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-500/20">
|
||||||
|
<CheckCircle2 className="h-6 w-6 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-white">Connected!</p>
|
||||||
|
<p className="text-xs text-white/60">
|
||||||
|
Your gateway is live. Click Next to continue.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-white/8 bg-white/[0.02] px-3 py-2.5">
|
||||||
|
<WifiOff className="h-4 w-4 text-white/40" />
|
||||||
|
<p className="text-xs text-white/60">Not connected</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-[11px] font-medium text-white/80">
|
||||||
|
Gateway URL
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="h-9 rounded-md border border-white/10 bg-white/5 px-3 font-mono text-sm text-white outline-none placeholder:text-white/30 focus:border-emerald-500/50"
|
||||||
|
type="text"
|
||||||
|
value={gatewayUrl}
|
||||||
|
onChange={(e) => onGatewayUrlChange(e.target.value)}
|
||||||
|
placeholder="ws://localhost:18789 or wss://your-host"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-[11px] font-medium text-white/80">
|
||||||
|
Gateway Token
|
||||||
|
</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
className="h-9 w-full rounded-md border border-white/10 bg-white/5 px-3 pr-9 font-mono text-sm text-white outline-none placeholder:text-white/30 focus:border-emerald-500/50"
|
||||||
|
type={showToken ? "text" : "password"}
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => onTokenChange(e.target.value)}
|
||||||
|
placeholder="your-gateway-token"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-y-0 right-1 my-auto flex h-7 w-7 items-center justify-center rounded text-white/50 hover:text-white"
|
||||||
|
onClick={() => setShowToken((prev) => !prev)}
|
||||||
|
aria-label={showToken ? "Hide token" : "Show token"}
|
||||||
|
>
|
||||||
|
{showToken ? (
|
||||||
|
<EyeOff className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-md bg-emerald-600 px-4 text-xs font-semibold text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
onClick={onConnect}
|
||||||
|
disabled={connecting || !gatewayUrl.trim()}
|
||||||
|
>
|
||||||
|
{connecting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Connecting…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Wifi className="h-3.5 w-3.5" />
|
||||||
|
Connect
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="rounded-md bg-red-500/10 px-3 py-2 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-1.5 text-[11px] text-white/40">
|
||||||
|
<p>
|
||||||
|
<strong className="text-white/60">Local?</strong> Use{" "}
|
||||||
|
<code className="text-white/50">ws://localhost:18789</code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong className="text-white/60">Tailscale?</strong> Use{" "}
|
||||||
|
<code className="text-white/50">wss://your-host.ts.net</code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong className="text-white/60">SSH tunnel?</strong> Forward port
|
||||||
|
18789 first, then use localhost.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* OnboardingWizard — Step-based onboarding flow for new Claw3D users.
|
||||||
|
*
|
||||||
|
* Renders a modal overlay with step navigation, progress indicator,
|
||||||
|
* and content slots for each onboarding phase. Designed to be mounted
|
||||||
|
* at the app root and dismissed once complete or skipped.
|
||||||
|
*/
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { ArrowLeft, ArrowRight, X } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getNextStep,
|
||||||
|
getPrevStep,
|
||||||
|
getStepIndex,
|
||||||
|
ONBOARDING_STEPS,
|
||||||
|
type OnboardingStepId,
|
||||||
|
} from "@/features/onboarding/types";
|
||||||
|
import { WelcomeStep } from "@/features/onboarding/components/WelcomeStep";
|
||||||
|
import { PrerequisitesStep } from "@/features/onboarding/components/PrerequisitesStep";
|
||||||
|
import { ConnectStep } from "@/features/onboarding/components/ConnectStep";
|
||||||
|
import { AgentsStep } from "@/features/onboarding/components/AgentsStep";
|
||||||
|
import { CompleteStep } from "@/features/onboarding/components/CompleteStep";
|
||||||
|
|
||||||
|
export type OnboardingWizardProps = {
|
||||||
|
/** Whether the gateway is currently connected. */
|
||||||
|
gatewayConnected: boolean;
|
||||||
|
/** Number of agents discovered. */
|
||||||
|
agentCount: number;
|
||||||
|
/** Gateway URL (for the connect step). */
|
||||||
|
gatewayUrl: string;
|
||||||
|
/** Gateway token (for the connect step). */
|
||||||
|
token: string;
|
||||||
|
/** Callbacks for the connect step. */
|
||||||
|
onGatewayUrlChange: (value: string) => void;
|
||||||
|
onTokenChange: (value: string) => void;
|
||||||
|
onConnect: () => void;
|
||||||
|
/** Called when the user finishes or dismisses the wizard. */
|
||||||
|
onComplete: () => void;
|
||||||
|
/** Connection error message, if any. */
|
||||||
|
connectionError: string | null;
|
||||||
|
/** Whether we're currently connecting. */
|
||||||
|
connecting: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OnboardingWizard = ({
|
||||||
|
gatewayConnected,
|
||||||
|
agentCount,
|
||||||
|
gatewayUrl,
|
||||||
|
token,
|
||||||
|
onGatewayUrlChange,
|
||||||
|
onTokenChange,
|
||||||
|
onConnect,
|
||||||
|
onComplete,
|
||||||
|
connectionError,
|
||||||
|
connecting,
|
||||||
|
}: OnboardingWizardProps) => {
|
||||||
|
const [currentStep, setCurrentStep] = useState<OnboardingStepId>("welcome");
|
||||||
|
const [completedSteps, setCompletedSteps] = useState<Set<OnboardingStepId>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const stepIndex = useMemo(() => getStepIndex(currentStep), [currentStep]);
|
||||||
|
const currentStepDef = ONBOARDING_STEPS[stepIndex];
|
||||||
|
const totalSteps = ONBOARDING_STEPS.length;
|
||||||
|
|
||||||
|
const markComplete = useCallback(
|
||||||
|
(stepId: OnboardingStepId) => {
|
||||||
|
setCompletedSteps((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(stepId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goNext = useCallback(() => {
|
||||||
|
markComplete(currentStep);
|
||||||
|
const next = getNextStep(currentStep);
|
||||||
|
if (next) {
|
||||||
|
setCurrentStep(next);
|
||||||
|
} else {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
}, [currentStep, markComplete, onComplete]);
|
||||||
|
|
||||||
|
const goPrev = useCallback(() => {
|
||||||
|
const prev = getPrevStep(currentStep);
|
||||||
|
if (prev) setCurrentStep(prev);
|
||||||
|
}, [currentStep]);
|
||||||
|
|
||||||
|
const canGoNext = useMemo(() => {
|
||||||
|
// Connect step requires gateway connection before proceeding
|
||||||
|
if (currentStep === "connect" && !gatewayConnected) return false;
|
||||||
|
return true;
|
||||||
|
}, [currentStep, gatewayConnected]);
|
||||||
|
|
||||||
|
const renderStepContent = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case "welcome":
|
||||||
|
return <WelcomeStep />;
|
||||||
|
case "prerequisites":
|
||||||
|
return <PrerequisitesStep />;
|
||||||
|
case "connect":
|
||||||
|
return (
|
||||||
|
<ConnectStep
|
||||||
|
gatewayUrl={gatewayUrl}
|
||||||
|
token={token}
|
||||||
|
onGatewayUrlChange={onGatewayUrlChange}
|
||||||
|
onTokenChange={onTokenChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
connected={gatewayConnected}
|
||||||
|
connecting={connecting}
|
||||||
|
error={connectionError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "agents":
|
||||||
|
return <AgentsStep agentCount={agentCount} connected={gatewayConnected} />;
|
||||||
|
case "complete":
|
||||||
|
return <CompleteStep />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100000] flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||||
|
<div className="relative mx-4 flex w-full max-w-[560px] flex-col overflow-hidden rounded-xl border border-white/10 bg-[#0d1117] shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-white/10 px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">
|
||||||
|
{currentStepDef?.title ?? "Onboarding"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-white/60">
|
||||||
|
{currentStepDef?.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md text-white/50 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
onClick={onComplete}
|
||||||
|
aria-label="Close onboarding"
|
||||||
|
title="Skip onboarding"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="flex gap-1.5 px-6 pt-4">
|
||||||
|
{ONBOARDING_STEPS.map((step, idx) => (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className={`h-1 flex-1 rounded-full transition-colors ${
|
||||||
|
idx <= stepIndex
|
||||||
|
? "bg-emerald-500"
|
||||||
|
: completedSteps.has(step.id)
|
||||||
|
? "bg-emerald-500/40"
|
||||||
|
: "bg-white/10"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<div className="min-h-[280px] px-6 py-5">{renderStepContent()}</div>
|
||||||
|
|
||||||
|
{/* Footer navigation */}
|
||||||
|
<div className="flex items-center justify-between border-t border-white/10 px-6 py-4">
|
||||||
|
<div>
|
||||||
|
{stepIndex > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-xs font-medium text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
onClick={goPrev}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-white/40">
|
||||||
|
{stepIndex + 1} / {totalSteps}
|
||||||
|
</span>
|
||||||
|
{currentStep === "complete" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-emerald-600 px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-emerald-500"
|
||||||
|
onClick={onComplete}
|
||||||
|
>
|
||||||
|
Enter Office
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-white/10 px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
onClick={goNext}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
>
|
||||||
|
{currentStep === "connect" && !gatewayConnected
|
||||||
|
? "Connect first"
|
||||||
|
: "Next"}
|
||||||
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* PrerequisitesStep — Tells users what they need before connecting.
|
||||||
|
*/
|
||||||
|
import { CheckCircle2, ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
|
const prerequisites = [
|
||||||
|
{
|
||||||
|
label: "OpenClaw installed",
|
||||||
|
detail: "Install via npm, pnpm, or from source",
|
||||||
|
link: "https://docs.openclaw.ai",
|
||||||
|
linkLabel: "Installation docs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Gateway running",
|
||||||
|
detail: "Start with: openclaw gateway start",
|
||||||
|
command: "openclaw gateway start",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Gateway URL and token",
|
||||||
|
detail: "Found in ~/.openclaw/openclaw.json or your remote setup",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Node.js 20+",
|
||||||
|
detail: "Required for running Claw3D locally",
|
||||||
|
link: "https://nodejs.org",
|
||||||
|
linkLabel: "Download Node.js",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const PrerequisitesStep = () => (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-white/70">
|
||||||
|
Make sure you have these ready before connecting. If you already have
|
||||||
|
OpenClaw running, you can skip this step.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{prerequisites.map(({ label, detail, ...rest }) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="flex gap-3 rounded-lg border border-white/8 bg-white/[0.02] px-3.5 py-3"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-white/30" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-semibold text-white">{label}</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-white/55">{detail}</p>
|
||||||
|
{"command" in rest ? (
|
||||||
|
<code className="mt-1.5 block rounded bg-black/40 px-2 py-1 font-mono text-[11px] text-emerald-400">
|
||||||
|
{rest.command}
|
||||||
|
</code>
|
||||||
|
) : null}
|
||||||
|
{"link" in rest && rest.link ? (
|
||||||
|
<a
|
||||||
|
href={rest.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-1.5 inline-flex items-center gap-1 text-[11px] text-emerald-400 hover:text-emerald-300"
|
||||||
|
>
|
||||||
|
{rest.linkLabel ?? "Learn more"}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-white/40">
|
||||||
|
Need help? Check{" "}
|
||||||
|
<a
|
||||||
|
href="https://docs.openclaw.ai"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-emerald-400/70 hover:text-emerald-300"
|
||||||
|
>
|
||||||
|
docs.openclaw.ai
|
||||||
|
</a>{" "}
|
||||||
|
or{" "}
|
||||||
|
<a
|
||||||
|
href="https://discord.com/invite/clawd"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-emerald-400/70 hover:text-emerald-300"
|
||||||
|
>
|
||||||
|
join Discord
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* WelcomeStep — First onboarding screen introducing Claw3D.
|
||||||
|
*/
|
||||||
|
import { Building2, Eye, MessageSquare, Users } from "lucide-react";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: Eye,
|
||||||
|
title: "Watch agents work",
|
||||||
|
description: "See your AI agents in real time in a shared 3D office",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
title: "Manage your fleet",
|
||||||
|
description: "Create, configure, and monitor agents from one place",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: MessageSquare,
|
||||||
|
title: "Chat and approve",
|
||||||
|
description: "Talk to agents, approve exec commands, review their work",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Building2,
|
||||||
|
title: "Build your office",
|
||||||
|
description: "Customize rooms, desks, and the whole office layout",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const WelcomeStep = () => (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm leading-relaxed text-white/80">
|
||||||
|
Claw3D turns your AI automation into a{" "}
|
||||||
|
<span className="font-medium text-white">visual workplace</span> — an
|
||||||
|
office where your OpenClaw agents collaborate, code, test, and execute
|
||||||
|
tasks in a shared 3D environment.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
This wizard will help you connect to your OpenClaw gateway and get
|
||||||
|
started in about two minutes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{features.map(({ icon: Icon, title, description }) => (
|
||||||
|
<div
|
||||||
|
key={title}
|
||||||
|
className="rounded-lg border border-white/8 bg-white/[0.03] px-3.5 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="h-4 w-4 shrink-0 text-emerald-400" />
|
||||||
|
<span className="text-xs font-semibold text-white">{title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[11px] leading-snug text-white/55">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Onboarding feature module.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { OnboardingWizard, useOnboardingState } from "@/features/onboarding";
|
||||||
|
*/
|
||||||
|
export { OnboardingWizard } from "@/features/onboarding/components/OnboardingWizard";
|
||||||
|
export type { OnboardingWizardProps } from "@/features/onboarding/components/OnboardingWizard";
|
||||||
|
export { useOnboardingState } from "@/features/onboarding/useOnboardingState";
|
||||||
|
export type { OnboardingStateReturn } from "@/features/onboarding/useOnboardingState";
|
||||||
|
export {
|
||||||
|
ONBOARDING_STEPS,
|
||||||
|
type OnboardingStepId,
|
||||||
|
type OnboardingStep,
|
||||||
|
} from "@/features/onboarding/types";
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Onboarding wizard types.
|
||||||
|
*
|
||||||
|
* The wizard is step-based and extensible: new steps can be added by
|
||||||
|
* extending `OnboardingStepId` and registering a component in the
|
||||||
|
* step registry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type OnboardingStepId =
|
||||||
|
| "welcome"
|
||||||
|
| "prerequisites"
|
||||||
|
| "connect"
|
||||||
|
| "agents"
|
||||||
|
| "complete";
|
||||||
|
|
||||||
|
export type OnboardingStep = {
|
||||||
|
id: OnboardingStepId;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
/** Whether the step can be skipped. */
|
||||||
|
skippable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnboardingState = {
|
||||||
|
currentStep: OnboardingStepId;
|
||||||
|
completedSteps: Set<OnboardingStepId>;
|
||||||
|
/** Whether the user has dismissed the wizard entirely. */
|
||||||
|
dismissed: boolean;
|
||||||
|
/** Gateway connection state passed from the parent. */
|
||||||
|
gatewayConnected: boolean;
|
||||||
|
/** Number of agents discovered after connection. */
|
||||||
|
agentCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||||
|
{
|
||||||
|
id: "welcome",
|
||||||
|
title: "Welcome to Claw3D",
|
||||||
|
description: "Your AI office in 3D",
|
||||||
|
skippable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prerequisites",
|
||||||
|
title: "Before You Start",
|
||||||
|
description: "What you'll need",
|
||||||
|
skippable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "connect",
|
||||||
|
title: "Connect Your Gateway",
|
||||||
|
description: "Link to your OpenClaw instance",
|
||||||
|
skippable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "agents",
|
||||||
|
title: "Your Agents",
|
||||||
|
description: "Meet your AI team",
|
||||||
|
skippable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "complete",
|
||||||
|
title: "You're All Set",
|
||||||
|
description: "Start exploring",
|
||||||
|
skippable: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getStepIndex = (stepId: OnboardingStepId): number =>
|
||||||
|
ONBOARDING_STEPS.findIndex((s) => s.id === stepId);
|
||||||
|
|
||||||
|
export const getNextStep = (
|
||||||
|
currentId: OnboardingStepId,
|
||||||
|
): OnboardingStepId | null => {
|
||||||
|
const idx = getStepIndex(currentId);
|
||||||
|
if (idx < 0 || idx >= ONBOARDING_STEPS.length - 1) return null;
|
||||||
|
return ONBOARDING_STEPS[idx + 1].id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPrevStep = (
|
||||||
|
currentId: OnboardingStepId,
|
||||||
|
): OnboardingStepId | null => {
|
||||||
|
const idx = getStepIndex(currentId);
|
||||||
|
if (idx <= 0) return null;
|
||||||
|
return ONBOARDING_STEPS[idx - 1].id;
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* useOnboardingState — Tracks whether onboarding has been completed.
|
||||||
|
*
|
||||||
|
* Uses localStorage so the wizard only shows once per browser.
|
||||||
|
* The key is scoped to the Claw3D app to avoid collisions.
|
||||||
|
*/
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "claw3d:onboarding:completed";
|
||||||
|
|
||||||
|
const readCompleted = (): boolean => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(STORAGE_KEY) === "true";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeCompleted = (value: boolean): void => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
if (value) {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, "true");
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Storage might be unavailable in some environments.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnboardingStateReturn = {
|
||||||
|
/** Whether the wizard should be shown. */
|
||||||
|
showOnboarding: boolean;
|
||||||
|
/** Mark onboarding as complete (hides the wizard). */
|
||||||
|
completeOnboarding: () => void;
|
||||||
|
/** Reset onboarding (shows the wizard again). */
|
||||||
|
resetOnboarding: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOnboardingState = (): OnboardingStateReturn => {
|
||||||
|
const [completed, setCompleted] = useState(readCompleted);
|
||||||
|
|
||||||
|
const completeOnboarding = useCallback(() => {
|
||||||
|
setCompleted(true);
|
||||||
|
writeCompleted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetOnboarding = useCallback(() => {
|
||||||
|
setCompleted(false);
|
||||||
|
writeCompleted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
showOnboarding: !completed,
|
||||||
|
completeOnboarding,
|
||||||
|
resetOnboarding,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1892,6 +1892,7 @@ export function RetroOffice3D({
|
|||||||
onVoiceRepliesSpeedChange,
|
onVoiceRepliesSpeedChange,
|
||||||
onVoiceRepliesPreview,
|
onVoiceRepliesPreview,
|
||||||
onGatewayDisconnect,
|
onGatewayDisconnect,
|
||||||
|
onOpenOnboarding,
|
||||||
atmAnalytics = null,
|
atmAnalytics = null,
|
||||||
feedEvents = [],
|
feedEvents = [],
|
||||||
gatewayStatus = "disconnected",
|
gatewayStatus = "disconnected",
|
||||||
@@ -1949,6 +1950,7 @@ export function RetroOffice3D({
|
|||||||
onVoiceRepliesSpeedChange?: (speed: number) => void;
|
onVoiceRepliesSpeedChange?: (speed: number) => void;
|
||||||
onVoiceRepliesPreview?: (voiceId: string | null, voiceName: string) => void;
|
onVoiceRepliesPreview?: (voiceId: string | null, voiceName: string) => void;
|
||||||
onGatewayDisconnect?: () => void;
|
onGatewayDisconnect?: () => void;
|
||||||
|
onOpenOnboarding?: () => void;
|
||||||
atmAnalytics?: OfficeUsageAnalyticsParams | null;
|
atmAnalytics?: OfficeUsageAnalyticsParams | null;
|
||||||
feedEvents?: {
|
feedEvents?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -6049,6 +6051,10 @@ export function RetroOffice3D({
|
|||||||
onGatewayDisconnect?.();
|
onGatewayDisconnect?.();
|
||||||
setSettingsModalOpen(false);
|
setSettingsModalOpen(false);
|
||||||
}}
|
}}
|
||||||
|
onOpenOnboarding={() => {
|
||||||
|
onOpenOnboarding?.();
|
||||||
|
setSettingsModalOpen(false);
|
||||||
|
}}
|
||||||
officeTitle={officeTitle}
|
officeTitle={officeTitle}
|
||||||
officeTitleLoaded={officeTitleLoaded}
|
officeTitleLoaded={officeTitleLoaded}
|
||||||
onOfficeTitleChange={(title) => onOfficeTitleChange?.(title)}
|
onOfficeTitleChange={(title) => onOfficeTitleChange?.(title)}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { useOnboardingState } from "@/features/onboarding/useOnboardingState";
|
||||||
|
|
||||||
|
describe("useOnboardingState", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up localStorage between tests
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem("claw3d:onboarding:completed");
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows onboarding by default when localStorage is empty", () => {
|
||||||
|
const { result } = renderHook(() => useOnboardingState());
|
||||||
|
expect(result.current.showOnboarding).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides onboarding after completeOnboarding is called", () => {
|
||||||
|
const { result } = renderHook(() => useOnboardingState());
|
||||||
|
expect(result.current.showOnboarding).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.completeOnboarding();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showOnboarding).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists completion to localStorage", () => {
|
||||||
|
const { result } = renderHook(() => useOnboardingState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.completeOnboarding();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.localStorage.getItem("claw3d:onboarding:completed")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads completion state from localStorage on mount", () => {
|
||||||
|
window.localStorage.setItem("claw3d:onboarding:completed", "true");
|
||||||
|
const { result } = renderHook(() => useOnboardingState());
|
||||||
|
expect(result.current.showOnboarding).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets onboarding when resetOnboarding is called", () => {
|
||||||
|
const { result } = renderHook(() => useOnboardingState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.completeOnboarding();
|
||||||
|
});
|
||||||
|
expect(result.current.showOnboarding).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.resetOnboarding();
|
||||||
|
});
|
||||||
|
expect(result.current.showOnboarding).toBe(true);
|
||||||
|
expect(window.localStorage.getItem("claw3d:onboarding:completed")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getNextStep,
|
||||||
|
getPrevStep,
|
||||||
|
getStepIndex,
|
||||||
|
ONBOARDING_STEPS,
|
||||||
|
} from "@/features/onboarding/types";
|
||||||
|
|
||||||
|
describe("ONBOARDING_STEPS", () => {
|
||||||
|
it("has at least 3 steps", () => {
|
||||||
|
expect(ONBOARDING_STEPS.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with welcome and ends with complete", () => {
|
||||||
|
expect(ONBOARDING_STEPS[0].id).toBe("welcome");
|
||||||
|
expect(ONBOARDING_STEPS[ONBOARDING_STEPS.length - 1].id).toBe("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has unique step IDs", () => {
|
||||||
|
const ids = ONBOARDING_STEPS.map((s) => s.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every step has a non-empty title and description", () => {
|
||||||
|
for (const step of ONBOARDING_STEPS) {
|
||||||
|
expect(step.title.length).toBeGreaterThan(0);
|
||||||
|
expect(step.description.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getStepIndex", () => {
|
||||||
|
it("returns 0 for welcome", () => {
|
||||||
|
expect(getStepIndex("welcome")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns last index for complete", () => {
|
||||||
|
expect(getStepIndex("complete")).toBe(ONBOARDING_STEPS.length - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct index for connect", () => {
|
||||||
|
const idx = ONBOARDING_STEPS.findIndex((s) => s.id === "connect");
|
||||||
|
expect(getStepIndex("connect")).toBe(idx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getNextStep", () => {
|
||||||
|
it("returns prerequisites after welcome", () => {
|
||||||
|
expect(getNextStep("welcome")).toBe("prerequisites");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null after complete", () => {
|
||||||
|
expect(getNextStep("complete")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances through all steps in order", () => {
|
||||||
|
let current = ONBOARDING_STEPS[0].id;
|
||||||
|
const visited: string[] = [current];
|
||||||
|
let next = getNextStep(current);
|
||||||
|
while (next) {
|
||||||
|
visited.push(next);
|
||||||
|
current = next;
|
||||||
|
next = getNextStep(current);
|
||||||
|
}
|
||||||
|
expect(visited).toEqual(ONBOARDING_STEPS.map((s) => s.id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPrevStep", () => {
|
||||||
|
it("returns null for welcome", () => {
|
||||||
|
expect(getPrevStep("welcome")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns prerequisites for connect", () => {
|
||||||
|
expect(getPrevStep("connect")).toBe("prerequisites");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates backward through all steps", () => {
|
||||||
|
let current = ONBOARDING_STEPS[ONBOARDING_STEPS.length - 1].id;
|
||||||
|
const visited: string[] = [current];
|
||||||
|
let prev = getPrevStep(current);
|
||||||
|
while (prev) {
|
||||||
|
visited.push(prev);
|
||||||
|
current = prev;
|
||||||
|
prev = getPrevStep(current);
|
||||||
|
}
|
||||||
|
visited.reverse();
|
||||||
|
expect(visited).toEqual(ONBOARDING_STEPS.map((s) => s.id));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user