Files
Jyotisha/frontend/src/hooks/use-profile-onboarding.ts
T
Jesse_ChenandClaude Opus 5.5 d79dada970 feat(ui): optional gender radio in the people form and 个人资料 (T2)
One ProfileGenderField (女 / 男 / 不填, 44px, one hint line) in the
people-archive edit form (saved with the form, 不填 → null) and in
设置 → 个人资料 for the owner (saves on its own PATCH; birth-data saves never
send gender). The detail card shows 性别 (未填 when empty). Not asked in
onboarding. DESIGN/VOICE updated; 390/1280 screenshots with fictional data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-27 18:30:58 +08:00

464 lines
17 KiB
TypeScript

"use client";
import { useState, type Dispatch, FormEvent, MutableRefObject, SetStateAction } from "react";
import {
beamAvatarSchema,
type BeamAvatarPatch,
} from "@/lib/beam-avatar";
import {
applyPersistedBirthTime,
birthTimePersistenceValues,
isBirthTimeDraftReady,
} from "@/lib/birth-time-intake-model";
import {
birthTimeConsultationOptionsCopy,
createBirthTimeConsultationConsentState,
type BirthTimeConsultationConsentState,
type LatestAccountRequestGuard,
} from "@/lib/birth-time-consultation-consent";
import { readChartBirthFingerprint } from "@/lib/chart-birth-truth";
import { composerDraftSnapshot } from "@/lib/composer-draft";
import {
fetchAccount,
friendlyError,
LoginRedirectError,
} from "@/lib/home-cloud-sync";
import {
accountChartPinStillCurrent,
beginChartCacheAccountRead,
pinChartSnapshotIdentity,
} from "@/lib/secondary-page-data";
import {
birthProfileDeclarationChanged,
missingProfileStep,
readProfile,
selectedBirthPlace,
} from "@/lib/home-profile";
import { resolveAyanamsa } from "@/lib/ayanamsa";
import { withSavedGender, type ProfileGender } from "@/lib/profile-gender";
import { createStartGreeting } from "@/lib/starter-greeting";
import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
import {
type Account,
type AccountDialog,
type OnboardingStep,
type OpenAccountDialogOptions,
type Profile,
accountDialogOptions,
emptyProfile,
} from "@/lib/home-types";
import { preserveShallowEqual } from "@/lib/preserve-shallow-equal";
import {
requestBirthTimeAssessment,
type JourneyClientResponse,
} from "@/lib/birth-time-journey-client";
import { previewRectificationJourney } from "@/lib/birth-time-guided-preview";
import { selfHostedOtpActions } from "@/modules/identity/client";
import type { BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
export type ProfileOnboardingParams = {
account: Account | null;
accountRefreshGuard: MutableRefObject<LatestAccountRequestGuard>;
accountTrigger: MutableRefObject<HTMLButtonElement | null>;
birthTimeRevisionPendingRef: MutableRefObject<boolean>;
dialogReturnTargetRef: MutableRefObject<HTMLButtonElement | null>;
setAccount: Dispatch<SetStateAction<Account | null>>;
setAccountError: Dispatch<SetStateAction<string>>;
setAccountMenuOpen: Dispatch<SetStateAction<boolean>>;
setActiveAccountDialog: Dispatch<SetStateAction<AccountDialog | null>>;
setBirthTimeAssessmentPhase: Dispatch<SetStateAction<BirthTimeAssessmentPhase | null>>;
setBirthTimeConsultationConsent: Dispatch<SetStateAction<BirthTimeConsultationConsentState>>;
setBirthTimeError: Dispatch<SetStateAction<string>>;
setBirthTimeJourney: Dispatch<SetStateAction<JourneyClientResponse | null>>;
setBillingPane: Dispatch<SetStateAction<OpenAccountDialogOptions>>;
setDraft: (value: string) => void;
setOnboardingJustCompleted: Dispatch<SetStateAction<boolean>>;
setOnboardingStep: Dispatch<SetStateAction<OnboardingStep>>;
setPresetMessageLength: Dispatch<SetStateAction<number>>;
setRectificationError: Dispatch<SetStateAction<string>>;
setSigningOut: Dispatch<SetStateAction<boolean>>;
setStartGreeting: Dispatch<SetStateAction<string>>;
signingOut: boolean;
uiPreview: MutableRefObject<boolean>;
/** Warm return to `/` (BUG-1040): the profile Home starts with instead of empty. */
initialProfile?: () => Profile | null;
};
export function useProfileOnboarding(params: ProfileOnboardingParams) {
const {
account,
accountRefreshGuard,
accountTrigger,
birthTimeRevisionPendingRef,
dialogReturnTargetRef,
setAccount,
setAccountError,
setAccountMenuOpen,
setActiveAccountDialog,
setBirthTimeAssessmentPhase,
setBirthTimeConsultationConsent,
setBirthTimeError,
setBirthTimeJourney,
setBillingPane,
setDraft,
setOnboardingJustCompleted,
setOnboardingStep,
setPresetMessageLength,
setRectificationError,
setSigningOut,
setStartGreeting,
signingOut,
uiPreview,
initialProfile,
} = params;
// Owned here since 2026-09-16 (state lowering batch 2). Home reads them back
// from this hook's return instead of declaring them itself.
const [profile, setProfile] = useState<Profile>(() => initialProfile?.() ?? emptyProfile);
const [profileDraft, setProfileDraft] = useState<Profile>(() => initialProfile?.() ?? emptyProfile);
const [profileNotice, setProfileNotice] = useState("");
const [avatarNotice, setAvatarNotice] = useState("");
const [avatarSaving, setAvatarSaving] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
async function refreshAccount() {
const requestIdentity = accountRefreshGuard.current.begin();
const chartRead = beginChartCacheAccountRead();
try {
const latest = await fetchAccount();
if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return;
const fingerprint = readChartBirthFingerprint(latest);
if (fingerprint && accountChartPinStillCurrent(chartRead)) {
pinChartSnapshotIdentity({ accountId: latest.user.id, fingerprint });
}
const nextProfile = readProfile(latest.profile);
setProfile((current) => preserveShallowEqual(current, nextProfile));
setAccount(latest);
setAccountError("");
} catch (caught) {
if (caught instanceof LoginRedirectError) return;
if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return;
setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息");
}
}
function openAccountDialog(
dialog: AccountDialog,
returnTargetOrOptions?: HTMLButtonElement | null | OpenAccountDialogOptions,
) {
const options = accountDialogOptions(returnTargetOrOptions);
dialogReturnTargetRef.current = options.returnTarget ?? accountTrigger.current;
setAccountMenuOpen(false);
setAccountError("");
if (dialog === "profile") {
setProfileNotice("");
setAvatarNotice("");
}
if (dialog === "chart-library") {
window.location.assign("/people");
return;
}
if (dialog === "billing") {
setBillingPane({
source: options.source,
plan: options.plan,
tab: options.tab,
});
}
setActiveAccountDialog(dialog);
}
function closeAccountDialog() {
if (signingOut) return;
setActiveAccountDialog(null);
const returnTarget = dialogReturnTargetRef.current;
window.requestAnimationFrame(() => returnTarget?.focus());
}
async function persistAvatar(patch: BeamAvatarPatch) {
if (!account?.avatar || avatarSaving) return;
setAvatarSaving(true);
setAvatarNotice("");
setAccountError("");
try {
const response = await fetch("/api/account/avatar", {
method: "PATCH",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify(patch),
});
const payload = await response.json().catch(() => null) as { avatar?: unknown; error?: string } | null;
if (!response.ok) throw new Error(payload?.error || "头像暂时无法保存。");
const avatar = beamAvatarSchema.parse(payload?.avatar);
setAccount((current) => current ? { ...current, avatar } : current);
setAvatarNotice(patch.action === "randomize" ? "已生成并保存新头像。" : "头像配色已保存。");
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "头像保存失败"));
} finally {
setAvatarSaving(false);
}
}
/**
* The owner's optional gender (设置 → 个人资料). A PATCH of this one field:
* birth-data saves never send it, so they can never clear it.
*/
async function persistGender(gender: ProfileGender | null): Promise<void> {
if (!account) throw new Error("账户尚未加载完成");
const response = await fetch("/api/account", {
method: "PATCH",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({ gender }),
});
const payload = await response.json().catch(() => null) as { error?: string } | null;
if (!response.ok) throw new Error(payload?.error || "性别没保存上,再试一次。");
const apply = (current: Profile): Profile => ({ ...current, gender });
setProfile(apply);
setProfileDraft(apply);
setAccount((current) => current ? { ...current, profile: withSavedGender(current.profile, gender) } : current);
}
async function persistProfile(nextProfile: Profile): Promise<Profile> {
if (!account) throw new Error("账户尚未加载完成");
if (process.env.NODE_ENV === "development" && uiPreview.current) return nextProfile;
const birthPlace = selectedBirthPlace(nextProfile);
const response = await fetch("/api/account", {
method: "PATCH",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: nextProfile.name.trim() || null,
birth_date: nextProfile.date || null,
...birthTimePersistenceValues(nextProfile),
country_code: nextProfile.countryCode,
province_code: nextProfile.provinceCode || null,
city_code: nextProfile.cityCode || null,
district_code: nextProfile.districtCode || null,
birth_place_label: nextProfile.birthPlaceLabel || null,
birth_place_type: nextProfile.birthPlaceType || null,
birth_place_provider: nextProfile.birthPlaceProvider || null,
birth_place_provider_id: nextProfile.birthPlaceProviderId || null,
latitude: birthPlace?.lat ?? null,
longitude: birthPlace?.lon ?? null,
timezone_id: nextProfile.timezoneId || null,
timezone_offset: birthPlace?.tz ?? null,
timezone_source: nextProfile.timezoneSource || null,
ayanamsa: resolveAyanamsa(nextProfile),
}),
});
const payload = await response.json().catch(() => null) as {
error?: string;
birthTime?: unknown;
chartBirth?: { fingerprint?: unknown };
} | null;
if (!response.ok) {
throw new Error(payload?.error || "账户资料暂时无法保存。");
}
const fingerprint = readChartBirthFingerprint(payload);
if (fingerprint) pinChartSnapshotIdentity({ accountId: account.user.id, fingerprint });
const savedProfile = applyPersistedBirthTime(nextProfile, payload?.birthTime);
setAccount((current) => current ? { ...current, profile: savedProfile } : current);
return savedProfile;
}
async function assessSavedBirthTime(nextProfile: Profile) {
const result = process.env.NODE_ENV === "development" && uiPreview.current
? previewRectificationJourney
: await requestBirthTimeAssessment();
const nextStatus = result.snapshot.state === "ready"
? "confirmed"
: result.snapshot.state === "candidate"
? "candidate"
: "rectifying";
const assessedProfile: Profile = {
...nextProfile,
time: result.snapshot.activeTime ?? "",
birthTimeStatus: nextStatus,
rectificationCaseId: result.caseId,
};
setBirthTimeJourney(result);
setBirthTimeError("");
setProfile(assessedProfile);
setProfileDraft(assessedProfile);
return assessedProfile;
}
async function saveProfile(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!profileDraft.name.trim() || !isBirthTimeDraftReady(profileDraft) || !selectedBirthPlace(profileDraft) || !account || profileSaving) return;
setProfileSaving(true);
setProfileNotice("");
setAccountError("");
try {
const declarationChanged = birthProfileDeclarationChanged(profile, profileDraft);
const savedProfile = await persistProfile(profileDraft);
setProfile(savedProfile);
setProfileDraft(savedProfile);
setRectificationError("");
if (declarationChanged) {
setBirthTimeConsultationConsent(createBirthTimeConsultationConsentState());
void refreshAccount();
}
setProfileNotice(savedProfile.birthTimeStatus === "confirmed"
? "出生资料已保存到云端,可在同一账号的其他设备使用。"
: `出生资料已保存。${birthTimeConsultationOptionsCopy(savedProfile)}`);
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生资料保存失败"));
} finally {
setProfileSaving(false);
}
}
async function saveOnboardingName() {
const name = composerDraftSnapshot().replace(/\s+/g, " ").trim().slice(0, 80);
if (!name || !account || profileSaving) return;
const nextProfile = { ...profileDraft, name };
setProfileSaving(true);
setAccountError("");
try {
const savedProfile = await persistProfile(nextProfile);
setProfile(savedProfile);
setProfileDraft(savedProfile);
setStartGreeting(createStartGreeting(savedProfile.name));
setDraft("");
setPresetMessageLength(0);
const nextStep = missingProfileStep(savedProfile);
if (nextStep) setOnboardingStep(nextStep);
else setOnboardingJustCompleted(false);
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "称呼保存失败"));
} finally {
setProfileSaving(false);
}
}
async function saveOnboardingBirth(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!isBirthTimeDraftReady(profileDraft) || !account || profileSaving) return;
setProfileSaving(true);
setBirthTimeAssessmentPhase("saving_profile");
setAccountError("");
try {
const savedProfile = await persistProfile(profileDraft);
birthTimeRevisionPendingRef.current = false;
setProfile(savedProfile);
setProfileDraft(savedProfile);
setPresetMessageLength(0);
const nextStep = missingProfileStep(savedProfile);
if (nextStep) setOnboardingStep(nextStep);
else setOnboardingJustCompleted(false);
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生时间保存失败"));
} finally {
setBirthTimeAssessmentPhase(null);
setProfileSaving(false);
}
}
function editDeclaredBirthTimeDetails() {
birthTimeRevisionPendingRef.current = true;
setBirthTimeError("");
setPresetMessageLength(0);
setOnboardingStep("birth");
}
async function saveOnboardingPlace(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedBirthPlace(profileDraft) || !account || profileSaving) return;
setProfileSaving(true);
setBirthTimeAssessmentPhase("entering_home");
setAccountError("");
try {
const savedProfile = await persistProfile(profileDraft);
setProfile(savedProfile);
setProfileDraft(savedProfile);
setStartGreeting(createStartGreeting(savedProfile.name));
setPresetMessageLength(0);
setOnboardingJustCompleted(false);
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生地点保存失败"));
} finally {
setBirthTimeAssessmentPhase(null);
setProfileSaving(false);
}
}
function completeGuidedBirthTime(result: JourneyClientResponse) {
if (result.nextAction.kind !== "ready") return;
const confirmedProfile: Profile = {
...profileDraft,
time: result.nextAction.activeTime,
birthTimeStatus: "confirmed",
rectificationCaseId: result.caseId,
};
setProfile(confirmedProfile);
setProfileDraft(confirmedProfile);
setPresetMessageLength(0);
setOnboardingJustCompleted(false);
}
async function retryBirthTimeAssessment() {
if (!account || profileSaving) return;
setProfileSaving(true);
setBirthTimeError("");
try {
const assessedProfile = await assessSavedBirthTime(profileDraft);
setPresetMessageLength(0);
if (assessedProfile.birthTimeStatus === "confirmed") setOnboardingJustCompleted(false);
} catch (caught) {
setBirthTimeError(caught instanceof Error ? caught.message : "生时评估暂时不可用,请稍后重试。");
} finally {
setProfileSaving(false);
}
}
async function signOut() {
if (signingOut) return;
setSigningOut(true);
setAccountError("");
try {
// Signing out ends this account's warm return to `/` (BUG-1040); a
// failed sign-out only costs one loading ring on the next return.
clearHomeWarmSnapshot();
await selfHostedOtpActions.signOut();
window.location.assign("/login");
} catch (caught) {
const message = caught instanceof Error ? caught.message : "退出失败";
setAccountError(friendlyError(message));
setSigningOut(false);
}
}
return {
profile,
setProfile,
profileDraft,
setProfileDraft,
profileNotice,
setProfileNotice,
avatarNotice,
avatarSaving,
profileSaving,
refreshAccount,
openAccountDialog,
closeAccountDialog,
persistAvatar,
persistGender,
persistProfile,
assessSavedBirthTime,
saveProfile,
saveOnboardingName,
saveOnboardingBirth,
editDeclaredBirthTimeDetails,
saveOnboardingPlace,
completeGuidedBirthTime,
retryBirthTimeAssessment,
signOut,
};
}