From 2ddb4f31e5fb2184bbf024203e28c380fa21cc72 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Thu, 20 Aug 2026 02:40:31 +0330 Subject: [PATCH] feat: integrate Habib user region API for IP geolocation and update phone component shimmer UI --- .../questions-list/questions-list-client.tsx | 6 + .../Componentes/question-phone.test.tsx | 106 +++++++++++------- src/components/Componentes/question-phone.tsx | 41 +++++-- .../question-viewport-coordinator.ts | 37 +++--- 4 files changed, 129 insertions(+), 61 deletions(-) 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)) {