import { Trans, useLingui } from "@lingui/react/macro"; import type { IntegrationSetupState, MainConversationRef } from "@rakazo/contracts"; import { Button } from "@rakazo/ui-web"; import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { IntegrationSetup } from "../components/integrations/IntegrationSetup"; import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell"; import { DEFAULT_FIRST_OO_NAME, OnboardingNameStep, } from "../components/onboarding/OnboardingNameStep"; import { type OnboardingEmojiConfig, OnboardingOoDesigner, } from "../components/onboarding/OnboardingOoDesigner"; import { OnboardingScanStep } from "../components/onboarding/OnboardingScanStep"; import { KOKORO_LOCAL_TOKEN, OnboardingVoiceStep, } from "../components/onboarding/OnboardingVoiceStep"; import { localTimezone } from "../lib/local-timezone"; import { computeDiscoveryItems, countConfiguredIntegrations, type OnboardingDiscoveryPhase, } from "../lib/onboarding-discovery"; import { clearOnboardingDraft, persistOnboardingDraft, readOnboardingDraft, } from "../lib/onboarding-draft"; import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories"; import { onboardingProfileUpdate } from "../lib/onboarding-profile"; import { rpc, selectSpace } from "../lib/rpc"; import { persistUiLocale, resolveUiLocale, UI_LOCALE_LABELS, type UiLocale, } from "../lib/ui-locale"; import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest"; /** Selects the main conversation's space and returns its route. */ function enterMainConversation(main: MainConversationRef): string { selectSpace(main.spaceId); return `/app/g/${main.groupId}`; } function defaultEmojiConfig(): OnboardingEmojiConfig { const preset = findLookCategory("calm").looks[0]; return { pose: preset?.pose ?? "idle", avatarValue: preset ? lookPresetToAvatarValue(preset) : "oo1:c=8B5CF6", }; } export function OnboardingPage() { const { t } = useLingui(); const navigate = useNavigate(); const [step, setStep] = useState< "loading" | "emoji" | "name" | "language" | "voice" | "integrations" | "scan" | "bot" >("emoji"); const [ooName, setOoName] = useState(DEFAULT_FIRST_OO_NAME); const [selectedVoiceId, setSelectedVoiceId] = useState(""); const [voiceSkipped, setVoiceSkipped] = useState(false); const [integrationSetup, setIntegrationSetup] = useState(null); const needsIntegrationSetup = integrationSetup?.needsSetup ?? false; const [integrationServers, setIntegrationServers] = useState([]); const [error, setError] = useState(null); const [bootstrapMe, setBootstrapMe] = useState> | null>(null); const [profilePersisted, setProfilePersisted] = useState(false); const [crewReady, setCrewReady] = useState(false); const [emojiConfig, setEmojiConfig] = useState(defaultEmojiConfig); const [currentLocale, setCurrentLocale] = useState(() => resolveUiLocale()); const [languageChoice, setLanguageChoice] = useState(null); const activeUiLocale = languageChoice ?? currentLocale; const draftRestoredRef = useRef(false); useEffect(() => { if (draftRestoredRef.current) return; draftRestoredRef.current = true; const draft = readOnboardingDraft(); if (!draft || draft.step === "bot") return; setStep(draft.step); setEmojiConfig(draft.emojiConfig); setOoName(draft.ooName); setLanguageChoice(draft.languageChoice); setCurrentLocale(draft.currentLocale); setSelectedVoiceId(draft.selectedVoiceId); setVoiceSkipped(draft.voiceSkipped); }, []); useEffect(() => { if (step === "loading" || step === "bot") return; persistOnboardingDraft({ step, emojiConfig, ooName, languageChoice, currentLocale, selectedVoiceId, voiceSkipped, }); }, [step, emojiConfig, ooName, languageChoice, currentLocale, selectedVoiceId, voiceSkipped]); useEffect(() => { let cancelled = false; const bootstrap = Promise.race([ Promise.all([rpc.me(), rpc.integrationSetup.get().catch(() => null)]), new Promise((_, reject) => { setTimeout(() => reject(new Error("onboarding bootstrap timeout")), 15_000); }), ]); void bootstrap .then(async ([me, integrations]) => { if (cancelled) return; setBootstrapMe(me); setIntegrationSetup(integrations); // The first step renders before this resolves; never pull a user back who moved on. if (!me.needsModel && integrations?.needsSetup) { setStep((current) => (current === "emoji" ? "integrations" : current)); } // Onboarding runs once: a user with a main bot, or a space that already // has a bot, does not need the designer again. Resolve this after the // first step renders so a slow spaces list cannot leave E2E on loading. try { const navigation = await rpc.spaces.list(); if (cancelled) return; const existing = navigation.current.bots[0]; if (navigation.main && !me.needsModel) { navigate(enterMainConversation(navigation.main), { replace: true }); } else if (existing && !me.needsModel) { navigate(`/app/${existing.id}`, { replace: true }); } } catch { // Best effort; fresh signups stay on onboarding. } }) .catch(() => { if (!cancelled) setStep((current) => (current === "loading" ? "emoji" : current)); }); return () => { cancelled = true; }; }, [navigate]); const discoveryPhase: OnboardingDiscoveryPhase = step === "loading" ? "loading" : step === "emoji" || step === "name" || step === "language" || step === "voice" ? "emoji" : step === "integrations" ? "integrations" : "assembly"; const discoveryItems = useMemo( () => computeDiscoveryItems({ phase: discoveryPhase, me: bootstrapMe, integrationSetup, connectedIntegrationCount: Math.max( integrationServers.length, countConfiguredIntegrations(integrationSetup), ), profilePersisted, crewReady, }), [ bootstrapMe, crewReady, discoveryPhase, integrationServers.length, integrationSetup, profilePersisted, ], ); async function createFirstBot() { if (createStartedRef.current) return; createStartedRef.current = true; setError(null); try { const bot = await rpc.bots.ensureMain(); const trimmedName = ooName.trim() || DEFAULT_FIRST_OO_NAME; await rpc.bots .update({ botId: bot.id, color: emojiConfig.avatarValue, name: trimmedName }) .catch(() => null); setCrewReady(true); await rpc.profile .update( onboardingProfileUpdate({ browserTimeZone: localTimezone(), locale: typeof navigator !== "undefined" ? navigator.language : undefined, language: languageChoice, }), ) .catch(() => null); if (selectedVoiceId && !voiceSkipped) { await rpc.voice .connect({ provider: "kokoro", apiKey: KOKORO_LOCAL_TOKEN, voiceId: selectedVoiceId }) .catch(() => null); await rpc.voice .setVoice({ voiceId: selectedVoiceId, provider: "kokoro" }) .catch(() => null); await rpc.bots .update({ botId: bot.id, voiceId: selectedVoiceId, autoSpeak: true }) .catch(() => null); } const me = await rpc.me().catch(() => null); if (me) setBootstrapMe(me); await persistUserProfileHarvest(rpc, collectLocalProfileSignals(me ?? undefined)).catch( () => null, ); setProfilePersisted(true); for (const serverId of integrationServers) { await rpc.mcp.assignments.approve({ botId: bot.id, serverId }); } // The main bot asks the rest in the main conversation. await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined); const main = await rpc.spaces .list() .then((navigation) => navigation.main) .catch(() => null); clearOnboardingDraft(); navigate(main ? enterMainConversation(main) : `/app/${bot.id}`); } catch (err) { createStartedRef.current = false; setError(err instanceof Error ? err.message : t`Could not create your bot`); } } const createStartedRef = useRef(false); useEffect(() => { if (step !== "bot") return; void createFirstBot(); }, [step]); function goAfterVoice(skipped: boolean) { setVoiceSkipped(skipped); setStep(needsIntegrationSetup ? "integrations" : "scan"); } return (
{error && step !== "bot" ?

{error}

: null} {step === "emoji" ? ( setStep("name")} /> ) : null} {step === "name" ? ( setStep("emoji")} onContinue={() => setStep("language")} /> ) : null} {step === "language" ? (

Choose your language

Your Oo will communicate in this language. You can change it later.

{Object.entries(UI_LOCALE_LABELS).map(([code, label]) => ( ))}
) : null} {step === "voice" ? ( setStep("language")} onSkip={() => goAfterVoice(true)} onContinue={() => goAfterVoice(false)} /> ) : null} {step === "integrations" ? (

Connect tools when you''re ready

setStep("scan")} onServerConnected={(id) => setIntegrationServers((current) => [...new Set([...current, id])]) } />
) : null} {step === "scan" ? setStep("bot")} /> : null} {step === "bot" && error ? (

{error}

) : null}
); }