5.3: profile / profileDraft / profileNotice / avatarNotice / avatarSaving / profileSaving move into use-profile-onboarding.ts; Home reads them back from the hook's return. The hook call moves above useBirthTimeGuidedJourney so profile is defined before the first reader. Turning the hook stateful made the React compiler lint rules apply to it, which surfaced five errors that only a hook-shaped file gets checked for: - two ref params written through .current (dialogReturnTarget, birthTimeRevisionPending) now end in "Ref" as the rule's hint asks; - two render-phase ref writes (guidedBirthTimeReadyRef / editDeclaredBirthTimeDetailsRef) are gone: the hook already returns completeGuidedBirthTime and editDeclaredBirthTimeDetails, and now that it runs before useBirthTimeGuidedJourney they are passed straight in. Both callbacks were only ever read from render-scope closures, so the call target is the same function on every frame as before. setProfile / setProfileDraft are listed in the two effects that call them; they are raw useState setters handed through the return, so identity never changes and neither effect gains a re-run. Zero behavior change, zero copy change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
439 lines
16 KiB
TypeScript
439 lines
16 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 { composerDraftSnapshot } from "@/lib/composer-draft";
|
|
import {
|
|
activeChartStorageKey,
|
|
fetchAccount,
|
|
friendlyError,
|
|
LoginRedirectError,
|
|
saveCloudChartProfile,
|
|
} from "@/lib/home-cloud-sync";
|
|
import {
|
|
birthProfileDeclarationChanged,
|
|
buildSelfChartRecord,
|
|
missingProfileStep,
|
|
readProfile,
|
|
selectedBirthPlace,
|
|
} from "@/lib/home-profile";
|
|
import { resolveAyanamsa } from "@/lib/ayanamsa";
|
|
import { createStartGreeting } from "@/lib/starter-greeting";
|
|
import {
|
|
timestamp,
|
|
type Account,
|
|
type AccountDialog,
|
|
type ChartLibraryRecord,
|
|
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>;
|
|
activeChartId: string;
|
|
birthTimeRevisionPendingRef: MutableRefObject<boolean>;
|
|
chartLibrary: ChartLibraryRecord[];
|
|
dialogReturnTargetRef: MutableRefObject<HTMLButtonElement | null>;
|
|
setAccount: Dispatch<SetStateAction<Account | null>>;
|
|
setAccountError: Dispatch<SetStateAction<string>>;
|
|
setAccountMenuOpen: Dispatch<SetStateAction<boolean>>;
|
|
setActiveAccountDialog: Dispatch<SetStateAction<AccountDialog | null>>;
|
|
setActiveChartId: Dispatch<SetStateAction<string>>;
|
|
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>;
|
|
};
|
|
|
|
export function useProfileOnboarding(params: ProfileOnboardingParams) {
|
|
const {
|
|
account,
|
|
accountRefreshGuard,
|
|
accountTrigger,
|
|
activeChartId,
|
|
birthTimeRevisionPendingRef,
|
|
chartLibrary,
|
|
dialogReturnTargetRef,
|
|
setAccount,
|
|
setAccountError,
|
|
setAccountMenuOpen,
|
|
setActiveAccountDialog,
|
|
setActiveChartId,
|
|
setBirthTimeAssessmentPhase,
|
|
setBirthTimeConsultationConsent,
|
|
setBirthTimeError,
|
|
setBirthTimeJourney,
|
|
setBillingPane,
|
|
setDraft,
|
|
setOnboardingJustCompleted,
|
|
setOnboardingStep,
|
|
setPresetMessageLength,
|
|
setRectificationError,
|
|
setSigningOut,
|
|
setStartGreeting,
|
|
signingOut,
|
|
uiPreview,
|
|
} = 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>(emptyProfile);
|
|
const [profileDraft, setProfileDraft] = useState<Profile>(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();
|
|
try {
|
|
const latest = await fetchAccount();
|
|
if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return;
|
|
const nextProfile = readProfile(latest.profile);
|
|
const activeOther = activeChartId !== "self"
|
|
&& chartLibrary.find((record) => record.id === activeChartId && record.role === "other");
|
|
if (!activeOther) {
|
|
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") {
|
|
setProfileDraft(profile);
|
|
setProfileNotice("");
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
} | null;
|
|
if (!response.ok) {
|
|
throw new Error(payload?.error || "账户资料暂时无法保存。");
|
|
}
|
|
const savedProfile = applyPersistedBirthTime(nextProfile, payload?.birthTime);
|
|
setAccount((current) => current ? { ...current, profile: savedProfile } : current);
|
|
setActiveChartId("self");
|
|
localStorage.setItem(activeChartStorageKey(account.user.id), "self");
|
|
await saveCloudChartProfile({ ...buildSelfChartRecord(savedProfile), updatedAt: timestamp() }).catch(() => null);
|
|
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 {
|
|
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,
|
|
persistProfile,
|
|
assessSavedBirthTime,
|
|
saveProfile,
|
|
saveOnboardingName,
|
|
saveOnboardingBirth,
|
|
editDeclaredBirthTimeDetails,
|
|
saveOnboardingPlace,
|
|
completeGuidedBirthTime,
|
|
retryBirthTimeAssessment,
|
|
signOut,
|
|
};
|
|
}
|