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. 37
      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 { 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);

106
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(<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;
const ipPromise = new Promise((resolve) => {
resolveIpFetch = resolve;
@ -95,11 +135,6 @@ describe("QuestionPhone IP country detection and shimmer", () => {
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 () => {
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(<QuestionPhone question={phoneQuestion1} />);
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(<QuestionPhone question={phoneQuestion1} />);
@ -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();

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 { 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<string> {
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<string> {
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<string> {
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 ? (
<div
className="flex items-center gap-1.5 py-1"
className="flex items-center py-1"
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>
) : (
<>

37
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)) {

Loading…
Cancel
Save