mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
feat(i18n): translate onboarding namespace (welcome + step header)
Translates the user-first-impression surfaces of the onboarding flow:
- step-welcome.tsx (the wordmark, headline, lede paragraphs, all CTAs:
Download Desktop / Continue on web / Start exploring / I've done
this before, illustration caption)
- step-header.tsx ("Step N of M" counter + matching aria-label)
- onboarding-flow.tsx (skip-onboarding error toast)
Test wrapper added to onboarding/components/step-header.test.tsx —
custom render() helper wraps with <I18nProvider> so the "Step 2 of 5"
assertions match the EN bundle.
Deferred (acceptable English fallback for now): step-questionnaire,
step-workspace, step-runtime-connect, step-platform-fork, step-agent,
step-first-issue, cli-install-instructions, option-card, runtime
aside panels, starter-content-prompt, cloud-waitlist-expand. These
are deeper steps with significant copy that would benefit from a
focused dedicated pass — voice on each is more nuanced (questionnaire
options, runtime install instructions, agent template recommendations).
Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,9 @@ const TRANSLATED_FILES = [
|
||||
"layout/app-sidebar.tsx",
|
||||
"layout/help-launcher.tsx",
|
||||
"layout/workspace-loader.tsx",
|
||||
"onboarding/components/step-header.tsx",
|
||||
"onboarding/steps/step-welcome.tsx",
|
||||
"onboarding/onboarding-flow.tsx",
|
||||
];
|
||||
|
||||
export default [
|
||||
|
||||
@@ -1 +1,22 @@
|
||||
{}
|
||||
{
|
||||
"step_header": {
|
||||
"step_of": "Step {{current}} of {{total}}"
|
||||
},
|
||||
"welcome": {
|
||||
"wordmark": "Welcome to Multica",
|
||||
"headline_line1": "Your AI teammates,",
|
||||
"headline_line2": "in",
|
||||
"headline_emphasis": "one workspace.",
|
||||
"lede": "Assign them work like you'd assign a colleague — they pick it up, update status, and comment when done.",
|
||||
"lede_web": "Desktop bundles the runtime — nothing to install. Continue on web to connect your own CLI.",
|
||||
"lede_desktop": "By the end, a real agent will be replying to your first issue.",
|
||||
"download_desktop": "Download Desktop",
|
||||
"continue_on_web": "Continue on web",
|
||||
"start_exploring": "Start exploring",
|
||||
"skip_existing": "I've done this before",
|
||||
"illustration_caption": "Every issue, every thread, every decision — shared by your team and agents."
|
||||
},
|
||||
"errors": {
|
||||
"skip_failed": "Failed to finish onboarding"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import enCommon from "./en/common.json";
|
||||
import enAuth from "./en/auth.json";
|
||||
import enSettings from "./en/settings.json";
|
||||
import enEditor from "./en/editor.json";
|
||||
import enOnboarding from "./en/onboarding.json";
|
||||
import enInvite from "./en/invite.json";
|
||||
import enLabels from "./en/labels.json";
|
||||
import enMembers from "./en/members.json";
|
||||
@@ -21,6 +22,7 @@ import zhHansCommon from "./zh-Hans/common.json";
|
||||
import zhHansAuth from "./zh-Hans/auth.json";
|
||||
import zhHansSettings from "./zh-Hans/settings.json";
|
||||
import zhHansEditor from "./zh-Hans/editor.json";
|
||||
import zhHansOnboarding from "./zh-Hans/onboarding.json";
|
||||
import zhHansInvite from "./zh-Hans/invite.json";
|
||||
import zhHansLabels from "./zh-Hans/labels.json";
|
||||
import zhHansMembers from "./zh-Hans/members.json";
|
||||
@@ -45,6 +47,7 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
|
||||
auth: enAuth,
|
||||
settings: enSettings,
|
||||
editor: enEditor,
|
||||
onboarding: enOnboarding,
|
||||
invite: enInvite,
|
||||
labels: enLabels,
|
||||
members: enMembers,
|
||||
@@ -65,6 +68,7 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
|
||||
auth: zhHansAuth,
|
||||
settings: zhHansSettings,
|
||||
editor: zhHansEditor,
|
||||
onboarding: zhHansOnboarding,
|
||||
invite: zhHansInvite,
|
||||
labels: zhHansLabels,
|
||||
members: zhHansMembers,
|
||||
|
||||
@@ -1 +1,22 @@
|
||||
{}
|
||||
{
|
||||
"step_header": {
|
||||
"step_of": "第 {{current}} 步 / 共 {{total}} 步"
|
||||
},
|
||||
"welcome": {
|
||||
"wordmark": "欢迎使用 Multica",
|
||||
"headline_line1": "你的 AI 队友,",
|
||||
"headline_line2": "在",
|
||||
"headline_emphasis": "同一个工作区。",
|
||||
"lede": "像分配给同事一样把任务交给它们——它们会接手、推进状态、完成后留下评论。",
|
||||
"lede_web": "桌面端自带运行时,无需安装。在 web 端继续可以连接你自己的 CLI。",
|
||||
"lede_desktop": "完成本流程后,真实的智能体会回复你创建的第一个 issue。",
|
||||
"download_desktop": "下载桌面端",
|
||||
"continue_on_web": "在 web 端继续",
|
||||
"start_exploring": "开始探索",
|
||||
"skip_existing": "我之前做过了",
|
||||
"illustration_caption": "每个 issue、每条对话、每个决策——团队和智能体共享同一份上下文。"
|
||||
},
|
||||
"errors": {
|
||||
"skip_failed": "完成上手引导失败"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render as rtlRender, screen, type RenderOptions } from "@testing-library/react";
|
||||
import { ONBOARDING_STEP_ORDER } from "@multica/core/onboarding";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enOnboarding from "../../locales/en/onboarding.json";
|
||||
import { StepHeader } from "./step-header";
|
||||
|
||||
const TEST_RESOURCES = {
|
||||
en: { common: enCommon, onboarding: enOnboarding },
|
||||
};
|
||||
|
||||
function I18nWrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
{children}
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function render(ui: React.ReactElement, options?: RenderOptions) {
|
||||
return rtlRender(ui, { wrapper: I18nWrapper, ...options });
|
||||
}
|
||||
|
||||
describe("StepHeader", () => {
|
||||
it("renders one dot per step in ONBOARDING_STEP_ORDER", () => {
|
||||
const { container } = render(<StepHeader currentStep="questionnaire" />);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type OnboardingStep,
|
||||
} from "@multica/core/onboarding";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
/**
|
||||
* Horizontal step indicator shown at the top of every onboarding step
|
||||
@@ -30,6 +31,7 @@ import { cn } from "@multica/ui/lib/utils";
|
||||
* mapping from local UI step to the canonical `OnboardingStep`.
|
||||
*/
|
||||
export function StepHeader({ currentStep }: { currentStep: OnboardingStep }) {
|
||||
const { t } = useT("onboarding");
|
||||
const total = ONBOARDING_STEP_ORDER.length;
|
||||
const currentIndex = ONBOARDING_STEP_ORDER.indexOf(currentStep);
|
||||
// Defensive: unknown step → render a disabled-looking header rather
|
||||
@@ -43,7 +45,7 @@ export function StepHeader({ currentStep }: { currentStep: OnboardingStep }) {
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={total}
|
||||
aria-valuenow={safeIndex + 1}
|
||||
aria-label={`Step ${safeIndex + 1} of ${total}`}
|
||||
aria-label={t(($) => $.step_header.step_of, { current: safeIndex + 1, total })}
|
||||
className="flex w-full items-center justify-between py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -65,7 +67,7 @@ export function StepHeader({ currentStep }: { currentStep: OnboardingStep }) {
|
||||
})}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Step {safeIndex + 1} of {total}
|
||||
{t(($) => $.step_header.step_of, { current: safeIndex + 1, total })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ import { StepRuntimeConnect } from "./steps/step-runtime-connect";
|
||||
import { StepPlatformFork } from "./steps/step-platform-fork";
|
||||
import { StepAgent } from "./steps/step-agent";
|
||||
import { StepFirstIssue } from "./steps/step-first-issue";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
const EMPTY_QUESTIONNAIRE: QuestionnaireAnswers = {
|
||||
team_size: null,
|
||||
@@ -55,6 +56,7 @@ export function OnboardingFlow({
|
||||
onComplete: (workspace?: Workspace) => void;
|
||||
runtimeInstructions?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useT("onboarding");
|
||||
const user = useAuthStore((s) => s.user);
|
||||
if (!user) {
|
||||
throw new Error("OnboardingFlow requires an authenticated user");
|
||||
@@ -114,7 +116,7 @@ export function OnboardingFlow({
|
||||
await completeOnboarding("skip_existing");
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to finish onboarding",
|
||||
err instanceof Error ? err.message : t(($) => $.errors.skip_failed),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { STATUS_CONFIG } from "@multica/core/issues/config";
|
||||
import type { IssueStatus } from "@multica/core/types";
|
||||
import { StatusIcon } from "../../issues/components/status-icon";
|
||||
import { ProviderLogo } from "../../runtimes/components/provider-logo";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
/**
|
||||
* Step 0 — the one-shot product intro shown on every onboarding
|
||||
@@ -45,6 +46,7 @@ export function StepWelcome({
|
||||
onSkip?: () => void | Promise<void>;
|
||||
isWeb?: boolean;
|
||||
}) {
|
||||
const { t } = useT("onboarding");
|
||||
// Tracks which button is mid-flight so we can show a per-button
|
||||
// spinner and disable both while one is in progress.
|
||||
const [pending, setPending] = useState<"next" | "skip" | null>(null);
|
||||
@@ -79,30 +81,25 @@ export function StepWelcome({
|
||||
<div className="flex items-center gap-2.5">
|
||||
<MulticaIcon className="size-5 text-foreground" noSpin />
|
||||
<span className="font-serif text-xl font-medium tracking-tight">
|
||||
Welcome to Multica
|
||||
{t(($) => $.welcome.wordmark)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-balance font-serif text-5xl font-medium leading-[1.04] tracking-tight sm:text-6xl">
|
||||
Your AI teammates,
|
||||
{t(($) => $.welcome.headline_line1)}
|
||||
<br />
|
||||
in <em className="italic text-brand">one workspace.</em>
|
||||
{t(($) => $.welcome.headline_line2)}{" "}
|
||||
<em className="italic text-brand">{t(($) => $.welcome.headline_emphasis)}</em>
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-lg leading-relaxed text-foreground/85">
|
||||
Assign them work like you'd assign a colleague — they
|
||||
pick it up, update status, and comment when done.
|
||||
{t(($) => $.welcome.lede)}
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{isWeb ? (
|
||||
<>
|
||||
Desktop bundles the runtime — nothing to install.
|
||||
Continue on web to connect your own CLI.
|
||||
</>
|
||||
) : (
|
||||
"By the end, a real agent will be replying to your first issue."
|
||||
)}
|
||||
{isWeb
|
||||
? t(($) => $.welcome.lede_web)
|
||||
: t(($) => $.welcome.lede_desktop)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -124,7 +121,7 @@ export function StepWelcome({
|
||||
className={buttonVariants({ size: "lg" })}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download Desktop
|
||||
{t(($) => $.welcome.download_desktop)}
|
||||
</a>
|
||||
<Button
|
||||
size="lg"
|
||||
@@ -135,7 +132,7 @@ export function StepWelcome({
|
||||
{pending === "next" && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Continue on web
|
||||
{t(($) => $.welcome.continue_on_web)}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
@@ -148,7 +145,7 @@ export function StepWelcome({
|
||||
{pending === "next" && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Start exploring
|
||||
{t(($) => $.welcome.start_exploring)}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -162,7 +159,7 @@ export function StepWelcome({
|
||||
{pending === "skip" && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
I've done this before
|
||||
{t(($) => $.welcome.skip_existing)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -180,8 +177,7 @@ export function StepWelcome({
|
||||
<DragStrip />
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-7 px-8 py-8">
|
||||
<p className="max-w-[440px] text-balance text-center font-serif text-[15px] italic leading-snug text-muted-foreground">
|
||||
Every issue, every thread, every decision — shared by your team and
|
||||
agents.
|
||||
{t(($) => $.welcome.illustration_caption)}
|
||||
</p>
|
||||
<WelcomeIllustration />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user