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
464 lines
17 KiB
TypeScript
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,
|
|
};
|
|
}
|