Browse Source

refactor: implement Flutter marriage data synchronization and improve keyboard visibility handling in information sheet

master
mortezaei 2 weeks ago
parent
commit
2cc684ce48
  1. 22
      src/app/layout.tsx
  2. 2
      src/app/new-match/new-match-client.tsx
  3. 4
      src/components/Componentes/information-sheet.test.tsx
  4. 120
      src/components/Componentes/information-sheet.tsx
  5. 128
      src/hooks/marriage/use-profile-main.test.ts
  6. 125
      src/hooks/marriage/use-profile-main.ts
  7. 54
      src/lib/auth-bridge.test.ts
  8. 53
      src/lib/auth-bridge.ts
  9. 203
      src/lib/marriage-profile-contract.ts

22
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);
}
});

2
src/app/new-match/new-match-client.tsx

@ -739,7 +739,7 @@ export default function NewMatchClient() {
{/* 2. Match Summary Card Section */}
<div className="w-full shrink-0">
{isLoading && !matchSummary ? (
{(isLoading || (!matchSummary && isFetching)) ? (
<section className="relative overflow-hidden rounded-[24px] bg-[#FAFBFD] border border-[#F2E5E8] shadow-[0_4px_24px_rgba(0,0,0,0.04)] text-start">
<div className="relative h-[74px] w-full bg-[linear-gradient(180deg,#FCE2E6_0%,#FFD6DC_100%)]" />

4
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("");
});
});
});

120
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,
]

128
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();
});
});

125
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<MarriageProfileResponse>(
const { data: rawData } = await http.get<MarriageProfileResponse>(
"/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<TData = MarriageProfileResponse>(
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);
}
}
}
};

54
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);
});
});

53
src/lib/auth-bridge.ts

@ -54,15 +54,25 @@ class AuthBridge {
__HABIB_BOOTSTRAP__?: Record<string, any>;
};
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,
}),
);
}
}
},
);

203
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,
};
}
Loading…
Cancel
Save