feat: integrate birth time journey into onboarding

This commit is contained in:
Jesse_Chen
2026-07-17 16:19:11 +08:00
parent 73fe62e9c3
commit 49054172ac
19 changed files with 1136 additions and 104 deletions
@@ -0,0 +1,174 @@
"use client";
import { useId } from "react";
import {
birthTimePeriodOptions,
birthTimeSourceOptions,
type BirthTimeDraft,
type BirthTimeDraftPatch,
type BirthTimeSource,
} from "@/lib/birth-time-intake-model";
type BirthTimeIntakeProps = {
readonly value: BirthTimeDraft;
readonly onPatch: (patch: BirthTimeDraftPatch) => void;
};
const sourceDefaults = {
hospital_record: {
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
},
family_exact: {
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 10,
uncertaintyAfterMinutes: 10,
},
approximate: {
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
},
period_only: {
reportedTime: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
},
unknown: {
reportedTime: "",
birthTimePeriod: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
},
} as const satisfies Record<Exclude<BirthTimeSource, "" | "legacy_import">, BirthTimeDraftPatch>;
export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) {
const groupId = useId();
const source = value.birthTimeSource;
const isConfirmed = value.birthTimeStatus === "confirmed";
const usesClockTime = source === "hospital_record"
|| source === "family_exact"
|| source === "approximate"
|| source === "legacy_import";
return (
<div className="birth-time-intake">
<label>
<span>出生日期</span>
<input
required
disabled={isConfirmed}
type="date"
value={value.date}
onChange={(event) => onPatch({ date: event.target.value })}
/>
</label>
{source === "legacy_import" && (
<p className="birth-time-legacy-note">
这是账号里已有的确认时间。本阶段保留原始记录,不在资料表内直接覆盖。
</p>
)}
{!isConfirmed && <fieldset className="birth-time-source-fieldset">
<legend>你知道的出生时间属于哪一种?</legend>
<div className="birth-time-source-list">
{birthTimeSourceOptions.map((option) => (
<label
className={`birth-time-source-option ${source === option.value ? "is-selected" : ""}`}
key={option.value}
>
<input
checked={source === option.value}
name={`birth-time-source-${groupId}`}
type="radio"
value={option.value}
onChange={() => onPatch({
birthTimeSource: option.value,
birthTimeStatus: "reported",
time: "",
...sourceDefaults[option.value],
})}
/>
<span>
<b>{option.label}</b>
<small>{option.hint}</small>
</span>
</label>
))}
</div>
</fieldset>}
{usesClockTime && (
<div className="birth-time-detail-grid onboarding-card-reveal">
<label>
<span>{isConfirmed ? "当前排盘时间" : source === "approximate" ? "大概时间" : "记录时间"}</span>
<input
required
disabled={isConfirmed}
type="time"
value={value.reportedTime || value.time}
onChange={(event) => onPatch({ reportedTime: event.target.value })}
/>
</label>
{source === "hospital_record" && (
<p className="birth-time-detail-note">系统会无感检查前后 2 分钟的上升与 D9 / D10 稳定性。</p>
)}
{(source === "family_exact" || source === "approximate") && (
<label>
<span>可能误差</span>
<select
value={value.uncertaintyBeforeMinutes ?? ""}
onChange={(event) => {
const minutes = Number(event.target.value);
onPatch({ uncertaintyBeforeMinutes: minutes, uncertaintyAfterMinutes: minutes });
}}
>
{(source === "family_exact" ? [5, 10, 15] : [15, 30, 60]).map((minutes) => (
<option key={minutes} value={minutes}>前后 {minutes} 分钟</option>
))}
</select>
</label>
)}
</div>
)}
{source === "period_only" && (
<label className="onboarding-card-reveal">
<span>大致时段</span>
<select
required
value={value.birthTimePeriod}
onChange={(event) => {
const period = birthTimePeriodOptions.find((option) => option.value === event.target.value);
if (period) onPatch({ birthTimePeriod: period.value });
}}
>
<option value="" disabled>请选择你知道的时段</option>
{birthTimePeriodOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
)}
{source === "unknown" && (
<label className="onboarding-card-reveal">
<span>目前知道的线索(可选)</span>
<textarea
maxLength={240}
placeholder="例如:家人只记得天黑以后,或可以再询问长辈"
rows={3}
value={value.birthTimeClue}
onChange={(event) => onPatch({ birthTimeClue: event.target.value })}
/>
</label>
)}
</div>
);
}
@@ -0,0 +1,93 @@
"use client";
import { assistantIntentCopy } from "@/lib/birth-time-intake-model";
import type {
JourneyAnswer,
JourneyClientResponse,
} from "@/lib/birth-time-journey-client";
type BirthTimeRectificationProps = {
readonly journey: JourneyClientResponse;
readonly answers: Readonly<Record<string, JourneyAnswer>>;
readonly pendingQuestionId: string;
readonly error: string;
readonly onAnswer: (questionId: string, answer: JourneyAnswer) => void;
};
const fallbackOptions = [
{ key: "A", label: "明确有,而且时间大致吻合" },
{ key: "B", label: "有类似经历,但时间或程度不完全确定" },
{ key: "C", label: "没有明显发生" },
{ key: "D", label: "不确定 / 不记得" },
] as const;
export function BirthTimeRectification({
journey,
answers,
pendingQuestionId,
error,
onAnswer,
}: BirthTimeRectificationProps) {
const questions = journey.questionnaire?.questions.slice(0, 3) ?? [];
const answeredCount = Object.keys(answers).length;
return (
<section className="birth-time-rectification onboarding-card" aria-labelledby="birth-time-assessment-title">
<div className="birth-time-assessment-heading">
<div>
<span>出生时间评估</span>
<h2 id="birth-time-assessment-title">
{journey.snapshot.state === "candidate" ? "候选范围已保存" : "需要先缩小时间范围"}
</h2>
</div>
<span className="birth-time-status-badge">
{journey.snapshot.state === "candidate" ? "候选" : "校正中"}
</span>
</div>
<dl className="birth-time-range-summary">
<div><dt>当前范围</dt><dd>{journey.snapshot.reportedRange.label}</dd></div>
<div><dt>应用状态</dt><dd>尚未设为排盘时间</dd></div>
</dl>
<p className="birth-time-assistant-intent" role="status">
{assistantIntentCopy(journey.snapshot.assistantIntent)}
</p>
{questions.length > 0 ? (
<div className="birth-time-question-list">
<div className="birth-time-question-progress">
<b>首轮问题</b>
<span>{Math.min(answeredCount, questions.length)} / {questions.length}</span>
</div>
{questions.map((question, index) => (
<fieldset className="birth-time-question" key={question.id}>
<legend><span>{index + 1}</span>{question.prompt}</legend>
<div className="birth-time-answer-list">
{(question.options?.length ? question.options : fallbackOptions).map((option) => (
<button
aria-pressed={answers[question.id] === option.key}
className={answers[question.id] === option.key ? "is-selected" : ""}
disabled={Boolean(pendingQuestionId)}
key={option.key}
type="button"
onClick={() => onAnswer(question.id, option.key)}
>
<span>{option.key}</span>{option.label}
</button>
))}
</div>
{pendingQuestionId === question.id && <small role="status">正在更新候选范围…</small>}
</fieldset>
))}
</div>
) : (
<p className="birth-time-assessment-unavailable">
当前资料已经保留,但候选扫描暂时不可用。系统不会把未经验证的分钟用于正式排盘。
</p>
)}
{error && <p className="form-error" role="alert">{error}</p>}
</section>
);
}