Browse Source

feat: integrate Habib user region API for IP geolocation and update phone component shimmer UI

master
mortezaei 5 days ago
parent
commit
2ddb4f31e5
  1. 6
      src/app/questions-list/questions-list-client.tsx
  2. 106
      src/components/Componentes/question-phone.test.tsx
  3. 41
      src/components/Componentes/question-phone.tsx
  4. 23
      src/components/Componentes/question-viewport-coordinator.ts

6
src/app/questions-list/questions-list-client.tsx

@ -52,6 +52,7 @@ import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch"; import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch";
import DevTapInstrumentation from "@/components/Componentes/dev-tap-instrumentation"; import DevTapInstrumentation from "@/components/Componentes/dev-tap-instrumentation";
import { fetchGeoCountryCode } from "@/components/Componentes/question-phone";
import SectionsRequest from "./sections-request"; import SectionsRequest from "./sections-request";
import SectionOverlayHost from "@/components/Componentes/section-overlay-host"; import SectionOverlayHost from "@/components/Componentes/section-overlay-host";
import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client"; import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client";
@ -101,6 +102,11 @@ export default function QuestionsListClient() {
} }
}, [isProfileRedirecting, locale, profileTargetPath, router]); }, [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 // Signal Flutter to lift its loading cover once the profile is available
// (either from SSR hydration or client-side fetch). // (either from SSR hydration or client-side fetch).
useHabibWebReady(!!profile && !isProfileLoading); useHabibWebReady(!!profile && !isProfileLoading);

106
src/components/Componentes/question-phone.test.tsx

@ -15,6 +15,14 @@ const mockSetAnswerValue = vi.fn((q: QuestionField, val: unknown) => {
answerMap[q.id] = val; answerMap[q.id] = val;
}); });
const httpMocks = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock("@/lib/http", () => ({
http: { get: httpMocks.get },
}));
vi.mock("@/translations/provider", () => ({ vi.mock("@/translations/provider", () => ({
useI18n: () => ({ useI18n: () => ({
locale: "en", locale: "en",
@ -62,8 +70,6 @@ const phoneQuestion2: QuestionField = {
options: [], options: [],
}; };
describe("QuestionPhone IP country detection and shimmer", () => { describe("QuestionPhone IP country detection and shimmer", () => {
beforeEach(() => { beforeEach(() => {
answerMap = {}; answerMap = {};
@ -71,13 +77,47 @@ describe("QuestionPhone IP country detection and shimmer", () => {
localStorage.clear(); localStorage.clear();
resetGeoPhoneStateForTesting(); resetGeoPhoneStateForTesting();
vi.restoreAllMocks(); vi.restoreAllMocks();
httpMocks.get.mockReset();
}); });
afterEach(() => { afterEach(() => {
cleanup(); 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(<QuestionPhone question={phoneQuestion1} />);
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; let resolveIpFetch!: (value: unknown) => void;
const ipPromise = new Promise((resolve) => { const ipPromise = new Promise((resolve) => {
resolveIpFetch = resolve; resolveIpFetch = resolve;
@ -95,11 +135,6 @@ describe("QuestionPhone IP country detection and shimmer", () => {
const { container } = render(<QuestionPhone question={phoneQuestion1} />); const { container } = render(<QuestionPhone question={phoneQuestion1} />);
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 () => { await act(async () => {
resolveIpFetch({ country_calling_code: "+98" }); 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( vi.spyOn(globalThis, "fetch").mockRejectedValue(
new Error("Network failure"), 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 () => { 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( render(
<> <>
@ -150,10 +178,15 @@ describe("QuestionPhone IP country detection and shimmer", () => {
</>, </>,
); );
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(httpMocks.get).toHaveBeenCalledTimes(1);
await act(async () => { await act(async () => {
resolveIpFetch({ country_calling_code: "+98" });
resolveRegion({
data: {
country: "Iran",
country_code: "IR",
},
});
}); });
await waitFor(() => { await waitFor(() => {
@ -170,31 +203,21 @@ describe("QuestionPhone IP country detection and shimmer", () => {
phoneNumber: "2025550143", phoneNumber: "2025550143",
}; };
const fetchSpy = vi.spyOn(globalThis, "fetch");
const { container } = render(<QuestionPhone question={phoneQuestion1} />); const { container } = render(<QuestionPhone question={phoneQuestion1} />);
expect(container.querySelectorAll(".shimmer-bg").length).toBe(0); expect(container.querySelectorAll(".shimmer-bg").length).toBe(0);
expect(screen.getByText("+1")).toBeDefined(); expect(screen.getByText("+1")).toBeDefined();
expect(screen.getByDisplayValue("2025550143")).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 () => { 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(<QuestionPhone question={phoneQuestion1} />); render(<QuestionPhone question={phoneQuestion1} />);
@ -202,7 +225,12 @@ describe("QuestionPhone IP country detection and shimmer", () => {
fireEvent.change(input, { target: { value: "123456" } }); fireEvent.change(input, { target: { value: "123456" } });
await act(async () => { await act(async () => {
resolveIpFetch({ country_calling_code: "+98" });
resolveRegion({
data: {
country: "Iran",
country_code: "IR",
},
});
}); });
expect(screen.getByDisplayValue("123456")).toBeDefined(); expect(screen.getByDisplayValue("123456")).toBeDefined();

41
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import type { MarriagePhoneFieldValue } from "@/hooks/marriage/types"; import type { MarriagePhoneFieldValue } from "@/hooks/marriage/types";
import { http } from "@/lib/http";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
@ -72,7 +73,35 @@ export function fetchGeoCountryCode(defaultCode = "+44"): Promise<string> {
geoIpPromise = (async () => { geoIpPromise = (async () => {
try { 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 controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000); const timeoutId = setTimeout(() => controller.abort(), 2000);
try { try {
@ -96,7 +125,7 @@ export function fetchGeoCountryCode(defaultCode = "+44"): Promise<string> {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
// 2. Secondary fallback: ipwho.is with 2s timeout
// 3. Tertiary fallback: ipwho.is with 2s timeout
const secondaryController = new AbortController(); const secondaryController = new AbortController();
const secondaryTimeoutId = setTimeout( const secondaryTimeoutId = setTimeout(
() => secondaryController.abort(), () => secondaryController.abort(),
@ -123,7 +152,7 @@ export function fetchGeoCountryCode(defaultCode = "+44"): Promise<string> {
clearTimeout(secondaryTimeoutId); clearTimeout(secondaryTimeoutId);
} }
// 3. Fallback to default
// 4. Fallback to default
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
try { try {
localStorage.setItem("geoIPPhoneCode", defaultCode); localStorage.setItem("geoIPPhoneCode", defaultCode);
@ -691,12 +720,10 @@ export function QuestionPhone({
> >
{isResolvingCountry ? ( {isResolvingCountry ? (
<div <div
className="flex items-center gap-1.5 py-1"
className="flex items-center py-1"
aria-hidden="true" aria-hidden="true"
> >
<span className="h-[18px] w-6 rounded-[4px] shimmer-bg inline-block shrink-0" />
<span className="h-[18px] w-8 rounded-[4px] shimmer-bg inline-block shrink-0" />
<span className="h-2.5 w-2.5 rounded-[2px] shimmer-bg inline-block shrink-0 opacity-60" />
<span className="h-[22px] w-[58px] rounded-[6px] shimmer-bg inline-block shrink-0" />
</div> </div>
) : ( ) : (
<> <>

23
src/components/Componentes/question-viewport-coordinator.ts

@ -83,12 +83,12 @@ export function isActiveQuestionKeyboardInput(
} }
function setLift(nextLift: number) { function setLift(nextLift: number) {
const prevLift = currentLift;
currentLift = Math.max(0, nextLift); currentLift = Math.max(0, nextLift);
document.documentElement.style.setProperty(LIFT_VARIABLE, `${currentLift}px`); 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() { function getKeyboardTop() {
@ -108,6 +108,7 @@ function updateLift() {
'.question-snap-item[aria-current="step"] .question-snap-content', '.question-snap-item[aria-current="step"] .question-snap-content',
); );
if (!content) { if (!content) {
console.log('[Coord] updateLift: no content element found -> lift 0');
setLift(0); setLift(0);
return; return;
} }
@ -117,6 +118,7 @@ function updateLift() {
(value): value is number => value !== null, (value): value is number => value !== null,
); );
if (visibleBottomCandidates.length === 0) { if (visibleBottomCandidates.length === 0) {
console.log('[Coord] updateLift: no visibleBottom candidates -> lift 0');
setLift(0); setLift(0);
return; return;
} }
@ -129,8 +131,7 @@ function updateLift() {
getComputedStyle(document.documentElement).getPropertyValue("--safe-top"), getComputedStyle(document.documentElement).getPropertyValue("--safe-top"),
); );
setLift(
computeQuestionLift({
const computed = computeQuestionLift({
baseTop, baseTop,
baseBottom, baseBottom,
visibleTop: Math.max( visibleTop: Math.max(
@ -138,8 +139,9 @@ function updateLift() {
snapList?.getBoundingClientRect().top ?? 0, snapList?.getBoundingClientRect().top ?? 0,
), ),
visibleBottom: Math.min(...visibleBottomCandidates), 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() { function scheduleLiftUpdate() {
@ -154,6 +156,7 @@ export function updateQuestionKeyboardHeight(height: number) {
const nextHeight = Math.max(0, height); const nextHeight = Math.max(0, height);
if (Math.abs(nextHeight - keyboardHeight) < 1) return; if (Math.abs(nextHeight - keyboardHeight) < 1) return;
console.log(`[Coord] updateHeight: ${keyboardHeight} -> ${nextHeight}`);
keyboardHeight = nextHeight; keyboardHeight = nextHeight;
keyboardVisible = keyboardHeight > KEYBOARD_THRESHOLD; keyboardVisible = keyboardHeight > KEYBOARD_THRESHOLD;
if (keyboardVisible) { if (keyboardVisible) {
@ -196,6 +199,7 @@ export function useQuestionViewportCoordinator() {
const handleFocusIn = (event: FocusEvent) => { const handleFocusIn = (event: FocusEvent) => {
if (!isActiveQuestionKeyboardInput(event.target)) return; if (!isActiveQuestionKeyboardInput(event.target)) return;
activeQuestionInput = event.target; activeQuestionInput = event.target;
console.log('[Coord] focusin on input:', (event.target as HTMLElement).tagName, 'lastKHeight:', lastKeyboardHeight);
if (lastKeyboardHeight > KEYBOARD_THRESHOLD) { if (lastKeyboardHeight > KEYBOARD_THRESHOLD) {
keyboardHeight = lastKeyboardHeight; keyboardHeight = lastKeyboardHeight;
keyboardVisible = true; keyboardVisible = true;
@ -204,12 +208,14 @@ export function useQuestionViewportCoordinator() {
}; };
const handleFocusOut = () => { const handleFocusOut = () => {
console.log('[Coord] focusout event');
window.setTimeout(() => { window.setTimeout(() => {
activeQuestionInput = isActiveQuestionKeyboardInput( activeQuestionInput = isActiveQuestionKeyboardInput(
document.activeElement, document.activeElement,
) )
? document.activeElement ? document.activeElement
: null; : null;
console.log('[Coord] focusout settled, active:', activeQuestionInput?.tagName ?? 'none');
if (!activeQuestionInput) { if (!activeQuestionInput) {
keyboardVisible = false; keyboardVisible = false;
keyboardHeight = 0; keyboardHeight = 0;
@ -226,6 +232,7 @@ export function useQuestionViewportCoordinator() {
const viewportHeight = const viewportHeight =
window.visualViewport?.height ?? window.innerHeight; window.visualViewport?.height ?? window.innerHeight;
const reduction = closedViewportHeight - viewportHeight; const reduction = closedViewportHeight - viewportHeight;
console.log(`[Coord] visualViewport resize: vpHeight=${viewportHeight}, reduction=${reduction}`);
if (reduction > KEYBOARD_THRESHOLD) { if (reduction > KEYBOARD_THRESHOLD) {
if (isActiveQuestionKeyboardInput(document.activeElement)) { if (isActiveQuestionKeyboardInput(document.activeElement)) {

Loading…
Cancel
Save