Files
multica/packages/views/onboarding/steps/step-question.tsx
Naiyuan Qing 7b3cad664b revert(self-host): remove source channel reporting (#4799)
* Revert "test(onboarding): cover official source reporting controls (#4782)"

This reverts commit fc88c7720f.

* Revert "fix(self-host): restore official source report endpoint (#4781)"

This reverts commit ad1afdd48d.

* Revert "feat(self-host): collect anonymous source channels mul-3878 (#4741)"

This reverts commit 26142d74aa.
2026-07-01 17:53:16 +08:00

227 lines
7.8 KiB
TypeScript

"use client";
import { type ReactNode, useRef, useState } from "react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import type { OnboardingStep } from "@multica/core/onboarding";
import { DragStrip } from "@multica/views/platform";
import { StepHeader } from "../components/step-header";
import {
IconOptionCard,
IconOtherOptionCard,
} from "../components/icon-option-card";
import { useT } from "../../i18n";
/**
* One option in the card grid. `slug` is the persisted enum value;
* `icon` is a React node (lucide icon, brand SVG, or emoji span);
* `label` is the localized string already resolved by the caller.
* `isOther` flips this card into a free-text input row.
*/
export interface QuestionOption {
slug: string;
icon: ReactNode;
label: string;
isOther?: boolean;
}
/**
* Generic per-question step used by Source / Role / Use case. Back
* lives in the page header next to the step indicator; Skip + Continue
* sit inline directly below the options grid (with a status hint),
* not in a sticky bottom footer — the form usually only fills the top
* third of the viewport, and a footer pinned to the page bottom left a
* large dead zone between the options and the action buttons.
*
* Supports both single-select (`multiSelect=false`, the default — Role
* and Source) and multi-select (`multiSelect=true` — Use case). The
* contract is always an array of selected slugs and an `onAnswer(slug)`
* callback fired when a card is clicked; the parent decides whether to
* replace (single) or toggle (multi) the selection in its own state.
*/
export function StepQuestion({
step,
number,
eyebrow,
question,
options,
selectedSlugs,
otherValue,
onOtherChange,
otherPlaceholder,
onAnswer,
onAdvance,
onSkip,
onBack,
multiSelect = false,
}: {
step: OnboardingStep;
number: number;
eyebrow?: string;
question: string;
options: readonly QuestionOption[];
selectedSlugs: readonly string[];
otherValue: string;
onOtherChange: (value: string) => void;
otherPlaceholder: string;
/** Record the click in the parent — does NOT advance. Parent
* decides replace vs. toggle based on its own multi/single mode. */
onAnswer: (slug: string) => void;
/** Commit the current selection and move to the next step. */
onAdvance: () => void;
onSkip: () => void;
onBack?: () => void;
multiSelect?: boolean;
}) {
const { t } = useT("onboarding");
const [pendingOther, setPendingOther] = useState(false);
const mainRef = useRef<HTMLElement>(null);
const fadeStyle = useScrollFade(mainRef);
const handleSelect = (option: QuestionOption) => {
if (option.isOther) {
setPendingOther(true);
onOtherChange(otherValue);
onAnswer(option.slug);
return;
}
setPendingOther(false);
onAnswer(option.slug);
};
const otherOption = options.find((o) => o.isOther) ?? null;
const otherSelected = otherOption
? selectedSlugs.includes(otherOption.slug)
: false;
const otherActive = otherSelected || pendingOther;
const otherFilled = (otherValue ?? "").trim().length > 0;
// Continue is enabled when:
// - at least one non-Other option is selected, OR
// - Other is selected AND the free-text input has content.
// In multi-select (Use case) Other can stack with regular picks; we
// treat the non-Other selections as sufficient to advance even if
// Other's text is empty.
const hasNonOtherSelection = selectedSlugs.some(
(slug) => slug !== otherOption?.slug,
);
const canContinue =
selectedSlugs.length > 0 &&
(hasNonOtherSelection || !otherActive || otherFilled);
const confirmAdvance = () => {
if (canContinue) onAdvance();
};
// Footer label:
// - single select (Role / Source): name the picked option
// ("hint_selected").
// - multi-select (Use case) with 1 pick: same.
// - multi-select with >1 pick: show count via the existing
// "hint_continue" key (locale-friendly, doesn't grow a new key
// just for "N selected").
const singlePicked =
selectedSlugs.length === 1
? options.find((o) => o.slug === selectedSlugs[0]) ?? null
: null;
const singlePickedLabel = singlePicked?.label ?? null;
const footerHint = canContinue
? singlePickedLabel
? t(($) => $.step_runtime.hint_selected, { name: singlePickedLabel })
: t(($) => $.step_question.hint_continue)
: t(($) => $.step_question.hint_pick);
return (
<div className="animate-onboarding-enter flex h-full min-h-0 flex-col bg-background">
<DragStrip />
<header className="flex shrink-0 items-center gap-4 bg-background px-6 py-3 sm:px-10 md:px-14 lg:px-16">
{onBack ? (
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t(($) => $.common.back)}
</button>
) : (
<span aria-hidden className="w-0" />
)}
<div className="flex-1">
<StepHeader currentStep={step} />
</div>
</header>
<main
ref={mainRef}
style={fadeStyle}
className="min-h-0 flex-1 overflow-y-auto"
>
<div className="mx-auto w-full max-w-[920px] px-6 py-10 sm:px-10 md:px-14 lg:py-14">
{eyebrow ? (
<div className="mb-2 text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">
{eyebrow}
</div>
) : null}
<div className="mb-1 font-mono text-xs text-muted-foreground">
{String(number).padStart(2, "0")}
</div>
<h1 className="text-balance font-serif text-[34px] font-medium leading-[1.15] tracking-tight text-foreground">
{question}
</h1>
<fieldset
role={multiSelect ? "group" : "radiogroup"}
aria-label={question}
className="mt-10 m-0 grid grid-cols-1 gap-3 p-0 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{options.map((option) =>
option.isOther ? (
<IconOtherOptionCard
key={option.slug}
icon={option.icon}
label={option.label}
selected={otherActive}
onSelect={() => handleSelect(option)}
otherValue={otherValue}
onOtherChange={onOtherChange}
onConfirm={confirmAdvance}
placeholder={otherPlaceholder}
mode={multiSelect ? "checkbox" : "radio"}
/>
) : (
<IconOptionCard
key={option.slug}
icon={option.icon}
label={option.label}
selected={selectedSlugs.includes(option.slug)}
onSelect={() => handleSelect(option)}
mode={multiSelect ? "checkbox" : "radio"}
/>
),
)}
</fieldset>
<div className="mt-8 flex flex-wrap items-center justify-end gap-x-4 gap-y-2">
<span
aria-live="polite"
className="mr-auto text-xs text-muted-foreground"
>
{footerHint}
</span>
<div className="flex items-center gap-2">
<Button size="lg" variant="secondary" onClick={onSkip}>
{t(($) => $.common.skip)}
</Button>
<Button size="lg" disabled={!canContinue} onClick={confirmAdvance}>
{t(($) => $.common.continue)}
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</main>
</div>
);
}