mortezaei 3 days ago
parent
commit
7225e65546
  1. 39
      src/components/Componentes/question-answer-storage.tsx
  2. 31
      src/components/Componentes/question-birthplace.tsx
  3. 6
      src/components/Componentes/question-number.tsx
  4. 29
      src/components/Componentes/question-phone.test.tsx
  5. 11
      src/components/Componentes/question-sheet.tsx
  6. 6
      src/hooks/marriage/types.ts
  7. 104
      src/lib/geo-region.ts
  8. 19
      src/lib/marriage-field-formatter.ts

39
src/components/Componentes/question-answer-storage.tsx

@ -17,6 +17,7 @@ import type {
MarriageField, MarriageField,
MarriageFieldValue, MarriageFieldValue,
MarriagePhoneFieldValue, MarriagePhoneFieldValue,
MarriageBirthplaceFieldValue,
UpdateMarriageSectionDataPayload, UpdateMarriageSectionDataPayload,
} from "@/hooks/marriage/types"; } from "@/hooks/marriage/types";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
@ -80,7 +81,7 @@ export function getQuestionAnswersStorageKey(slug: string) {
} }
export function hasQuestionAnswerValue(value: MarriageFieldValue) { export function hasQuestionAnswerValue(value: MarriageFieldValue) {
if (value === null) {
if (value === null || value === undefined) {
return false; return false;
} }
@ -88,9 +89,40 @@ export function hasQuestionAnswerValue(value: MarriageFieldValue) {
return value.trim().length > 0; return value.trim().length > 0;
} }
if (typeof value === "object") {
if (Array.isArray(value)) {
return value.length > 0;
}
const phone = value as Partial<MarriagePhoneFieldValue>;
if (
typeof phone.countryCode === "string" ||
typeof phone.phoneNumber === "string"
) {
return Boolean(phone.countryCode?.trim() || phone.phoneNumber?.trim());
}
const bp = value as Partial<MarriageBirthplaceFieldValue>;
if (typeof bp.country === "string" || typeof bp.city === "string") {
return Boolean(bp.country?.trim() || bp.city?.trim());
}
}
return true; return true;
} }
function isMarriageBirthplaceFieldValue(
value: unknown,
): value is MarriageBirthplaceFieldValue {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const bpValue = value as Partial<MarriageBirthplaceFieldValue>;
return (
typeof bpValue.country === "string" && typeof bpValue.city === "string"
);
}
function isMarriageField(value: unknown): value is MarriageField { function isMarriageField(value: unknown): value is MarriageField {
if (!value || typeof value !== "object") { if (!value || typeof value !== "object") {
return false; return false;
@ -107,14 +139,15 @@ function isMarriageField(value: unknown): value is MarriageField {
typeof field.value === "number" || typeof field.value === "number" ||
typeof field.value === "boolean" || typeof field.value === "boolean" ||
Array.isArray(field.value) || Array.isArray(field.value) ||
isMarriagePhoneFieldValue(field.value))
isMarriagePhoneFieldValue(field.value) ||
isMarriageBirthplaceFieldValue(field.value))
); );
} }
function isMarriagePhoneFieldValue( function isMarriagePhoneFieldValue(
value: unknown, value: unknown,
): value is MarriagePhoneFieldValue { ): value is MarriagePhoneFieldValue {
if (!value || typeof value !== "object") {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false; return false;
} }

31
src/components/Componentes/question-birthplace.tsx

@ -208,17 +208,17 @@ export function QuestionBirthplace({
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, closeSheet]); }, [isOpen, closeSheet]);
const lastInternalAnswerRef = useRef<string | null>(null);
const lastInternalAnswerRef = useRef<BirthplaceValue | string | null>(null);
const updateAnswers = (country: string, city: string) => { const updateAnswers = (country: string, city: string) => {
const cleanCountry = country?.trim() || ""; const cleanCountry = country?.trim() || "";
const cleanCity = city?.trim() || ""; const cleanCity = city?.trim() || "";
const formatted =
cleanCountry && cleanCity
? `${cleanCountry}, ${cleanCity}`
: cleanCountry || cleanCity || null;
lastInternalAnswerRef.current = formatted;
setAnswerValue(question, formatted);
const payload =
cleanCountry || cleanCity
? { country: cleanCountry, city: cleanCity }
: null;
lastInternalAnswerRef.current = payload;
setAnswerValue(question, payload);
}; };
// GeoIP detection logic using unified getUserGeoRegion // GeoIP detection logic using unified getUserGeoRegion
@ -304,6 +304,21 @@ export function QuestionBirthplace({
if (rawValue === lastInternalAnswerRef.current) { if (rawValue === lastInternalAnswerRef.current) {
return; return;
} }
if (
typeof rawValue === "object" &&
rawValue !== null &&
typeof lastInternalAnswerRef.current === "object" &&
lastInternalAnswerRef.current !== null
) {
const currentObj = lastInternalAnswerRef.current as BirthplaceValue;
const rawObj = rawValue as BirthplaceValue;
if (
(rawObj.country?.trim() || "") === (currentObj.country?.trim() || "") &&
(rawObj.city?.trim() || "") === (currentObj.city?.trim() || "")
) {
return;
}
}
const updated = parseValue(rawValue); const updated = parseValue(rawValue);
const resolvedC = resolveCountryName(updated.country, locale) || updated.country; const resolvedC = resolveCountryName(updated.country, locale) || updated.country;
if (resolvedC !== selectedCountry) { if (resolvedC !== selectedCountry) {
@ -315,7 +330,7 @@ export function QuestionBirthplace({
if (resolvedC || updated.city) { if (resolvedC || updated.city) {
setDetectedLocation([resolvedC, updated.city].filter(Boolean).join(", ")); setDetectedLocation([resolvedC, updated.city].filter(Boolean).join(", "));
} }
lastInternalAnswerRef.current = typeof rawValue === "string" ? rawValue : null;
lastInternalAnswerRef.current = rawValue as BirthplaceValue | string | null;
}, [rawValue, locale]); }, [rawValue, locale]);
const options = getCountryList(locale); const options = getCountryList(locale);

6
src/components/Componentes/question-number.tsx

@ -466,6 +466,12 @@ function getCountryFromStorage(): string {
f.key?.includes("mhl_skwnt_fly"), f.key?.includes("mhl_skwnt_fly"),
); );
const value = field?.value; const value = field?.value;
if (typeof value === "object" && value !== null) {
const obj = value as { country?: string; city?: string };
if (typeof obj.country === "string" && obj.country.trim()) {
return obj.country.trim();
}
}
if (typeof value === "string") { if (typeof value === "string") {
const parts = value.split(",").map((p) => p.trim()); const parts = value.split(",").map((p) => p.trim());
if (parts.length >= 2) { if (parts.length >= 2) {

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

@ -117,35 +117,20 @@ describe("QuestionPhone IP country detection and shimmer", () => {
}); });
}); });
it("falls back to secondary fetch when Habib region API fails and shows resolved code", async () => {
it("falls back to default region without calling external fetch when Habib region API fails", async () => {
httpMocks.get.mockRejectedValue(new Error("Network failure")); httpMocks.get.mockRejectedValue(new Error("Network failure"));
let resolveIpFetch!: (value: unknown) => void;
const ipPromise = new Promise((resolve) => {
resolveIpFetch = resolve;
});
vi.spyOn(globalThis, "fetch").mockImplementation(() =>
ipPromise.then(
(data) =>
({
ok: true,
json: async () => data,
}) as unknown as Response,
),
);
const fetchSpy = vi.spyOn(globalThis, "fetch");
const { container } = render(<QuestionPhone question={phoneQuestion1} />); const { container } = render(<QuestionPhone question={phoneQuestion1} />);
await act(async () => {
resolveIpFetch({ country_calling_code: "+98" });
});
await waitFor(() => { await waitFor(() => {
expect(container.querySelectorAll(".shimmer-bg").length).toBe(0); expect(container.querySelectorAll(".shimmer-bg").length).toBe(0);
expect(screen.getByText("+98")).toBeDefined();
expect(screen.getByText("🇮🇷")).toBeDefined();
expect(screen.getByText("+44")).toBeDefined();
expect(screen.getByText("🇬🇧")).toBeDefined();
}); });
// Verify external fetch was NEVER called
expect(fetchSpy).not.toHaveBeenCalled();
}); });
it("shows default country code when all IP requests fail", async () => { it("shows default country code when all IP requests fail", async () => {

11
src/components/Componentes/question-sheet.tsx

@ -14,6 +14,7 @@ import { registerCompactQuestionSheet } from "./question-viewport-coordinator";
import { useSheetScrollLock } from "./use-sheet-scroll-lock"; import { useSheetScrollLock } from "./use-sheet-scroll-lock";
const EXIT_ANIMATION_MS = 300; const EXIT_ANIMATION_MS = 300;
const EMPTY_ARRAY: string[] = [];
export type QuestionSheetProps = { export type QuestionSheetProps = {
question: QuestionField; question: QuestionField;
@ -30,11 +31,11 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
question.type === "checkbox" || question.type === "checkbox" ||
(question.extras?.range && question.extras.range[1] > 1); (question.extras?.range && question.extras.range[1] > 1);
const selectedList = Array.isArray(rawValue)
? rawValue
: typeof rawValue === "string" && rawValue
? [rawValue]
: [];
const selectedList = useMemo(() => {
if (Array.isArray(rawValue)) return rawValue;
if (typeof rawValue === "string" && rawValue) return [rawValue];
return EMPTY_ARRAY;
}, [rawValue]);
const singleValue = typeof rawValue === "string" ? rawValue : ""; const singleValue = typeof rawValue === "string" ? rawValue : "";
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);

6
src/hooks/marriage/types.ts

@ -31,12 +31,18 @@ export type MarriagePhoneFieldValue = {
phoneNumber: string; phoneNumber: string;
}; };
export type MarriageBirthplaceFieldValue = {
country: string;
city: string;
};
export type MarriageFieldValue = export type MarriageFieldValue =
| string | string
| string[] | string[]
| number | number
| boolean | boolean
| MarriagePhoneFieldValue | MarriagePhoneFieldValue
| MarriageBirthplaceFieldValue
| null; | null;
export type MarriageField = { export type MarriageField = {

104
src/lib/geo-region.ts

@ -140,83 +140,20 @@ async function fetchHttpGeoRegion(): Promise<UserGeoRegion> {
); );
} }
// 2. Secondary fallback: ipapi.co with 2s timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
try {
console.log("[GEO_BRIDGE_LOG] 🔄 Falling back to ipapi.co...");
const res = await fetch("https://ipapi.co/json/", {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (res?.ok) {
const data = await res.json();
if (
data &&
(data.country_name || data.city || data.country_calling_code)
) {
const rawPhone = data.country_calling_code
? String(data.country_calling_code).trim()
: "";
const phoneCode = rawPhone.startsWith("+")
? rawPhone
: rawPhone
? `+${rawPhone}`
: "+44";
const region: UserGeoRegion = {
ip: data.ip,
city: data.city,
country: data.country_name,
countryCode: data.country_code,
phoneCode,
};
setStoredUserGeoRegion(region);
return region;
}
}
} catch {
clearTimeout(timeoutId);
}
// 3. Tertiary fallback: ipwho.is with 2s timeout
const secondaryController = new AbortController();
const secondaryTimeoutId = setTimeout(
() => secondaryController.abort(),
2000,
);
try {
console.log("[GEO_BRIDGE_LOG] 🔄 Falling back to ipwho.is...");
const res = await fetch("https://ipwho.is/", {
signal: secondaryController.signal,
});
clearTimeout(secondaryTimeoutId);
if (res?.ok) {
const data = await res.json();
if (data && (data.country || data.city || data.calling_code)) {
const rawPhone = data.calling_code
? String(data.calling_code).trim()
: "";
const phoneCode = rawPhone.startsWith("+")
? rawPhone
: rawPhone
? `+${rawPhone}`
: "+44";
const region: UserGeoRegion = {
ip: data.ip,
city: data.city,
country: data.country,
countryCode: data.country_code,
phoneCode,
};
setStoredUserGeoRegion(region);
return region;
}
}
} catch {
clearTimeout(secondaryTimeoutId);
// Default fallback: preserve previously cached/stored region if present
const existing = getStoredUserGeoRegion();
if (
existing &&
(existing.country || existing.phoneCode || existing.countryCode || existing.city)
) {
console.log(
"[GEO_BRIDGE_LOG] 📦 Preserving existing stored user geo region:",
JSON.stringify(existing),
);
setStoredUserGeoRegion(existing);
return existing;
} }
// 4. Default fallback
console.log("[GEO_BRIDGE_LOG] ⚠️ Using default fallback region (+44)..."); console.log("[GEO_BRIDGE_LOG] ⚠️ Using default fallback region (+44)...");
const defaultRegion: UserGeoRegion = { const defaultRegion: UserGeoRegion = {
phoneCode: "+44", phoneCode: "+44",
@ -224,6 +161,15 @@ async function fetchHttpGeoRegion(): Promise<UserGeoRegion> {
setStoredUserGeoRegion(defaultRegion); setStoredUserGeoRegion(defaultRegion);
return defaultRegion; return defaultRegion;
} catch { } catch {
const existing = getStoredUserGeoRegion();
if (
existing &&
(existing.country || existing.phoneCode || existing.countryCode || existing.city)
) {
setStoredUserGeoRegion(existing);
return existing;
}
const defaultRegion: UserGeoRegion = { const defaultRegion: UserGeoRegion = {
phoneCode: "+44", phoneCode: "+44",
}; };
@ -395,14 +341,18 @@ export function getUserGeoRegion(force = false): Promise<UserGeoRegion> {
(Boolean(window.HabibApp?.postMessage) || (Boolean(window.HabibApp?.postMessage) ||
typeof (window as any).sendToFlutter === "function"); typeof (window as any).sendToFlutter === "function");
if (isFlutter) {
const isDev = process.env.NODE_ENV === "development";
if (isFlutter && !isDev) {
console.log( console.log(
"[GEO_BRIDGE_LOG] 📱 Inside Flutter WebView detected, requesting location via Flutter bridge", "[GEO_BRIDGE_LOG] 📱 Inside Flutter WebView detected, requesting location via Flutter bridge",
); );
geoRegionPromise = fetchFlutterBridgeGeoRegion(); geoRegionPromise = fetchFlutterBridgeGeoRegion();
} else { } else {
console.log( console.log(
"[GEO_BRIDGE_LOG] 🌐 Standard browser environment detected, requesting location via HTTP",
isDev && isFlutter
? "[GEO_BRIDGE_LOG] 🛠️ Development environment (npm run dev) detected, skipping Flutter bridge action and requesting location via HTTP"
: "[GEO_BRIDGE_LOG] 🌐 Standard browser environment detected, requesting location via HTTP",
); );
geoRegionPromise = fetchHttpGeoRegion(); geoRegionPromise = fetchHttpGeoRegion();
} }

19
src/lib/marriage-field-formatter.ts

@ -2,6 +2,7 @@ import type {
MarriageField, MarriageField,
MarriageFieldValue, MarriageFieldValue,
MarriagePhoneFieldValue, MarriagePhoneFieldValue,
MarriageBirthplaceFieldValue,
} from "@/hooks/marriage/types"; } from "@/hooks/marriage/types";
import { dictionaries } from "@/translations/dictionaries"; import { dictionaries } from "@/translations/dictionaries";
@ -23,7 +24,7 @@ for (const enKey of Object.keys(dictionaries.en)) {
export function isMarriagePhoneFieldValue( export function isMarriagePhoneFieldValue(
value: unknown, value: unknown,
): value is MarriagePhoneFieldValue { ): value is MarriagePhoneFieldValue {
if (!value || typeof value !== "object") {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false; return false;
} }
@ -35,6 +36,20 @@ export function isMarriagePhoneFieldValue(
); );
} }
export function isMarriageBirthplaceFieldValue(
value: unknown,
): value is MarriageBirthplaceFieldValue {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const bpValue = value as Partial<MarriageBirthplaceFieldValue>;
return (
typeof bpValue.country === "string" && typeof bpValue.city === "string"
);
}
export function formatFieldValue(value: MarriageFieldValue): string | null { export function formatFieldValue(value: MarriageFieldValue): string | null {
if (value === null || value === undefined || value === "") { if (value === null || value === undefined || value === "") {
return null; return null;
@ -51,7 +66,7 @@ export function formatFieldValue(value: MarriageFieldValue): string | null {
if (typeof value === "object") { if (typeof value === "object") {
if ("country" in value || "city" in value || "state" in value) { if ("country" in value || "city" in value || "state" in value) {
const v = value as { country?: string; state?: string; city?: string }; const v = value as { country?: string; state?: string; city?: string };
const parts = [v.country, v.state, v.city]
const parts = [v.city, v.state, v.country]
.map((p) => (typeof p === "string" ? p.trim() : "")) .map((p) => (typeof p === "string" ? p.trim() : ""))
.filter(Boolean); .filter(Boolean);
return parts.join(", "); return parts.join(", ");

Loading…
Cancel
Save