diff --git a/src/components/Componentes/auth-data-boundary.tsx b/src/components/Componentes/auth-data-boundary.tsx index c6f8087..038c398 100644 --- a/src/components/Componentes/auth-data-boundary.tsx +++ b/src/components/Componentes/auth-data-boundary.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, type ReactNode } from "react"; import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; import { HABIB_AUTH_TOKEN_CHANGED_EVENT } from "@/lib/auth-bridge"; import { + clearScopedMarriageProfiles, isLegacyMigrationDone, migrateLegacyStorageKeys, } from "@/lib/user-scoped-storage"; @@ -83,6 +84,9 @@ export default function AuthDataBoundary({ return kind !== "config"; }, }); + + // 3. Clear persisted local profile cache + clearScopedMarriageProfiles(); }; window.addEventListener(HABIB_AUTH_TOKEN_CHANGED_EVENT, handleAuthChange); diff --git a/src/hooks/marriage/use-profile-main.ts b/src/hooks/marriage/use-profile-main.ts index d433562..84e8fa5 100644 --- a/src/hooks/marriage/use-profile-main.ts +++ b/src/hooks/marriage/use-profile-main.ts @@ -9,6 +9,10 @@ import { hasCompletedMarriageProfileBasics, } from "@/lib/get-submit-path"; import { http } from "@/lib/http"; +import { + getScopedMarriageProfile, + saveScopedMarriageProfile, +} from "@/lib/user-scoped-storage"; import type { QueryOptions } from "./options"; import { marriageQueryKeys } from "./query-keys"; import type { MarriageProfileResponse } from "./types"; @@ -16,7 +20,33 @@ import type { MarriageProfileResponse } from "./types"; export function getInitialMarriageProfile( dataOverride?: any, ): MarriageProfileResponse | undefined { - const data = dataOverride ?? authBridge.getMarriageData(); + if (dataOverride) { + const data = dataOverride; + return { + id: data.id ?? 0, + status: data.status, + gender: data.gender, + age: data.age ?? null, + is_registering_for_self: data.is_registering_for_self ?? null, + can_edit_profile: data.can_edit_profile ?? true, + can_message_expert: data.can_message_expert ?? true, + active_subscription: data.active_subscription ?? null, + is_ready_for_match: data.is_ready_for_match ?? true, + active_case: data.active_case ?? null, + needs_subscription: data.needs_subscription ?? false, + recommended_plan: data.recommended_plan ?? null, + match_summary: data.match_summary ?? null, + } as MarriageProfileResponse; + } + + // Priority 2: Use locally persisted profile from previous successful server response + const persisted = getScopedMarriageProfile(); + if (persisted) { + return persisted; + } + + // Priority 3: Fallback to Flutter native bridge data + const data = authBridge.getMarriageData(); if (!data) return undefined; return { @@ -55,6 +85,9 @@ export async function getMarriageProfile() { raw_match_summary: data?.match_summary, }); + // Persist latest verified profile response locally for instant 0ms hydration + saveScopedMarriageProfile(data); + setCachedMarriageEntryPath( hasCompletedMarriageProfileBasics(data) ? getSubmitPath(data) : "/intro", ); diff --git a/src/lib/user-scoped-storage.test.ts b/src/lib/user-scoped-storage.test.ts index e90004b..6e4b005 100644 --- a/src/lib/user-scoped-storage.test.ts +++ b/src/lib/user-scoped-storage.test.ts @@ -14,6 +14,9 @@ import { isDraftOwnedBy, getAllScopedSectionDraftKeys, extractSlugFromScopedKey, + saveScopedMarriageProfile, + getScopedMarriageProfile, + clearScopedMarriageProfiles, } from "./user-scoped-storage"; describe("user-scoped-storage", () => { @@ -160,4 +163,51 @@ describe("user-scoped-storage", () => { expect(isDraftOwnedBy(undefined, 99)).toBe(false); }); }); + + describe("Scoped marriage profile caching", () => { + const mockProfile = { + id: 241, + status: "in_case", + gender: "male", + age: 30, + is_registering_for_self: true, + can_edit_profile: true, + can_message_expert: true, + active_subscription: { is_active: true, plan: "gold" }, + is_ready_for_match: true, + active_case: null, + needs_subscription: false, + recommended_plan: null, + match_summary: null, + } as any; + + it("saves and retrieves scoped marriage profile correctly", () => { + saveScopedMarriageProfile(mockProfile); + + // Read latest + const latest = getScopedMarriageProfile(); + expect(latest).not.toBeNull(); + expect(latest?.id).toBe(241); + expect(latest?.status).toBe("in_case"); + expect(latest?.active_subscription?.plan).toBe("gold"); + + // Read by expected profile ID + const byId = getScopedMarriageProfile(241); + expect(byId?.id).toBe(241); + + // Read by wrong profile ID returns null + const wrongId = getScopedMarriageProfile(999); + expect(wrongId).toBeNull(); + }); + + it("clears persisted profiles on auth/token change", () => { + saveScopedMarriageProfile(mockProfile); + expect(getScopedMarriageProfile()).not.toBeNull(); + + clearScopedMarriageProfiles(); + expect(getScopedMarriageProfile()).toBeNull(); + expect(getScopedMarriageProfile(241)).toBeNull(); + }); + }); }); + diff --git a/src/lib/user-scoped-storage.ts b/src/lib/user-scoped-storage.ts index 1754649..0b7eeb8 100644 --- a/src/lib/user-scoped-storage.ts +++ b/src/lib/user-scoped-storage.ts @@ -8,6 +8,8 @@ * The raw auth token is NEVER used as a key component. */ +import type { MarriageProfileResponse } from "@/hooks/marriage/types"; + export const SCOPED_STORAGE_VERSION = 3; // ─── Types ─────────────────────────────────────────────────────────── @@ -283,3 +285,83 @@ export function extractSlugFromScopedKey(key: string): string | null { const match = key.match(/^marriage:user:\d+:sections:([^:]+):draft:v\d+$/); return match ? match[1] : null; } + +// ─── Scoped Marriage Profile Cache ───────────────────────────────────── + +export interface ScopedMarriageProfileData { + version: typeof SCOPED_STORAGE_VERSION; + ownerProfileId: number; + profile: MarriageProfileResponse; + savedAt: number; +} + +export function getScopedMarriageProfileKey(profileId: number): string { + return `marriage:user:${profileId}:profile:v${SCOPED_STORAGE_VERSION}`; +} + +export const LATEST_PROFILE_STORAGE_KEY = `marriage:profile:latest:v${SCOPED_STORAGE_VERSION}`; + +/** + * Persist the latest full MarriageProfileResponse from the server. + */ +export function saveScopedMarriageProfile(profile: MarriageProfileResponse): void { + if (typeof window === "undefined" || !profile?.id) return; + try { + const payload: ScopedMarriageProfileData = { + version: SCOPED_STORAGE_VERSION, + ownerProfileId: profile.id, + profile, + savedAt: Date.now(), + }; + const serialized = JSON.stringify(payload); + window.localStorage.setItem(getScopedMarriageProfileKey(profile.id), serialized); + window.localStorage.setItem(LATEST_PROFILE_STORAGE_KEY, serialized); + } catch {} +} + +/** + * Retrieve the persisted MarriageProfileResponse if valid. + */ +export function getScopedMarriageProfile( + expectedProfileId?: number, +): MarriageProfileResponse | null { + if (typeof window === "undefined") return null; + try { + const key = expectedProfileId + ? getScopedMarriageProfileKey(expectedProfileId) + : LATEST_PROFILE_STORAGE_KEY; + const raw = window.localStorage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw) as ScopedMarriageProfileData; + if (!parsed || parsed.version !== SCOPED_STORAGE_VERSION || !parsed.profile) { + return null; + } + if (expectedProfileId && parsed.ownerProfileId !== expectedProfileId) { + return null; + } + return parsed.profile; + } catch { + return null; + } +} + +/** + * Clear all persisted marriage profile caches (used on auth token/account change). + */ +export function clearScopedMarriageProfiles(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(LATEST_PROFILE_STORAGE_KEY); + const keysToRemove: string[] = []; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (key && key.startsWith("marriage:user:") && key.includes(":profile:")) { + keysToRemove.push(key); + } + } + for (const key of keysToRemove) { + window.localStorage.removeItem(key); + } + } catch {} +} +