import { Trans, useLingui } from "@lingui/react/macro"; import type { IntegrationSetupState, MainConversationRef } from "@rakazo/contracts"; import { Button, Input } from "@rakazo/ui-web"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { IntegrationSetup } from "../components/integrations/IntegrationSetup"; import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell"; import { type OnboardingEmojiConfig, OnboardingOoDesigner, } from "../components/onboarding/OnboardingOoDesigner"; import { OnboardingScanProgress } from "../components/onboarding/OnboardingScanProgress"; 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 { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories"; import { onboardingProfileUpdate } from "../lib/onboarding-profile"; import { rpc, selectSpace } from "../lib/rpc"; import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale"; import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe"; 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]; if (!preset) { return { color: "#8B5CF6", shapeIndex: 0, accessory: "none", pose: "idle", avatarValue: "#8B5CF6::shape_0", }; } return { color: preset.color, shapeIndex: preset.shapeIndex, accessory: "none", pose: preset.pose, avatarValue: lookPresetToAvatarValue(preset), }; } interface UserPreferences { name: string; timezone: string; hobbies: string[]; workStyle: string; communication: string; } const HOBBY_OPTIONS = [ { key: "coding", label: "Coding" }, { key: "design", label: "Design" }, { key: "writing", label: "Writing" }, { key: "music", label: "Music" }, { key: "gaming", label: "Gaming" }, { key: "reading", label: "Reading" }, { key: "sports", label: "Sports" }, { key: "cooking", label: "Cooking" }, { key: "tech", label: "Tech & Gadgets" }, { key: "none", label: Nothing specific }, ]; const WORK_STYLE_OPTIONS = [ { key: "structured", label: Structured & planned }, { key: "flexible", label: Flexible & spontaneous }, { key: "deep-dive", label: Deep-dive focused }, { key: "quick", label: Quick & pragmatic }, ]; const COMMUNICATION_OPTIONS = [ { key: "direct", label: Direct & concise }, { key: "detailed", label: Detailed & explanatory }, { key: "balanced", label: Balanced }, { key: "casual", label: Casual & friendly }, ]; function defaultPreferences(): UserPreferences { return { name: "", timezone: localTimezone(), hobbies: [], workStyle: "", communication: "", }; } export function OnboardingPage() { const { t } = useLingui(); const navigate = useNavigate(); const fieldId = useId(); const [step, setStep] = useState< "loading" | "emoji" | "preferences" | "language" | "voice" | "integrations" | "scan" | "bot" >("emoji"); 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 [_helpVibe, setHelpVibe] = useState(null); const [scanBotId, setScanBotId] = useState(null); const [emojiConfig, setEmojiConfig] = useState(defaultEmojiConfig); const [prefs, setPrefs] = useState(defaultPreferences()); const [currentLocale, setCurrentLocale] = useState(() => { try { return localStorage.getItem("rakazo.uiLocale") ?? "en"; } catch { return "en"; } }); const [languageChoice, setLanguageChoice] = useState(null); 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); setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me))); // Get bot ID for scan phase try { const botList = await rpc.bots.list(); const mainBot = botList.find((b) => b.kind === "main"); if (mainBot && !cancelled) setScanBotId(mainBot.id); } catch { /* best effort */ } 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]); function toggleHobby(key: string) { const hobbies = prefs.hobbies.includes(key) ? prefs.hobbies.filter((h) => h !== key) : [...prefs.hobbies, key]; setPrefs({ ...prefs, hobbies }); } const discoveryPhase: OnboardingDiscoveryPhase = step === "loading" ? "loading" : step === "emoji" || step === "preferences" || 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(); await rpc.bots.update({ botId: bot.id, color: emojiConfig.avatarValue }).catch(() => null); setCrewReady(true); await rpc.profile .update( onboardingProfileUpdate({ answers: prefs, accountName: bootstrapMe?.name, 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); const harvested = await persistUserProfileHarvest( rpc, collectLocalProfileSignals(me ?? undefined), ).catch(() => null); if (harvested) setHelpVibe(harvested); setProfilePersisted(true); for (const serverId of integrationServers) { await rpc.mcp.assignments.approve({ botId: bot.id, serverId }); } const started = await rpc.onboarding .start({ botId: bot.id }) .then(() => true) .catch(() => false); if (started) { await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined); } const main = await rpc.spaces .list() .then((navigation) => navigation.main) .catch(() => null); 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("preferences")} /> ) : null} {step === "preferences" ? (

Tell us about yourself

A few quick preferences so your Oo can be better tailored to you.

setPrefs({ ...prefs, name: e.target.value })} placeholder={t`Your name`} className="mt-2" />
setPrefs({ ...prefs, timezone: e.target.value })} placeholder={t`Auto-detected or enter manually`} className="mt-2" />
Hobbies & interests

Select all that apply

{HOBBY_OPTIONS.map((hobby) => ( ))}
Work style

How do you like to work?

{WORK_STYLE_OPTIONS.map((ws) => ( ))}
Communication style

How should your Oo talk to you?

{COMMUNICATION_OPTIONS.map((comm) => ( ))}
) : 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")} botId={scanBotId ?? undefined} /> ) : null} {step === "bot" && error ? (

{error}

) : null}
); }