diff --git a/src/app/questions-list/questions-list-client.tsx b/src/app/questions-list/questions-list-client.tsx
index b838db4..4aa2ba8 100644
--- a/src/app/questions-list/questions-list-client.tsx
+++ b/src/app/questions-list/questions-list-client.tsx
@@ -52,6 +52,7 @@ import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch";
import DevTapInstrumentation from "@/components/Componentes/dev-tap-instrumentation";
+import { fetchGeoCountryCode } from "@/components/Componentes/question-phone";
import SectionsRequest from "./sections-request";
import SectionOverlayHost from "@/components/Componentes/section-overlay-host";
import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client";
@@ -101,6 +102,11 @@ export default function QuestionsListClient() {
}
}, [isProfileRedirecting, locale, profileTargetPath, router]);
+ // Background prefetch user's geo country code so phone question is pre-warmed
+ useEffect(() => {
+ void fetchGeoCountryCode();
+ }, []);
+
// Signal Flutter to lift its loading cover once the profile is available
// (either from SSR hydration or client-side fetch).
useHabibWebReady(!!profile && !isProfileLoading);
diff --git a/src/components/Componentes/question-phone.test.tsx b/src/components/Componentes/question-phone.test.tsx
index c9c18e3..163bc81 100644
--- a/src/components/Componentes/question-phone.test.tsx
+++ b/src/components/Componentes/question-phone.test.tsx
@@ -15,6 +15,14 @@ const mockSetAnswerValue = vi.fn((q: QuestionField, val: unknown) => {
answerMap[q.id] = val;
});
+const httpMocks = vi.hoisted(() => ({
+ get: vi.fn(),
+}));
+
+vi.mock("@/lib/http", () => ({
+ http: { get: httpMocks.get },
+}));
+
vi.mock("@/translations/provider", () => ({
useI18n: () => ({
locale: "en",
@@ -62,8 +70,6 @@ const phoneQuestion2: QuestionField = {
options: [],
};
-
-
describe("QuestionPhone IP country detection and shimmer", () => {
beforeEach(() => {
answerMap = {};
@@ -71,13 +77,47 @@ describe("QuestionPhone IP country detection and shimmer", () => {
localStorage.clear();
resetGeoPhoneStateForTesting();
vi.restoreAllMocks();
+ httpMocks.get.mockReset();
});
afterEach(() => {
cleanup();
});
- it("renders shimmer on country button while IP request is pending, then shows resolved country code", async () => {
+ it("renders single unified shimmer on country button while IP request is pending, then shows resolved country code from Habib user region API", async () => {
+ let resolveRegion!: (value: unknown) => void;
+ const regionPromise = new Promise((resolve) => {
+ resolveRegion = resolve;
+ });
+
+ httpMocks.get.mockReturnValue(regionPromise);
+
+ const { container } = render();
+
+ const shimmerElements = container.querySelectorAll(".shimmer-bg");
+ expect(shimmerElements.length).toBe(1);
+ const input = screen.getByRole("textbox");
+ expect(input.classList.contains("shimmer-bg")).toBe(false);
+
+ await act(async () => {
+ resolveRegion({
+ data: {
+ country: "Iran",
+ country_code: "IR",
+ },
+ });
+ });
+
+ await waitFor(() => {
+ expect(container.querySelectorAll(".shimmer-bg").length).toBe(0);
+ expect(screen.getByText("+98")).toBeDefined();
+ expect(screen.getByText("🇮🇷")).toBeDefined();
+ });
+ });
+
+ it("falls back to secondary fetch when Habib region API fails and shows resolved code", async () => {
+ httpMocks.get.mockRejectedValue(new Error("Network failure"));
+
let resolveIpFetch!: (value: unknown) => void;
const ipPromise = new Promise((resolve) => {
resolveIpFetch = resolve;
@@ -95,11 +135,6 @@ describe("QuestionPhone IP country detection and shimmer", () => {
const { container } = render();
- const shimmerElements = container.querySelectorAll(".shimmer-bg");
- expect(shimmerElements.length).toBeGreaterThan(0);
- const input = screen.getByRole("textbox");
- expect(input.classList.contains("shimmer-bg")).toBe(false);
-
await act(async () => {
resolveIpFetch({ country_calling_code: "+98" });
});
@@ -111,7 +146,8 @@ describe("QuestionPhone IP country detection and shimmer", () => {
});
});
- it("shows default country code when IP request fails", async () => {
+ it("shows default country code when all IP requests fail", async () => {
+ httpMocks.get.mockRejectedValue(new Error("Network failure"));
vi.spyOn(globalThis, "fetch").mockRejectedValue(
new Error("Network failure"),
);
@@ -128,20 +164,12 @@ describe("QuestionPhone IP country detection and shimmer", () => {
});
it("fetches IP country code only once when multiple fields are rendered and updates both", async () => {
- let resolveIpFetch!: (value: unknown) => void;
- const ipPromise = new Promise((resolve) => {
- resolveIpFetch = resolve;
+ let resolveRegion!: (value: unknown) => void;
+ const regionPromise = new Promise((resolve) => {
+ resolveRegion = resolve;
});
- const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(() =>
- ipPromise.then(
- (data) =>
- ({
- ok: true,
- json: async () => data,
- }) as unknown as Response,
- ),
- );
+ httpMocks.get.mockReturnValue(regionPromise);
render(
<>
@@ -150,10 +178,15 @@ describe("QuestionPhone IP country detection and shimmer", () => {
>,
);
- expect(fetchSpy).toHaveBeenCalledTimes(1);
+ expect(httpMocks.get).toHaveBeenCalledTimes(1);
await act(async () => {
- resolveIpFetch({ country_calling_code: "+98" });
+ resolveRegion({
+ data: {
+ country: "Iran",
+ country_code: "IR",
+ },
+ });
});
await waitFor(() => {
@@ -170,31 +203,21 @@ describe("QuestionPhone IP country detection and shimmer", () => {
phoneNumber: "2025550143",
};
- const fetchSpy = vi.spyOn(globalThis, "fetch");
-
const { container } = render();
expect(container.querySelectorAll(".shimmer-bg").length).toBe(0);
expect(screen.getByText("+1")).toBeDefined();
expect(screen.getByDisplayValue("2025550143")).toBeDefined();
- expect(fetchSpy).not.toHaveBeenCalled();
+ expect(httpMocks.get).not.toHaveBeenCalled();
});
it("does not overwrite manual selection when user manually interacts", async () => {
- let resolveIpFetch!: (value: unknown) => void;
- const ipPromise = new Promise((resolve) => {
- resolveIpFetch = resolve;
+ let resolveRegion!: (value: unknown) => void;
+ const regionPromise = new Promise((resolve) => {
+ resolveRegion = resolve;
});
- vi.spyOn(globalThis, "fetch").mockImplementation(() =>
- ipPromise.then(
- (data) =>
- ({
- ok: true,
- json: async () => data,
- }) as unknown as Response,
- ),
- );
+ httpMocks.get.mockReturnValue(regionPromise);
render();
@@ -202,7 +225,12 @@ describe("QuestionPhone IP country detection and shimmer", () => {
fireEvent.change(input, { target: { value: "123456" } });
await act(async () => {
- resolveIpFetch({ country_calling_code: "+98" });
+ resolveRegion({
+ data: {
+ country: "Iran",
+ country_code: "IR",
+ },
+ });
});
expect(screen.getByDisplayValue("123456")).toBeDefined();
diff --git a/src/components/Componentes/question-phone.tsx b/src/components/Componentes/question-phone.tsx
index a3181cc..f7add32 100644
--- a/src/components/Componentes/question-phone.tsx
+++ b/src/components/Componentes/question-phone.tsx
@@ -4,6 +4,7 @@ import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { MarriagePhoneFieldValue } from "@/hooks/marriage/types";
+import { http } from "@/lib/http";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage";
@@ -72,7 +73,35 @@ export function fetchGeoCountryCode(defaultCode = "+44"): Promise {
geoIpPromise = (async () => {
try {
- // 1. Primary: ipapi.co with 2s timeout
+ // 1. Primary: Habib Backend User Region API (/account/auth/user/region/)
+ try {
+ const response = await http.get<{
+ country?: string;
+ country_code?: string;
+ city?: string;
+ }>("/account/auth/user/region/", {
+ timeout: 2500,
+ });
+
+ const isoCountry = response.data?.country_code;
+ if (isoCountry) {
+ const callingCode = phoneUtil.getCountryCodeForRegion(
+ isoCountry.toUpperCase(),
+ );
+ if (callingCode) {
+ const formatted = `+${callingCode}`;
+ setManuallySelectedGeoCode(formatted);
+ geoListeners.forEach((fn) => {
+ fn(formatted);
+ });
+ return formatted;
+ }
+ }
+ } catch {
+ // Fallback to secondary geo endpoints
+ }
+
+ // 2. Secondary fallback: ipapi.co with 2s timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
try {
@@ -96,7 +125,7 @@ export function fetchGeoCountryCode(defaultCode = "+44"): Promise {
clearTimeout(timeoutId);
}
- // 2. Secondary fallback: ipwho.is with 2s timeout
+ // 3. Tertiary fallback: ipwho.is with 2s timeout
const secondaryController = new AbortController();
const secondaryTimeoutId = setTimeout(
() => secondaryController.abort(),
@@ -123,7 +152,7 @@ export function fetchGeoCountryCode(defaultCode = "+44"): Promise {
clearTimeout(secondaryTimeoutId);
}
- // 3. Fallback to default
+ // 4. Fallback to default
if (typeof window !== "undefined") {
try {
localStorage.setItem("geoIPPhoneCode", defaultCode);
@@ -691,12 +720,10 @@ export function QuestionPhone({
>
{isResolvingCountry ? (
-
-
-
+
) : (
<>
diff --git a/src/components/Componentes/question-viewport-coordinator.ts b/src/components/Componentes/question-viewport-coordinator.ts
index 7f75f0b..957a72e 100644
--- a/src/components/Componentes/question-viewport-coordinator.ts
+++ b/src/components/Componentes/question-viewport-coordinator.ts
@@ -83,12 +83,12 @@ export function isActiveQuestionKeyboardInput(
}
function setLift(nextLift: number) {
+ const prevLift = currentLift;
currentLift = Math.max(0, nextLift);
document.documentElement.style.setProperty(LIFT_VARIABLE, `${currentLift}px`);
- document.body.classList.toggle(
- "question-keyboard-open",
- currentLift > 0 && keyboardVisible && activeQuestionInput !== null,
- );
+ const isOpen = currentLift > 0 && keyboardVisible && activeQuestionInput !== null;
+ document.body.classList.toggle("question-keyboard-open", isOpen);
+ console.log(`[Coord] setLift: ${prevLift}px -> ${currentLift}px | visible: ${keyboardVisible} | class: ${isOpen}`);
}
function getKeyboardTop() {
@@ -108,6 +108,7 @@ function updateLift() {
'.question-snap-item[aria-current="step"] .question-snap-content',
);
if (!content) {
+ console.log('[Coord] updateLift: no content element found -> lift 0');
setLift(0);
return;
}
@@ -117,6 +118,7 @@ function updateLift() {
(value): value is number => value !== null,
);
if (visibleBottomCandidates.length === 0) {
+ console.log('[Coord] updateLift: no visibleBottom candidates -> lift 0');
setLift(0);
return;
}
@@ -129,17 +131,17 @@ function updateLift() {
getComputedStyle(document.documentElement).getPropertyValue("--safe-top"),
);
- setLift(
- computeQuestionLift({
- baseTop,
- baseBottom,
- visibleTop: Math.max(
- Number.isFinite(safeTop) ? safeTop : 0,
- snapList?.getBoundingClientRect().top ?? 0,
- ),
- visibleBottom: Math.min(...visibleBottomCandidates),
- }),
- );
+ const computed = computeQuestionLift({
+ baseTop,
+ baseBottom,
+ visibleTop: Math.max(
+ Number.isFinite(safeTop) ? safeTop : 0,
+ snapList?.getBoundingClientRect().top ?? 0,
+ ),
+ visibleBottom: Math.min(...visibleBottomCandidates),
+ });
+ console.log(`[Coord] computed lift: ${computed}px (rect: ${rect.top.toFixed(0)}-${rect.bottom.toFixed(0)}, kTop: ${keyboardTop?.toFixed(0)})`);
+ setLift(computed);
}
function scheduleLiftUpdate() {
@@ -154,6 +156,7 @@ export function updateQuestionKeyboardHeight(height: number) {
const nextHeight = Math.max(0, height);
if (Math.abs(nextHeight - keyboardHeight) < 1) return;
+ console.log(`[Coord] updateHeight: ${keyboardHeight} -> ${nextHeight}`);
keyboardHeight = nextHeight;
keyboardVisible = keyboardHeight > KEYBOARD_THRESHOLD;
if (keyboardVisible) {
@@ -196,6 +199,7 @@ export function useQuestionViewportCoordinator() {
const handleFocusIn = (event: FocusEvent) => {
if (!isActiveQuestionKeyboardInput(event.target)) return;
activeQuestionInput = event.target;
+ console.log('[Coord] focusin on input:', (event.target as HTMLElement).tagName, 'lastKHeight:', lastKeyboardHeight);
if (lastKeyboardHeight > KEYBOARD_THRESHOLD) {
keyboardHeight = lastKeyboardHeight;
keyboardVisible = true;
@@ -204,12 +208,14 @@ export function useQuestionViewportCoordinator() {
};
const handleFocusOut = () => {
+ console.log('[Coord] focusout event');
window.setTimeout(() => {
activeQuestionInput = isActiveQuestionKeyboardInput(
document.activeElement,
)
? document.activeElement
: null;
+ console.log('[Coord] focusout settled, active:', activeQuestionInput?.tagName ?? 'none');
if (!activeQuestionInput) {
keyboardVisible = false;
keyboardHeight = 0;
@@ -226,6 +232,7 @@ export function useQuestionViewportCoordinator() {
const viewportHeight =
window.visualViewport?.height ?? window.innerHeight;
const reduction = closedViewportHeight - viewportHeight;
+ console.log(`[Coord] visualViewport resize: vpHeight=${viewportHeight}, reduction=${reduction}`);
if (reduction > KEYBOARD_THRESHOLD) {
if (isActiveQuestionKeyboardInput(document.activeElement)) {