evOo-local/apps/web/src/components/onboarding/OnboardingVoiceStep.tsx
Cursor Agent 83ec966a67
Harden Heute route and German voice onboarding E2E
Rebased onto main with Expo SDK 57 typecheck fixes. Voice options expose
stable test ids; Shell keeps /app/heute across bootstrap and redirects
legacy /app/heute bot URLs. Heute coach E2E asserts URL after reload.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 17:01:08 +00:00

134 lines
4.6 KiB
TypeScript

import { Trans } from "@lingui/react/macro";
import type { VoiceInfo } from "@rakazo/contracts";
import { Button } from "@rakazo/ui-web";
import { useEffect, useRef, useState } from "react";
import {
defaultOnboardingVoiceId,
offlineOnboardingVoices,
onboardingVoicesFromList,
} from "../../lib/onboarding-voices";
import { rpc } from "../../lib/rpc";
import type { UiLocale } from "../../lib/ui-locale";
/** Must match packages/adapters KokoroVoiceProvider placeholder. */
export const KOKORO_LOCAL_TOKEN = "local-kokoro";
type OnboardingVoiceStepProps = {
uiLocale: UiLocale;
selectedVoiceId: string;
onSelectedVoiceIdChange: (voiceId: string) => void;
onBack: () => void;
onSkip: () => void;
onContinue: () => void;
};
export function OnboardingVoiceStep({
uiLocale,
selectedVoiceId,
onSelectedVoiceIdChange,
onBack,
onSkip,
onContinue,
}: OnboardingVoiceStepProps) {
const [voiceOptions, setVoiceOptions] = useState<VoiceInfo[]>([]);
const [voiceLoading, setVoiceLoading] = useState(true);
const previewTask = useRef<Promise<void>>(Promise.resolve());
useEffect(() => {
let cancelled = false;
setVoiceLoading(true);
const task = (async () => {
try {
await rpc.voice.connect({ provider: "kokoro", apiKey: KOKORO_LOCAL_TOKEN });
const listed = await rpc.voice.voices({ provider: "kokoro" });
if (cancelled) return;
const ordered = onboardingVoicesFromList(listed, uiLocale);
setVoiceOptions(ordered);
const nextId = defaultOnboardingVoiceId(uiLocale, ordered);
if (nextId) onSelectedVoiceIdChange(nextId);
} catch {
if (!cancelled) {
const fallback = offlineOnboardingVoices(uiLocale);
setVoiceOptions(fallback);
const nextId = defaultOnboardingVoiceId(uiLocale, fallback);
if (nextId) onSelectedVoiceIdChange(nextId);
}
} finally {
if (!cancelled) setVoiceLoading(false);
}
})();
previewTask.current = task.then(
() => undefined,
() => undefined,
);
return () => {
cancelled = true;
};
}, [onSelectedVoiceIdChange, uiLocale]);
async function skipVoice() {
await previewTask.current;
// Listing voices persists a local credential. Skip must drop it so Voice
// still opens settings instead of a call the user never chose.
await rpc.voice.disconnect({ provider: "kokoro" }).catch(() => undefined);
onSkip();
}
return (
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
<Trans>Pick a voice</Trans>
</h2>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>How should your Oo sound when reading replies aloud?</Trans>
</p>
{voiceLoading ? (
<p className="mt-6 text-sm text-muted-foreground">
<Trans>Loading voices…</Trans>
</p>
) : voiceOptions.length > 0 ? (
<div className="mt-6 grid grid-cols-2 gap-2">
{voiceOptions.map((voice) => (
<button
key={voice.id}
type="button"
data-testid={`onboarding-voice-${voice.id}`}
onClick={() => onSelectedVoiceIdChange(voice.id)}
aria-pressed={selectedVoiceId === voice.id}
className={`rounded-xl border-2 px-3 py-3 text-left text-sm font-medium transition-all ${
selectedVoiceId === voice.id
? "border-primary bg-primary/10 text-foreground shadow-sm"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
}`}
>
<span className="block text-foreground">{voice.label}</span>
{voice.description ? (
<span className="mt-0.5 block text-xs text-muted-foreground">
{voice.description}
</span>
) : null}
</button>
))}
</div>
) : (
<p className="mt-6 text-sm text-muted-foreground">
<Trans>Voice is not available yet. You can turn it on later in settings.</Trans>
</p>
)}
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={onBack}>
<Trans>Back</Trans>
</Button>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => void skipVoice()}>
<Trans>Skip</Trans>
</Button>
<Button disabled={!selectedVoiceId} onClick={onContinue}>
<Trans>Continue</Trans>
</Button>
</div>
</div>
</div>
);
}