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,
+ };
+}