The four account panes now share a fixed frame, chart profiles open as list then detail, and membership lives in the homepage dialog instead of a separate page. Co-authored-by: Cursor <cursoragent@cursor.com>
446 lines
16 KiB
TypeScript
446 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { 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/onboarding-client";
|
|
import {
|
|
timestamp,
|
|
type Account,
|
|
type AccountDialog,
|
|
type ChartLibraryRecord,
|
|
type OnboardingStep,
|
|
type OpenAccountDialogOptions,
|
|
type Profile,
|
|
accountDialogOptions,
|
|
} 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;
|
|
avatarSaving: boolean;
|
|
birthTimeRevisionPending: MutableRefObject<boolean>;
|
|
chartLibrary: ChartLibraryRecord[];
|
|
dialogReturnTarget: MutableRefObject<HTMLButtonElement | null>;
|
|
profile: Profile;
|
|
profileDraft: Profile;
|
|
profileSaving: boolean;
|
|
setAccount: Dispatch<SetStateAction<Account | null>>;
|
|
setAccountError: Dispatch<SetStateAction<string>>;
|
|
setAccountMenuOpen: Dispatch<SetStateAction<boolean>>;
|
|
setActiveAccountDialog: Dispatch<SetStateAction<AccountDialog | null>>;
|
|
setActiveChartId: Dispatch<SetStateAction<string>>;
|
|
setAvatarNotice: Dispatch<SetStateAction<string>>;
|
|
setAvatarSaving: Dispatch<SetStateAction<boolean>>;
|
|
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>>;
|
|
setProfile: Dispatch<SetStateAction<Profile>>;
|
|
setProfileDraft: Dispatch<SetStateAction<Profile>>;
|
|
setProfileNotice: Dispatch<SetStateAction<string>>;
|
|
setProfileSaving: Dispatch<SetStateAction<boolean>>;
|
|
setRectificationError: Dispatch<SetStateAction<string>>;
|
|
setSigningOut: Dispatch<SetStateAction<boolean>>;
|
|
setStartGreeting: Dispatch<SetStateAction<string>>;
|
|
signingOut: boolean;
|
|
uiPreview: MutableRefObject<boolean>;
|
|
guidedBirthTimeReadyRef: MutableRefObject<(result: JourneyClientResponse) => void>;
|
|
editDeclaredBirthTimeDetailsRef: MutableRefObject<() => void>;
|
|
};
|
|
|
|
export function useProfileOnboarding(params: ProfileOnboardingParams) {
|
|
const {
|
|
account,
|
|
accountRefreshGuard,
|
|
accountTrigger,
|
|
activeChartId,
|
|
avatarSaving,
|
|
birthTimeRevisionPending,
|
|
chartLibrary,
|
|
dialogReturnTarget,
|
|
profile,
|
|
profileDraft,
|
|
profileSaving,
|
|
setAccount,
|
|
setAccountError,
|
|
setAccountMenuOpen,
|
|
setActiveAccountDialog,
|
|
setActiveChartId,
|
|
setAvatarNotice,
|
|
setAvatarSaving,
|
|
setBirthTimeAssessmentPhase,
|
|
setBirthTimeConsultationConsent,
|
|
setBirthTimeError,
|
|
setBirthTimeJourney,
|
|
setBillingPane,
|
|
setDraft,
|
|
setOnboardingJustCompleted,
|
|
setOnboardingStep,
|
|
setPresetMessageLength,
|
|
setProfile,
|
|
setProfileDraft,
|
|
setProfileNotice,
|
|
setProfileSaving,
|
|
setRectificationError,
|
|
setSigningOut,
|
|
setStartGreeting,
|
|
signingOut,
|
|
uiPreview,
|
|
guidedBirthTimeReadyRef,
|
|
editDeclaredBirthTimeDetailsRef,
|
|
} = params;
|
|
|
|
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);
|
|
dialogReturnTarget.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 = dialogReturnTarget.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);
|
|
birthTimeRevisionPending.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() {
|
|
birthTimeRevisionPending.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);
|
|
}
|
|
}
|
|
|
|
guidedBirthTimeReadyRef.current = completeGuidedBirthTime;
|
|
editDeclaredBirthTimeDetailsRef.current = editDeclaredBirthTimeDetails;
|
|
|
|
return {
|
|
refreshAccount,
|
|
openAccountDialog,
|
|
closeAccountDialog,
|
|
persistAvatar,
|
|
persistProfile,
|
|
assessSavedBirthTime,
|
|
saveProfile,
|
|
saveOnboardingName,
|
|
saveOnboardingBirth,
|
|
editDeclaredBirthTimeDetails,
|
|
saveOnboardingPlace,
|
|
completeGuidedBirthTime,
|
|
retryBirthTimeAssessment,
|
|
signOut,
|
|
};
|
|
}
|