diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b820a6d..364f374 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -221,9 +221,19 @@ export default async function RootLayout({ if (!config) return; configApplied = true; window.__HABIB_BOOTSTRAP__ = config; - if (config.marriage) { - window.__HABIB_MARRIAGE_INITIAL_DATA__ = config.marriage; - window.dispatchEvent(new CustomEvent('habib:marriage-initial-data', { detail: config.marriage })); + var marriageData = + config.marriage || + config.marriageData || + config.marriage_data || + config.profile || + (config.data && (config.data.marriage || config.data.profile || config.data.marriage_data)) || + (config.payload && (config.payload.marriage || config.payload.profile || config.payload.marriage_data)); + + if (marriageData) { + console.log('⚡ [Layout Bootstrap] Applying marriage initial data from Flutter:', marriageData); + window.__HABIB_MARRIAGE_INITIAL_DATA__ = marriageData; + window.HABIB_MARRIAGE = marriageData; + window.dispatchEvent(new CustomEvent('habib:marriage-initial-data', { detail: marriageData })); } var safe = config.safeArea || {}; root.style.setProperty('--safe-top', (Number(safe.top) || 0) + 'px'); @@ -256,9 +266,9 @@ export default async function RootLayout({ } window.addFlutterResponseListener(function(event) { - var action = String(event && event.action || '').toLowerCase(); - if (event && event.success !== false && action === 'initial_config') { - apply(event.data || event.payload); + var action = String(event && (event.action || event.type) || '').toLowerCase(); + if (event && event.success !== false && (action === 'initial_config' || action === 'initialconfig')) { + apply(event.data || event.payload || event); } }); diff --git a/src/app/new-match/new-match-client.tsx b/src/app/new-match/new-match-client.tsx index d1f1d40..89ad7cb 100644 --- a/src/app/new-match/new-match-client.tsx +++ b/src/app/new-match/new-match-client.tsx @@ -739,7 +739,7 @@ export default function NewMatchClient() { {/* 2. Match Summary Card Section */}
- {isLoading && !matchSummary ? ( + {(isLoading || (!matchSummary && isFetching)) ? (
diff --git a/src/components/Componentes/information-sheet.test.tsx b/src/components/Componentes/information-sheet.test.tsx index 43a6b8c..e58840a 100644 --- a/src/components/Componentes/information-sheet.test.tsx +++ b/src/components/Componentes/information-sheet.test.tsx @@ -64,7 +64,7 @@ describe("InformationSheet", () => { ); await waitFor(() => { - expect(section?.style.transform).toContain("translate3d"); + expect(section?.style.paddingBottom).toContain("280px"); }); }); @@ -97,7 +97,7 @@ describe("InformationSheet", () => { ); await waitFor(() => { - expect(section?.style.transform).toBe(""); + expect(section?.style.paddingBottom).toBe(""); }); }); }); diff --git a/src/components/Componentes/information-sheet.tsx b/src/components/Componentes/information-sheet.tsx index 69c2728..2c4a411 100644 --- a/src/components/Componentes/information-sheet.tsx +++ b/src/components/Componentes/information-sheet.tsx @@ -333,8 +333,6 @@ export function InformationSheet({ let keyboardHeight = 0; let lastKeyboardHeight = 0; let activeInput: HTMLElement | null = null; - let currentLift = 0; - let animFrame: number | null = null; let closedVpHeight = window.visualViewport?.height ?? window.innerHeight; const getDefaultKeyboardHeight = (): number => { @@ -345,63 +343,8 @@ export function InformationSheet({ }; const setLift = (nextLift: number) => { - currentLift = Math.max(0, Math.round(nextLift)); - setKeyboardLift(currentLift); - }; - - const updateLift = () => { - const sheet = sheetRef.current; - if (!sheet || !keyboardVisible || !activeInput) { - setLift(0); - return; - } - - // 1. Calculate keyboard visible bottom limit - const fullKeyboardHeight = - lastKeyboardHeight > 200 - ? lastKeyboardHeight - : getDefaultKeyboardHeight(); - const targetHeight = Math.max(keyboardHeight, fullKeyboardHeight); - - const flutterTop = window.innerHeight - targetHeight; - const viewport = window.visualViewport; - const viewportBottom = viewport - ? viewport.offsetTop + viewport.height - : window.innerHeight; - const visibleBottom = Math.min(flutterTop, viewportBottom); - - // 2. Calculate sheet base geometry (without current lift) - const sheetRect = sheet.getBoundingClientRect(); - const baseBottom = sheetRect.bottom + currentLift; - const baseTop = sheetRect.top + currentLift; - - const safeTop = - parseFloat( - getComputedStyle(document.documentElement).getPropertyValue( - "--safe-top", - ) || "0", - ) || 16; - const visibleTop = safeTop + 8; - - // 3. Compute required lift to ensure sheet bottom clears keyboard - const overlap = baseBottom - visibleBottom; - if (overlap <= 0) { - setLift(0); - return; - } - - const maxShift = Math.max(0, baseTop - visibleTop); - const computedLift = Math.min(overlap, maxShift); - setLift(computedLift); - }; - - const scheduleUpdate = () => { - if (animFrame !== null) cancelAnimationFrame(animFrame); - animFrame = requestAnimationFrame(() => { - animFrame = null; - updateLift(); - }); - updateLift(); + const lift = Math.max(0, Math.round(nextLift)); + setKeyboardLift(lift); }; const handleFocusIn = (e: FocusEvent) => { @@ -416,7 +359,15 @@ export function InformationSheet({ ? lastKeyboardHeight : getDefaultKeyboardHeight(); keyboardHeight = Math.max(keyboardHeight, expectedHeight); - scheduleUpdate(); + setLift(keyboardHeight); + setTimeout(() => { + if (typeof (e.target as any)?.scrollIntoView === "function") { + (e.target as any).scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + } + }, 100); } }; @@ -427,12 +378,10 @@ export function InformationSheet({ sheetRef.current?.contains(active) && isKeyboardInputTarget(active) ? (active as HTMLElement) : null; - if (!activeInput) { + if (!activeInput && (viewPaddingsBridge.getConfig().keyboardHeight || 0) === 0) { keyboardVisible = false; keyboardHeight = 0; setLift(0); - } else { - scheduleUpdate(); } }, 50); }; @@ -441,31 +390,14 @@ export function InformationSheet({ const nextHeight = Math.max(0, config.keyboardHeight); if (nextHeight > 0) { keyboardVisible = true; - const defaultKHeight = getDefaultKeyboardHeight(); - const expectedHeight = - lastKeyboardHeight > 200 ? lastKeyboardHeight : defaultKHeight; - - if (activeInput && nextHeight < expectedHeight - 15) { - keyboardHeight = expectedHeight; - } else { - lastKeyboardHeight = Math.max(lastKeyboardHeight, nextHeight); - keyboardHeight = nextHeight; - } - - const active = document.activeElement as HTMLElement | null; - if ( - !activeInput && - active && - sheetRef.current?.contains(active) && - isKeyboardInputTarget(active) - ) { - activeInput = active; - } + keyboardHeight = nextHeight; + lastKeyboardHeight = Math.max(lastKeyboardHeight, nextHeight); + setLift(nextHeight); } else { keyboardHeight = 0; keyboardVisible = false; + setLift(0); } - scheduleUpdate(); }); const handleViewportResize = () => { @@ -475,19 +407,18 @@ export function InformationSheet({ if (reduction > KEYBOARD_THRESHOLD) { if (activeInput) { keyboardVisible = true; - if (reduction > 250) { - lastKeyboardHeight = Math.max(lastKeyboardHeight, reduction); - keyboardHeight = reduction; - } + keyboardHeight = reduction; + lastKeyboardHeight = Math.max(lastKeyboardHeight, reduction); + setLift(reduction); } } else if (reduction <= KEYBOARD_THRESHOLD) { keyboardVisible = false; keyboardHeight = 0; + setLift(0); if (!activeInput) { closedVpHeight = vpHeight; } } - scheduleUpdate(); }; document.addEventListener("focusin", handleFocusIn); @@ -495,7 +426,6 @@ export function InformationSheet({ window.visualViewport?.addEventListener("resize", handleViewportResize); return () => { - if (animFrame !== null) cancelAnimationFrame(animFrame); document.removeEventListener("focusin", handleFocusIn); document.removeEventListener("focusout", handleFocusOut); window.visualViewport?.removeEventListener( @@ -554,16 +484,16 @@ export function InformationSheet({ ref={sheetRef} {...props} style={{ - transform: + paddingBottom: keyboardLift > 0 - ? `translate3d(0, -${keyboardLift}px, 0)` + ? `calc(max(24px, calc(24px + var(--safe-bottom))) + ${keyboardLift}px)` : undefined, - transition: "transform 200ms cubic-bezier(0.16, 1, 0.3, 1)", - animation: keyboardLift > 0 ? "none" : undefined, + transition: "padding-bottom 200ms cubic-bezier(0.16, 1, 0.3, 1)", + maxHeight: "calc(100dvh - var(--safe-top) - 16px)", ...props.style, }} className={[ - "relative w-full max-w-[834px] sm:max-w-[540px] rounded-t-[22px] bg-white px-4 pt-4 pb-[max(24px,calc(24px+var(--safe-bottom)))] text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] max-h-[calc(100dvh-var(--safe-top)-16px)] overflow-y-auto", + "relative w-full max-w-[834px] sm:max-w-[540px] rounded-t-[22px] bg-white px-4 pt-4 pb-[max(24px,calc(24px+var(--safe-bottom)))] text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] overflow-y-auto", isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface", className, ] diff --git a/src/hooks/marriage/use-profile-main.test.ts b/src/hooks/marriage/use-profile-main.test.ts index cd09895..7846aa7 100644 --- a/src/hooks/marriage/use-profile-main.test.ts +++ b/src/hooks/marriage/use-profile-main.test.ts @@ -1,9 +1,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getMarriageProfile } from "./use-profile-main"; +import { + getInitialMarriageProfile, + getMarriageProfile, +} from "./use-profile-main"; const mocks = vi.hoisted(() => ({ get: vi.fn(), setCachedEntryPath: vi.fn(), + getMarriageData: vi.fn(), + getScopedMarriageProfile: vi.fn(), + saveScopedMarriageProfile: vi.fn(), })); vi.mock("@/lib/http", () => ({ @@ -14,27 +20,42 @@ vi.mock("@/lib/entry-route-cache", () => ({ setCachedMarriageEntryPath: mocks.setCachedEntryPath, })); +vi.mock("@/lib/auth-bridge", () => ({ + authBridge: { + getMarriageData: () => mocks.getMarriageData(), + }, +})); + +vi.mock("@/lib/user-scoped-storage", () => ({ + getScopedMarriageProfile: () => mocks.getScopedMarriageProfile(), + saveScopedMarriageProfile: (p: any) => mocks.saveScopedMarriageProfile(p), +})); + describe("getMarriageProfile", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.getMarriageData.mockReturnValue(null); + mocks.getScopedMarriageProfile.mockReturnValue(null); }); it("persists the resolved route for a completed profile", async () => { const profile = { + id: 1, status: "pending_info", gender: "female", is_registering_for_self: true, }; mocks.get.mockResolvedValue({ data: profile }); - await expect(getMarriageProfile()).resolves.toBe(profile); - + const result = await getMarriageProfile(); + expect(result.status).toBe("pending_info"); expect(mocks.setCachedEntryPath).toHaveBeenCalledWith("/questions-list"); }); it("caches the intro route for an incomplete onboarding profile", async () => { mocks.get.mockResolvedValue({ data: { + id: 2, status: "pending_onboarding", gender: null, is_registering_for_self: null, @@ -50,6 +71,7 @@ describe("getMarriageProfile", () => { mocks.get .mockResolvedValueOnce({ data: { + id: 3, status: "pending_info", gender: "male", is_registering_for_self: true, @@ -57,6 +79,7 @@ describe("getMarriageProfile", () => { }) .mockResolvedValueOnce({ data: { + id: 3, status: "waiting", gender: "male", is_registering_for_self: true, @@ -76,3 +99,102 @@ describe("getMarriageProfile", () => { ); }); }); + +describe("getInitialMarriageProfile", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getMarriageData.mockReturnValue(null); + mocks.getScopedMarriageProfile.mockReturnValue(null); + }); + + it("uses Flutter initial data with match summary when local storage is empty", () => { + const flutterPayload = { + id: 101, + gender: "male", + status: "match_found", + match_summary: { + id: 202, + gender: "female", + overall_completion_percent: 85, + public_info: [ + { key: "first_name", label: "Name", value: "Fatima" }, + { key: "age", label: "Age", value: "24" }, + { key: "city", label: "City", value: "Tehran" }, + ], + }, + }; + + mocks.getMarriageData.mockReturnValue(flutterPayload); + mocks.getScopedMarriageProfile.mockReturnValue(null); + + const initial = getInitialMarriageProfile(); + + expect(initial).toBeDefined(); + expect(initial?.id).toBe(101); + expect(initial?.status).toBe("match_found"); + expect(initial?.match_summary?.id).toBe(202); + expect(initial?.match_summary?.public_info).toHaveLength(3); + expect(initial?.match_summary?.public_info[0].value).toBe("Fatima"); + }); + + it("normalizes camelCase Flutter initial data with matchSummary and publicInfo", () => { + const flutterCamelPayload = { + profileId: 105, + gender: "male", + status: "match_found", + matchSummary: { + candidateId: 303, + gender: "female", + overallCompletionPercent: 90, + publicInfo: [ + { fieldKey: "first_name", title: "Name", val: "Sara" }, + { fieldKey: "job", title: "Job", val: "Designer" }, + ], + }, + }; + + mocks.getMarriageData.mockReturnValue(flutterCamelPayload); + mocks.getScopedMarriageProfile.mockReturnValue(null); + + const initial = getInitialMarriageProfile(); + + expect(initial).toBeDefined(); + expect(initial?.id).toBe(105); + expect(initial?.match_summary).toBeDefined(); + expect(initial?.match_summary?.id).toBe(303); + expect(initial?.match_summary?.public_info).toHaveLength(2); + expect(initial?.match_summary?.public_info[0].key).toBe("first_name"); + expect(initial?.match_summary?.public_info[0].value).toBe("Sara"); + expect(initial?.match_summary?.public_info[1].value).toBe("Designer"); + }); + + it("prefers Flutter data if Flutter has match_summary and local storage does not", () => { + mocks.getScopedMarriageProfile.mockReturnValue({ + id: 101, + status: "match_found", + match_summary: null, + }); + + mocks.getMarriageData.mockReturnValue({ + id: 101, + status: "match_found", + match_summary: { + id: 202, + gender: "female", + public_info: [{ key: "first_name", label: "Name", value: "Zahra" }], + }, + }); + + const initial = getInitialMarriageProfile(); + expect(initial?.match_summary?.id).toBe(202); + expect(initial?.match_summary?.public_info[0].value).toBe("Zahra"); + }); + + it("returns undefined if neither Flutter nor local storage has data", () => { + mocks.getMarriageData.mockReturnValue(null); + mocks.getScopedMarriageProfile.mockReturnValue(null); + + const initial = getInitialMarriageProfile(); + expect(initial).toBeUndefined(); + }); +}); diff --git a/src/hooks/marriage/use-profile-main.ts b/src/hooks/marriage/use-profile-main.ts index 340b051..40b42da 100644 --- a/src/hooks/marriage/use-profile-main.ts +++ b/src/hooks/marriage/use-profile-main.ts @@ -9,6 +9,7 @@ import { hasCompletedMarriageProfileBasics, } from "@/lib/get-submit-path"; import { http } from "@/lib/http"; +import { normalizeMarriageProfile } from "@/lib/marriage-profile-contract"; import { getScopedMarriageProfile, saveScopedMarriageProfile, @@ -21,78 +22,59 @@ export function getInitialMarriageProfile( dataOverride?: any, ): MarriageProfileResponse | undefined { 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; + const normalized = normalizeMarriageProfile(dataOverride); + if (normalized) return normalized; } - const flutterData = authBridge.getMarriageData(); - const persisted = getScopedMarriageProfile(); + const rawFlutterData = authBridge.getMarriageData(); + const flutterData = rawFlutterData ? normalizeMarriageProfile(rawFlutterData) : null; + const rawPersisted = getScopedMarriageProfile(); + const persisted = rawPersisted ? normalizeMarriageProfile(rawPersisted) : null; + + console.log("🔍 [getInitialMarriageProfile] Evaluating initial profile sources:", { + hasFlutterData: Boolean(flutterData), + flutterMatchSummary: Boolean(flutterData?.match_summary), + flutterPublicInfoCount: flutterData?.match_summary?.public_info?.length, + hasPersisted: Boolean(persisted), + persistedMatchSummary: Boolean(persisted?.match_summary), + persistedPublicInfoCount: persisted?.match_summary?.public_info?.length, + }); - // If Flutter provided match_summary (e.g. fresh match on new/current device) and persisted doesn't have it, prefer Flutter data + // Priority 1: If Flutter provided match_summary (e.g. fresh match on new/current device) and persisted doesn't have it, prefer Flutter data if (flutterData?.match_summary && !persisted?.match_summary) { + console.log("⚡ [getInitialMarriageProfile] Using Flutter initial data (has match_summary)"); return { - id: flutterData.id ?? persisted?.id ?? 0, - status: flutterData.status ?? persisted?.status, - gender: flutterData.gender ?? persisted?.gender, - age: flutterData.age ?? persisted?.age ?? null, - is_registering_for_self: flutterData.is_registering_for_self ?? persisted?.is_registering_for_self ?? null, - can_edit_profile: flutterData.can_edit_profile ?? persisted?.can_edit_profile ?? true, - can_message_expert: flutterData.can_message_expert ?? persisted?.can_message_expert ?? true, - active_subscription: flutterData.active_subscription ?? persisted?.active_subscription ?? null, - is_ready_for_match: flutterData.is_ready_for_match ?? persisted?.is_ready_for_match ?? true, - active_case: flutterData.active_case ?? persisted?.active_case ?? null, - needs_subscription: flutterData.needs_subscription ?? persisted?.needs_subscription ?? false, - recommended_plan: flutterData.recommended_plan ?? persisted?.recommended_plan ?? null, - match_summary: flutterData.match_summary ?? null, - } as MarriageProfileResponse; + ...flutterData, + id: flutterData.id || persisted?.id || 0, + status: flutterData.status || persisted?.status || "match_found", + }; } // Priority 2: Use locally persisted profile from previous successful server response if (persisted) { + console.log("⚡ [getInitialMarriageProfile] Using persisted profile from localStorage"); return persisted; } // Priority 3: Fallback to Flutter native bridge data - if (!flutterData) return undefined; + if (flutterData) { + console.log("⚡ [getInitialMarriageProfile] Using Flutter initial data fallback"); + return flutterData; + } - return { - id: flutterData.id ?? 0, - status: flutterData.status, - gender: flutterData.gender, - age: flutterData.age ?? null, - is_registering_for_self: flutterData.is_registering_for_self ?? null, - can_edit_profile: flutterData.can_edit_profile ?? true, - can_message_expert: flutterData.can_message_expert ?? true, - active_subscription: flutterData.active_subscription ?? null, - is_ready_for_match: flutterData.is_ready_for_match ?? true, - active_case: flutterData.active_case ?? null, - needs_subscription: flutterData.needs_subscription ?? false, - recommended_plan: flutterData.recommended_plan ?? null, - match_summary: flutterData.match_summary ?? null, - } as MarriageProfileResponse; + console.log("ℹ️ [getInitialMarriageProfile] No initial data available; starting fresh query"); + return undefined; } export async function getMarriageProfile() { console.log("🌐 [Profile API] Sending GET /api/marriage/profile/main/ ..."); try { - const { data } = await http.get( + const { data: rawData } = await http.get( "/api/marriage/profile/main/", ); + const data = normalizeMarriageProfile(rawData) || rawData; + console.log("🌐 [Profile API] Successfully received data:", { id: data?.id, status: data?.status, @@ -126,20 +108,35 @@ export function useMarriageProfileQuery( useEffect(() => { const handleInitialData = (e: any) => { - const data = e.detail; - if (data) { - queryClient.setQueryData( - marriageQueryKeys.profile(), - (old: MarriageProfileResponse | undefined) => { - if (!old) return getInitialMarriageProfile(data); - return { - ...old, - ...data, - match_summary: data.match_summary ?? old.match_summary, - active_case: data.active_case ?? old.active_case, - }; - }, - ); + const rawData = e.detail; + if (rawData) { + const data = normalizeMarriageProfile(rawData); + console.log("⚡ [useMarriageProfileQuery] Received habib:marriage-initial-data event:", { + hasData: Boolean(data), + hasMatchSummary: Boolean(data?.match_summary), + publicInfoCount: data?.match_summary?.public_info?.length, + }); + + if (data) { + queryClient.setQueryData( + marriageQueryKeys.profile(), + (old: MarriageProfileResponse | undefined) => { + if (!old) return data; + return { + ...old, + ...data, + match_summary: data.match_summary ?? old.match_summary, + active_case: data.active_case ?? old.active_case, + active_subscription: + data.active_subscription ?? old.active_subscription, + }; + }, + ); + + if (data.id && (data.match_summary || data.status)) { + saveScopedMarriageProfile(data); + } + } } }; diff --git a/src/lib/auth-bridge.test.ts b/src/lib/auth-bridge.test.ts index 43bbf9d..943b2f4 100644 --- a/src/lib/auth-bridge.test.ts +++ b/src/lib/auth-bridge.test.ts @@ -14,13 +14,24 @@ vi.mock("./entry-route-cache", () => ({ })); describe("authBridge", () => { + let listeners: Array<(e: any) => void> = []; + beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + listeners = []; mocks.getClientCookie.mockReturnValue(null); window.HABIB_TOKEN = undefined; - window.addFlutterResponseListener = vi.fn(() => vi.fn()); + window.addFlutterResponseListener = vi.fn((cb) => { + listeners.push(cb); + return () => { + listeners = listeners.filter((l) => l !== cb); + }; + }); window.sessionStorage.clear(); + delete (window as any).HABIB_MARRIAGE; + delete (window as any).__HABIB_MARRIAGE_INITIAL_DATA__; + delete (window as any).__HABIB_BOOTSTRAP__; }); it("treats a missing token as anonymous and clears the entry cache", async () => { @@ -36,4 +47,45 @@ describe("authBridge", () => { expect(authBridge.isAuthenticated()).toBe(false); }); + + it("captures marriage initial data from Flutter initial_config event", async () => { + const { authBridge } = await import("./auth-bridge"); + + const marriagePayload = { + id: 123, + status: "match_found", + match_summary: { id: 456, gender: "female", public_info: [] }, + }; + + // Simulate Flutter firing INITIAL_CONFIG event + listeners.forEach((listener) => { + listener({ + action: "INITIAL_CONFIG", + success: true, + data: { marriage: marriagePayload }, + }); + }); + + expect(authBridge.getMarriageData()).toEqual(marriagePayload); + expect((window as any).HABIB_MARRIAGE).toEqual(marriagePayload); + }); + + it("captures marriage data when passed via payload property instead of data", async () => { + const { authBridge } = await import("./auth-bridge"); + + const marriagePayload = { + id: 789, + status: "match_found", + }; + + listeners.forEach((listener) => { + listener({ + action: "initial_config", + success: true, + payload: { marriage: marriagePayload }, + }); + }); + + expect(authBridge.getMarriageData()).toEqual(marriagePayload); + }); }); diff --git a/src/lib/auth-bridge.ts b/src/lib/auth-bridge.ts index fb4542e..f113cc8 100644 --- a/src/lib/auth-bridge.ts +++ b/src/lib/auth-bridge.ts @@ -54,15 +54,25 @@ class AuthBridge { __HABIB_BOOTSTRAP__?: Record; }; + const bootstrapMarriage = + win.__HABIB_BOOTSTRAP__?.marriage || + win.__HABIB_BOOTSTRAP__?.marriage_data || + win.__HABIB_BOOTSTRAP__?.marriageData || + win.__HABIB_BOOTSTRAP__?.profile || + (win.__HABIB_BOOTSTRAP__?.data as any)?.marriage || + (win.__HABIB_BOOTSTRAP__?.data as any)?.profile || + (win.__HABIB_BOOTSTRAP__?.payload as any)?.marriage || + (win.__HABIB_BOOTSTRAP__?.payload as any)?.profile; + if ( win.HABIB_MARRIAGE || win.__HABIB_MARRIAGE_INITIAL_DATA__ || - win.__HABIB_BOOTSTRAP__?.marriage + bootstrapMarriage ) { this.marriageData = win.HABIB_MARRIAGE ?? win.__HABIB_MARRIAGE_INITIAL_DATA__ ?? - win.__HABIB_BOOTSTRAP__?.marriage ?? + bootstrapMarriage ?? null; } else { const rawMarriageCookie = @@ -122,19 +132,38 @@ class AuthBridge { this.flutterResponseUnsubscribe = win.addFlutterResponseListener( (event) => { - if (event.action === "login") { + const action = String( + event?.action || (event as any)?.type || "", + ).toLowerCase(); + + if (action === "login") { this.handleLoginResponse(event.success); } else if ( - event.action === "initial_config" && - event.data && - (event.data as any).marriage + action === "initial_config" || + action === "initialconfig" ) { - this.marriageData = (event.data as any).marriage; - window.dispatchEvent( - new CustomEvent("habib:marriage-initial-data", { - detail: this.marriageData, - }), - ); + const rawData = event.data || (event as any)?.payload || event; + const marriage = + rawData?.marriage || + rawData?.marriage_data || + rawData?.marriageData || + rawData?.profile || + rawData?.data?.marriage || + rawData?.data?.profile || + rawData?.payload?.marriage || + rawData?.payload?.profile; + + if (marriage) { + console.log("⚡ [AuthBridge] Extracted marriage initial data from Flutter:", marriage); + this.marriageData = marriage; + (window as any).__HABIB_MARRIAGE_INITIAL_DATA__ = marriage; + (window as any).HABIB_MARRIAGE = marriage; + window.dispatchEvent( + new CustomEvent("habib:marriage-initial-data", { + detail: this.marriageData, + }), + ); + } } }, ); diff --git a/src/lib/marriage-profile-contract.ts b/src/lib/marriage-profile-contract.ts index 60c32e5..c263823 100644 --- a/src/lib/marriage-profile-contract.ts +++ b/src/lib/marriage-profile-contract.ts @@ -1 +1,204 @@ +import type { + MarriageActiveCase, + MarriageActiveSubscription, + MarriageField, + MarriageMatchSummary, + MarriageProfileResponse, + MarriageRecommendedPlan, +} from "@/hooks/marriage/types"; + export const REQUIRED_PROFILE_SECTION_COUNT = 10; + +/** + * Normalizes a single MarriageField from various potential backend or Flutter bridge formats. + */ +export function normalizeMarriageField(raw: any): MarriageField | null { + if (!raw || typeof raw !== "object") return null; + const key = raw.key ?? raw.field_key ?? raw.fieldKey ?? raw.name ?? ""; + const label = raw.label ?? raw.title ?? ""; + const value = raw.value ?? raw.val ?? ""; + return { + key: String(key), + label: String(label), + value: value ?? "", + option_id: raw.option_id ?? raw.optionId ?? null, + private: Boolean(raw.private ?? raw.is_private ?? false), + }; +} + +/** + * Normalizes a match_summary object supporting snake_case, camelCase, and candidate structures. + */ +export function normalizeMatchSummary(raw: any): MarriageMatchSummary | null { + if (!raw || typeof raw !== "object") return null; + + const rawPublicInfo = + raw.public_info ?? + raw.publicInfo ?? + raw.fields ?? + raw.public_fields ?? + raw.publicFields ?? + []; + + const publicInfo: MarriageField[] = Array.isArray(rawPublicInfo) + ? rawPublicInfo + .map(normalizeMarriageField) + .filter((f): f is MarriageField => f !== null) + : []; + + const rawId = raw.id ?? raw.candidate_id ?? raw.candidateId ?? 0; + const rawGender = raw.gender ?? "female"; + const rawPercent = + raw.overall_completion_percent ?? + raw.overallCompletionPercent ?? + raw.completion_percent ?? + raw.completionPercent ?? + 0; + + return { + id: Number(rawId) || 0, + gender: rawGender, + overall_completion_percent: Number(rawPercent) || 0, + public_info: publicInfo, + }; +} + +/** + * Normalizes a full MarriageProfileResponse from server, Flutter bridge, or localStorage. + * Guarantees consistent snake_case format and type safety. + */ +export function normalizeMarriageProfile( + raw: any, +): MarriageProfileResponse | null { + if (!raw || typeof raw !== "object") return null; + + // Handle nested data wrappers e.g. { marriage: ... }, { profile: ... }, or { data: { marriage: ... } } + const data = + raw.marriage ?? + raw.profile ?? + raw.data?.marriage ?? + raw.data?.profile ?? + raw.data ?? + raw; + + if (!data || typeof data !== "object") return null; + + const rawMatch = + data.match_summary ?? + data.matchSummary ?? + data.match ?? + data.candidate ?? + null; + const match_summary = normalizeMatchSummary(rawMatch); + + const rawActiveCase = data.active_case ?? data.activeCase ?? null; + const active_case: MarriageActiveCase | null = + rawActiveCase && typeof rawActiveCase === "object" + ? { + case_id: String( + rawActiveCase.case_id ?? + rawActiveCase.caseId ?? + rawActiveCase.id ?? + "", + ), + status: + rawActiveCase.status ?? + rawActiveCase.case_status ?? + rawActiveCase.caseStatus ?? + "", + other_action: + rawActiveCase.other_action ?? + rawActiveCase.otherAction ?? + undefined, + } + : null; + + const rawActiveSub = + data.active_subscription ?? data.activeSubscription ?? null; + const active_subscription: MarriageActiveSubscription | null = + rawActiveSub && typeof rawActiveSub === "object" + ? { + is_active: Boolean( + rawActiveSub.is_active ?? + rawActiveSub.isActive ?? + rawActiveSub.valid ?? + true, + ), + is_valid: Boolean( + rawActiveSub.is_valid ?? + rawActiveSub.isValid ?? + rawActiveSub.valid ?? + true, + ), + plan_title: + rawActiveSub.plan_title ?? rawActiveSub.planTitle ?? undefined, + expires_at: + rawActiveSub.expires_at ?? rawActiveSub.expiresAt ?? undefined, + } + : null; + + const rawRecommendedPlan = + data.recommended_plan ?? data.recommendedPlan ?? null; + const recommended_plan: MarriageRecommendedPlan | null = + rawRecommendedPlan && typeof rawRecommendedPlan === "object" + ? { + id: Number(rawRecommendedPlan.id ?? 1), + title: String(rawRecommendedPlan.title ?? ""), + price: Number(rawRecommendedPlan.price ?? 50), + discounted_price: + rawRecommendedPlan.discounted_price !== undefined + ? Number(rawRecommendedPlan.discounted_price) + : rawRecommendedPlan.discountedPrice !== undefined + ? Number(rawRecommendedPlan.discountedPrice) + : undefined, + discount_percent: + rawRecommendedPlan.discount_percent !== undefined + ? Number(rawRecommendedPlan.discount_percent) + : rawRecommendedPlan.discountPercent !== undefined + ? Number(rawRecommendedPlan.discountPercent) + : undefined, + } + : null; + + return { + id: Number(data.id ?? data.profile_id ?? data.profileId ?? 0), + status: data.status ?? "pending_onboarding", + gender: data.gender ?? null, + age: data.age !== undefined && data.age !== null ? Number(data.age) : null, + is_registering_for_self: + data.is_registering_for_self !== undefined + ? Boolean(data.is_registering_for_self) + : data.isRegisteringForSelf !== undefined + ? Boolean(data.isRegisteringForSelf) + : null, + can_edit_profile: + data.can_edit_profile !== undefined + ? Boolean(data.can_edit_profile) + : data.canEditProfile !== undefined + ? Boolean(data.canEditProfile) + : true, + can_message_expert: + data.can_message_expert !== undefined + ? Boolean(data.can_message_expert) + : data.canMessageExpert !== undefined + ? Boolean(data.canMessageExpert) + : true, + active_subscription, + is_ready_for_match: + data.is_ready_for_match !== undefined + ? Boolean(data.is_ready_for_match) + : data.isReadyForMatch !== undefined + ? Boolean(data.isReadyForMatch) + : true, + active_case, + needs_subscription: Boolean( + data.needs_subscription ?? data.needsSubscription ?? false, + ), + recommended_plan, + match_summary, + intro_video_url: data.intro_video_url ?? data.introVideoUrl ?? null, + intro_video_thumbnail_url: + data.intro_video_thumbnail_url ?? data.introVideoThumbnailUrl ?? null, + unseen_rejection: data.unseen_rejection ?? data.unseenRejection ?? null, + }; +}