feat: integrate birth time journey into onboarding
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user