41 Commits
425f0f4e01
...
8bce4dcd7c
60 changed files with 2435 additions and 842 deletions
-
16src/app/[lang]/page.tsx
-
273src/app/[lang]/test-api/page.tsx
-
28src/app/api/proxy/route.ts
-
6src/app/globals.css
-
109src/app/intro/intro-client.tsx
-
14src/app/layout.tsx
-
263src/app/new-match/new-match-client.tsx
-
6src/app/questions-list/questions-list-client.tsx
-
5src/app/test-api/page.tsx
-
213src/components/Componentes/intro-hero-illustration.tsx
-
22src/components/Componentes/question-answer-storage.tsx
-
72src/components/Componentes/question-answer.test.tsx
-
138src/components/Componentes/question-birthplace.tsx
-
35src/components/Componentes/question-card.tsx
-
2src/components/Componentes/question-checkbox.tsx
-
207src/components/Componentes/question-phone.test.tsx
-
330src/components/Componentes/question-phone.tsx
-
2src/components/Componentes/question-radio.tsx
-
14src/components/Componentes/question-sheet.test.tsx
-
108src/components/Componentes/question-sheet.tsx
-
40src/components/Componentes/question-snap-list.tsx
-
129src/components/Componentes/question-viewport-coordinator.ts
-
63src/components/Componentes/section-icon.tsx
-
1src/components/Componentes/slider-page.test.tsx
-
66src/components/Componentes/slider-page.tsx
-
32src/components/Componentes/slider-slide-two.tsx
-
43src/components/Componentes/ui-config.test.tsx
-
49src/data/countries.ts
-
79src/data/languages.ts
-
4src/hooks/marriage/use-profile-main.test.ts
-
2src/hooks/marriage/use-profile-main.ts
-
3src/lib/entry-route-cache.test.ts
-
1src/lib/entry-route-cache.ts
-
236src/lib/geo-region.ts
-
12src/lib/marriage-field-formatter.ts
-
11src/translations/locales/ar.json
-
19src/translations/locales/az.json
-
13src/translations/locales/bn.json
-
13src/translations/locales/da.json
-
7src/translations/locales/de.json
-
4src/translations/locales/en.json
-
11src/translations/locales/es.json
-
3src/translations/locales/fa.json
-
11src/translations/locales/fr.json
-
11src/translations/locales/gu.json
-
11src/translations/locales/ha.json
-
11src/translations/locales/he.json
-
11src/translations/locales/hi.json
-
11src/translations/locales/id.json
-
13src/translations/locales/ks.json
-
11src/translations/locales/pt.json
-
11src/translations/locales/ru.json
-
11src/translations/locales/sw.json
-
11src/translations/locales/tg.json
-
11src/translations/locales/tr.json
-
13src/translations/locales/ul.json
-
11src/translations/locales/ur.json
-
11src/translations/locales/uz.json
-
13src/translations/locales/zh.json
-
7src/translations/provider.tsx
@ -0,0 +1,273 @@ |
|||
"use client"; |
|||
|
|||
import { useEffect, useState } from "react"; |
|||
import { http } from "@/lib/http"; |
|||
import { getUserGeoRegion } from "@/lib/geo-region"; |
|||
import { resolveCountryName } from "@/data/countries"; |
|||
import { useI18n } from "@/translations/provider"; |
|||
|
|||
type TestResult = { |
|||
name: string; |
|||
url: string; |
|||
status: "pending" | "success" | "error"; |
|||
statusCode?: number; |
|||
data?: any; |
|||
error?: string; |
|||
timeMs?: number; |
|||
}; |
|||
|
|||
export default function TestApiPage() { |
|||
const { locale } = useI18n(); |
|||
const [results, setResults] = useState<Record<string, TestResult>>({}); |
|||
const [isRunning, setIsRunning] = useState(false); |
|||
const [clientInfo, setClientInfo] = useState<{ |
|||
userAgent: string; |
|||
cookie: string; |
|||
localStorageGeo: string | null; |
|||
}>({ userAgent: "", cookie: "", localStorageGeo: null }); |
|||
|
|||
const logToConsoleAndFlutter = (tag: string, payload: any) => { |
|||
const message = `[TEST_API] ${tag}: ${typeof payload === "string" ? payload : JSON.stringify(payload, null, 2)}`; |
|||
console.log(message); |
|||
if (typeof window !== "undefined" && (window as any).flutter_inappwebview) { |
|||
try { |
|||
(window as any).flutter_inappwebview.callHandler("log", message); |
|||
} catch {} |
|||
} |
|||
}; |
|||
|
|||
const runSingleTest = async ( |
|||
id: string, |
|||
name: string, |
|||
url: string, |
|||
fetcher: () => Promise<any>, |
|||
) => { |
|||
setResults((prev) => ({ |
|||
...prev, |
|||
[id]: { name, url, status: "pending" }, |
|||
})); |
|||
|
|||
const start = Date.now(); |
|||
try { |
|||
logToConsoleAndFlutter(`START ${name}`, { url }); |
|||
const data = await fetcher(); |
|||
const timeMs = Date.now() - start; |
|||
logToConsoleAndFlutter(`SUCCESS ${name}`, { timeMs, data }); |
|||
setResults((prev) => ({ |
|||
...prev, |
|||
[id]: { |
|||
name, |
|||
url, |
|||
status: "success", |
|||
statusCode: 200, |
|||
data, |
|||
timeMs, |
|||
}, |
|||
})); |
|||
} catch (err: any) { |
|||
const timeMs = Date.now() - start; |
|||
const status = err.response?.status || 500; |
|||
const errorMsg = |
|||
err.response?.data?.detail || |
|||
err.response?.data?.message || |
|||
err.message || |
|||
"Unknown error"; |
|||
logToConsoleAndFlutter(`ERROR ${name}`, { |
|||
timeMs, |
|||
status, |
|||
error: errorMsg, |
|||
data: err.response?.data, |
|||
}); |
|||
setResults((prev) => ({ |
|||
...prev, |
|||
[id]: { |
|||
name, |
|||
url, |
|||
status: "error", |
|||
statusCode: status, |
|||
error: errorMsg, |
|||
data: err.response?.data, |
|||
timeMs, |
|||
}, |
|||
})); |
|||
} |
|||
}; |
|||
|
|||
const runAllTests = async () => { |
|||
setIsRunning(true); |
|||
|
|||
if (typeof window !== "undefined") { |
|||
setClientInfo({ |
|||
userAgent: navigator.userAgent, |
|||
cookie: document.cookie, |
|||
localStorageGeo: localStorage.getItem("user_geo_region"), |
|||
}); |
|||
} |
|||
|
|||
// Test 1: User Region Backend API
|
|||
await runSingleTest( |
|||
"region", |
|||
"1. User Region API (/account/auth/user/region/)", |
|||
"/account/auth/user/region/", |
|||
async () => { |
|||
const res = await http.get("/account/auth/user/region/"); |
|||
const raw = res.data; |
|||
const localizedCountry = resolveCountryName( |
|||
raw.country || raw.country_code, |
|||
locale || "fa", |
|||
); |
|||
return { |
|||
rawResponse: raw, |
|||
resolvedCountryInPersian: resolveCountryName( |
|||
raw.country || raw.country_code, |
|||
"fa", |
|||
), |
|||
resolvedCountryInEnglish: resolveCountryName( |
|||
raw.country || raw.country_code, |
|||
"en", |
|||
), |
|||
currentLocaleResolved: localizedCountry, |
|||
}; |
|||
}, |
|||
); |
|||
|
|||
// Test 2: getUserGeoRegion helper with force refresh
|
|||
await runSingleTest( |
|||
"geo_helper", |
|||
"2. getUserGeoRegion(force=true)", |
|||
"lib/geo-region.ts", |
|||
async () => { |
|||
return await getUserGeoRegion(true); |
|||
}, |
|||
); |
|||
|
|||
// Test 3: Marriage Forms Profile Overview
|
|||
await runSingleTest( |
|||
"overview", |
|||
"3. Forms Profile Overview (/api/marriage/forms/profile/overview/)", |
|||
"/api/marriage/forms/profile/overview/?lang=fa", |
|||
async () => { |
|||
const res = await http.get( |
|||
"/api/marriage/forms/profile/overview/?lang=fa", |
|||
); |
|||
return { |
|||
completion_percent: res.data?.progress?.completion_percent, |
|||
sections_progress: res.data?.progress?.sections_progress, |
|||
sections_count: res.data?.sections?.length, |
|||
}; |
|||
}, |
|||
); |
|||
|
|||
// Test 4: Marriage Personal Identity Section
|
|||
await runSingleTest( |
|||
"personal_identity", |
|||
"4. Personal Identity Section (/sections/personal_identity/)", |
|||
"/api/marriage/forms/profile/sections/personal_identity/?lang=fa", |
|||
async () => { |
|||
const res = await http.get( |
|||
"/api/marriage/forms/profile/sections/personal_identity/?lang=fa", |
|||
); |
|||
return { |
|||
section_progress: res.data?.section_progress, |
|||
current_step: res.data?.section_progress?.current_step, |
|||
total_steps: res.data?.section_progress?.total_steps, |
|||
answers_count: Object.keys(res.data?.answers || {}).length, |
|||
}; |
|||
}, |
|||
); |
|||
|
|||
setIsRunning(false); |
|||
}; |
|||
|
|||
useEffect(() => { |
|||
void runAllTests(); |
|||
}, []); |
|||
|
|||
return ( |
|||
<div className="min-h-screen bg-slate-900 text-slate-100 p-4 font-sans dir-ltr text-left"> |
|||
<div className="max-w-3xl mx-auto space-y-4"> |
|||
{/* Header */} |
|||
<div className="bg-slate-800 rounded-2xl p-5 border border-slate-700 shadow-xl flex items-center justify-between"> |
|||
<div> |
|||
<h1 className="text-xl font-bold text-emerald-400"> |
|||
🧪 Marriage Backend API & GeoIP Diagnostics |
|||
</h1> |
|||
<p className="text-xs text-slate-400 mt-1"> |
|||
Live Phone Webview Test Runner & Logger |
|||
</p> |
|||
</div> |
|||
<button |
|||
onClick={runAllTests} |
|||
disabled={isRunning} |
|||
className="bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white font-bold text-sm px-4 py-2.5 rounded-xl transition cursor-pointer" |
|||
> |
|||
{isRunning ? "Running..." : "🔄 Re-run All"} |
|||
</button> |
|||
</div> |
|||
|
|||
{/* Client Diagnostics */} |
|||
<div className="bg-slate-800 rounded-2xl p-4 border border-slate-700 space-y-2 text-xs"> |
|||
<div className="font-bold text-slate-300">📱 Device Info:</div> |
|||
<div className="text-slate-400 truncate"> |
|||
<span className="text-slate-500">UA:</span> {clientInfo.userAgent} |
|||
</div> |
|||
<div className="text-slate-400 break-all"> |
|||
<span className="text-slate-500">Stored LocalStorage Geo:</span>{" "} |
|||
{clientInfo.localStorageGeo || "None"} |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Results List */} |
|||
<div className="space-y-3"> |
|||
{Object.entries(results).map(([id, res]) => ( |
|||
<div |
|||
key={id} |
|||
className="bg-slate-800 rounded-2xl p-4 border border-slate-700 space-y-2 shadow-lg" |
|||
> |
|||
<div className="flex items-center justify-between"> |
|||
<span className="font-bold text-sm text-slate-200"> |
|||
{res.name} |
|||
</span> |
|||
<span |
|||
className={`text-xs px-2.5 py-1 rounded-full font-bold ${ |
|||
res.status === "success" |
|||
? "bg-emerald-950 text-emerald-400 border border-emerald-800" |
|||
: res.status === "error" |
|||
? "bg-rose-950 text-rose-400 border border-rose-800" |
|||
: "bg-amber-950 text-amber-400 border border-amber-800 animate-pulse" |
|||
}`}
|
|||
> |
|||
{res.status === "success" |
|||
? `✅ 200 OK (${res.timeMs}ms)` |
|||
: res.status === "error" |
|||
? `❌ ${res.statusCode || "Error"} (${res.timeMs}ms)` |
|||
: "⏳ Testing..."} |
|||
</span> |
|||
</div> |
|||
|
|||
{res.url && ( |
|||
<div className="font-mono text-[11px] text-slate-400 bg-slate-950 px-2.5 py-1 rounded-lg truncate"> |
|||
URL: {res.url} |
|||
</div> |
|||
)} |
|||
|
|||
{res.error && ( |
|||
<div className="p-2.5 bg-rose-950/60 border border-rose-800 rounded-xl text-rose-300 text-xs font-mono"> |
|||
Error: {res.error} |
|||
</div> |
|||
)} |
|||
|
|||
{res.data && ( |
|||
<div className="bg-slate-950 p-3 rounded-xl border border-slate-800 overflow-x-auto max-h-60 overflow-y-auto"> |
|||
<pre className="font-mono text-xs text-emerald-300 whitespace-pre-wrap break-all"> |
|||
{JSON.stringify(res.data, null, 2)} |
|||
</pre> |
|||
</div> |
|||
)} |
|||
</div> |
|||
))} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,5 @@ |
|||
import TestApiPage from "../[lang]/test-api/page"; |
|||
|
|||
export default function RootTestApiPage() { |
|||
return <TestApiPage />; |
|||
} |
|||
@ -0,0 +1,213 @@ |
|||
"use client"; |
|||
|
|||
import type { SVGProps } from "react"; |
|||
import { useId } from "react"; |
|||
|
|||
/** |
|||
* Inline SVG components for Intro page stats and video controls. |
|||
* Rendering these icons as inline SVG eliminates external HTTP requests, |
|||
* lazy-loading delay, and layout shifts — rendering instantly on frame 0. |
|||
*/ |
|||
|
|||
export function IntroUsersIcon(props: SVGProps<SVGSVGElement>) { |
|||
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, ""); |
|||
const gradId = `paint0_linear_users_${uid}`; |
|||
|
|||
return ( |
|||
<svg |
|||
width="38" |
|||
height="38" |
|||
viewBox="0 0 38 38" |
|||
fill="none" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
aria-hidden="true" |
|||
{...props} |
|||
> |
|||
<path |
|||
d="M18.7918 3.13281C20.3406 3.13281 21.8547 3.5921 23.1425 4.4526C24.4304 5.3131 25.4341 6.53617 26.0268 7.96713C26.6196 9.39809 26.7746 10.9727 26.4725 12.4918C26.1703 14.0109 25.4245 15.4063 24.3292 16.5015C23.234 17.5967 21.8387 18.3425 20.3196 18.6447C18.8005 18.9469 17.2259 18.7918 15.7949 18.1991C14.364 17.6063 13.1409 16.6026 12.2804 15.3148C11.4199 14.0269 10.9606 12.5128 10.9606 10.964L10.9684 10.6241C11.056 8.60798 11.9185 6.70348 13.3761 5.30778C14.8336 3.91208 16.7737 3.13293 18.7918 3.13281ZM21.9242 21.9276C24.0012 21.9276 25.9931 22.7527 27.4617 24.2213C28.9303 25.69 29.7554 27.6819 29.7554 29.7588V31.325C29.7554 32.1558 29.4254 32.9526 28.8379 33.54C28.2505 34.1275 27.4537 34.4575 26.6229 34.4575H10.9606C10.1298 34.4575 9.33306 34.1275 8.7456 33.54C8.15815 32.9526 7.82813 32.1558 7.82812 31.325V29.7588C7.82812 27.6819 8.65319 25.69 10.1218 24.2213C11.5905 22.7527 13.5823 21.9276 15.6593 21.9276H21.9242Z" |
|||
fill={`url(#${gradId})`} |
|||
/> |
|||
<defs> |
|||
<linearGradient |
|||
id={gradId} |
|||
x1="29.9156" |
|||
y1="34.5362" |
|||
x2="5.53913" |
|||
y2="13.5045" |
|||
gradientUnits="userSpaceOnUse" |
|||
> |
|||
<stop stopColor="#FE6F82" /> |
|||
<stop offset="1" stopColor="#E03950" /> |
|||
</linearGradient> |
|||
</defs> |
|||
</svg> |
|||
); |
|||
} |
|||
|
|||
export function IntroMatchesIcon(props: SVGProps<SVGSVGElement>) { |
|||
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, ""); |
|||
const maskId = `mask0_matches_${uid}`; |
|||
const gradId = `paint0_linear_matches_${uid}`; |
|||
|
|||
return ( |
|||
<svg |
|||
width="38" |
|||
height="38" |
|||
viewBox="0 0 38 38" |
|||
fill="none" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
aria-hidden="true" |
|||
{...props} |
|||
> |
|||
<mask |
|||
id={maskId} |
|||
style={{ maskType: "luminance" }} |
|||
maskUnits="userSpaceOnUse" |
|||
x="3" |
|||
y="2" |
|||
width="32" |
|||
height="34" |
|||
> |
|||
<path |
|||
d="M18.7941 3.13281L22.9078 6.13372L28.0004 6.12432L29.5643 10.9703L33.6898 13.9555L32.1071 18.7952L33.6898 23.6348L29.5643 26.6201L28.0004 31.466L22.9078 31.4566L18.7941 34.4575L14.6804 31.4566L9.58779 31.466L8.0239 26.6201L3.89844 23.6348L5.48112 18.7952L3.89844 13.9555L8.0239 10.9703L9.58779 6.12432L14.6804 6.13372L18.7941 3.13281Z" |
|||
fill="white" |
|||
stroke="white" |
|||
strokeWidth="1.71553" |
|||
strokeLinecap="round" |
|||
strokeLinejoin="round" |
|||
/> |
|||
<path |
|||
d="M13.3125 18.7945L17.2281 22.7101L25.0593 14.8789" |
|||
stroke="black" |
|||
strokeWidth="1.71553" |
|||
strokeLinecap="round" |
|||
strokeLinejoin="round" |
|||
/> |
|||
</mask> |
|||
<g mask={`url(#${maskId})`}> |
|||
<path d="M0 0L37.5896 0V37.5896H0L0 0Z" fill={`url(#${gradId})`} /> |
|||
</g> |
|||
<defs> |
|||
<linearGradient |
|||
id={gradId} |
|||
x1="37.8642" |
|||
y1="37.6841" |
|||
x2="8.92816" |
|||
y2="2.01879" |
|||
gradientUnits="userSpaceOnUse" |
|||
> |
|||
<stop stopColor="#FE6F82" /> |
|||
<stop offset="1" stopColor="#E03950" /> |
|||
</linearGradient> |
|||
</defs> |
|||
</svg> |
|||
); |
|||
} |
|||
|
|||
export function IntroMarriagesIcon(props: SVGProps<SVGSVGElement>) { |
|||
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, ""); |
|||
const gradId = `paint0_linear_marriages_${uid}`; |
|||
|
|||
return ( |
|||
<svg |
|||
width="38" |
|||
height="38" |
|||
viewBox="0 0 38 38" |
|||
fill="none" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
aria-hidden="true" |
|||
{...props} |
|||
> |
|||
<path |
|||
d="M3.44531 14.7233C3.44531 16.7594 3.75856 19.8919 6.57778 22.7111C9.08376 25.2171 17.3848 30.8555 17.6981 31.1688C18.0113 31.3254 18.3245 31.482 18.6378 31.482C18.951 31.482 19.2643 31.3254 19.5775 31.1688C19.8908 30.8555 28.1918 25.3737 30.6978 22.7111C33.517 19.8919 33.8303 16.7594 33.8303 14.7233C33.8303 10.0246 30.0713 6.26562 25.3726 6.26562C22.8666 6.26562 20.3607 7.67524 18.7944 9.86797C17.2282 7.67524 14.7222 6.26562 11.903 6.26562C7.3609 6.26562 3.44531 10.0246 3.44531 14.7233Z" |
|||
fill={`url(#${gradId})`} |
|||
/> |
|||
<defs> |
|||
<linearGradient |
|||
id={gradId} |
|||
x1="34.0522" |
|||
y1="31.5454" |
|||
x2="15.6718" |
|||
y2="4.24691" |
|||
gradientUnits="userSpaceOnUse" |
|||
> |
|||
<stop stopColor="#FE6F82" /> |
|||
<stop offset="1" stopColor="#E03950" /> |
|||
</linearGradient> |
|||
</defs> |
|||
</svg> |
|||
); |
|||
} |
|||
|
|||
export function IntroPlayButtonIcon(props: SVGProps<SVGSVGElement>) { |
|||
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, ""); |
|||
const filterId = `filter0_d_${uid}`; |
|||
|
|||
return ( |
|||
<svg |
|||
width="68" |
|||
height="68" |
|||
viewBox="0 0 90 90" |
|||
fill="none" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
aria-hidden="true" |
|||
{...props} |
|||
> |
|||
<g opacity="0.8" filter={`url(#${filterId})`}> |
|||
<rect |
|||
x="11.1406" |
|||
y="6.86328" |
|||
width="68" |
|||
height="68" |
|||
rx="34" |
|||
transform="rotate(0.232334 11.1406 6.86328)" |
|||
fill="#D7DBE2" |
|||
shapeRendering="crispEdges" |
|||
/> |
|||
<path |
|||
fillRule="evenodd" |
|||
clipRule="evenodd" |
|||
d="M40.4883 33.2406C40.825 33.2573 41.1516 33.3557 41.4354 33.5256L52.6521 40.5683C52.9122 40.7282 53.1262 40.9464 53.2735 41.2026C53.4207 41.4589 53.4969 41.7458 53.4957 42.0365C53.4944 42.3272 53.4161 42.6136 53.2668 42.8686C53.1173 43.1237 52.9017 43.3401 52.6402 43.4979L41.3668 50.4493C41.0561 50.627 40.6997 50.7237 40.3354 50.7293C40.0264 50.7279 39.7213 50.6587 39.4455 50.5285C39.1467 50.3764 38.8977 50.1511 38.7255 49.8771C38.5534 49.6031 38.4638 49.2902 38.4675 48.9727L38.5242 34.9787C38.5229 34.661 38.6152 34.3482 38.7896 34.0755C38.964 33.8029 39.2148 33.5796 39.5149 33.43C39.8162 33.2891 40.1517 33.2239 40.4883 33.2406Z" |
|||
fill="black" |
|||
/> |
|||
</g> |
|||
<defs> |
|||
<filter |
|||
id={filterId} |
|||
x="-0.132812" |
|||
y="-0.136719" |
|||
width="90.2734" |
|||
height="90.2754" |
|||
filterUnits="userSpaceOnUse" |
|||
colorInterpolationFilters="sRGB" |
|||
> |
|||
<feFlood floodOpacity="0" result="BackgroundImageFix" /> |
|||
<feColorMatrix |
|||
in="SourceAlpha" |
|||
type="matrix" |
|||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" |
|||
result="hardAlpha" |
|||
/> |
|||
<feOffset dy="4" /> |
|||
<feGaussianBlur stdDeviation="5.5" /> |
|||
<feComposite in2="hardAlpha" operator="out" /> |
|||
<feColorMatrix |
|||
type="matrix" |
|||
values="0 0 0 0 0.616667 0 0 0 0 0.131042 0 0 0 0 0.131042 0 0 0 0.2 0" |
|||
/> |
|||
<feBlend |
|||
mode="normal" |
|||
in2="BackgroundImageFix" |
|||
result="effect1_dropShadow_2035_16301" |
|||
/> |
|||
<feBlend |
|||
mode="normal" |
|||
in="SourceGraphic" |
|||
in2="effect1_dropShadow_2035_16301" |
|||
result="shape" |
|||
/> |
|||
</filter> |
|||
</defs> |
|||
</svg> |
|||
); |
|||
} |
|||
63
src/components/Componentes/section-icon.tsx
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,236 @@ |
|||
"use client"; |
|||
|
|||
import { PhoneNumberUtil } from "google-libphonenumber"; |
|||
import { http } from "./http"; |
|||
import { resolveCountryName } from "@/data/countries"; |
|||
|
|||
export type UserGeoRegion = { |
|||
ip?: string; |
|||
city?: string; |
|||
country?: string; |
|||
countryCode?: string; // e.g. "IR", "US", "GB"
|
|||
phoneCode?: string; // e.g. "+98", "+1", "+44"
|
|||
}; |
|||
|
|||
const phoneUtil = PhoneNumberUtil.getInstance(); |
|||
|
|||
const STORAGE_KEY = "user_geo_region"; |
|||
const PHONE_STORAGE_KEY = "geoIPPhoneCode"; |
|||
|
|||
let cachedRegion: UserGeoRegion | null = null; |
|||
let geoRegionPromise: Promise<UserGeoRegion> | null = null; |
|||
const listeners = new Set<(region: UserGeoRegion) => void>(); |
|||
|
|||
export function resetUserGeoRegionForTesting() { |
|||
cachedRegion = null; |
|||
geoRegionPromise = null; |
|||
listeners.clear(); |
|||
} |
|||
|
|||
export function subscribeToUserGeoRegion( |
|||
fn: (region: UserGeoRegion) => void, |
|||
): () => void { |
|||
listeners.add(fn); |
|||
return () => { |
|||
listeners.delete(fn); |
|||
}; |
|||
} |
|||
|
|||
export function getStoredUserGeoRegion(): UserGeoRegion | null { |
|||
if (cachedRegion) return cachedRegion; |
|||
if (typeof window !== "undefined") { |
|||
try { |
|||
const stored = localStorage.getItem(STORAGE_KEY); |
|||
if (stored) { |
|||
const parsed = JSON.parse(stored) as UserGeoRegion; |
|||
if (parsed && (parsed.country || parsed.phoneCode)) { |
|||
cachedRegion = parsed; |
|||
return parsed; |
|||
} |
|||
} |
|||
} catch {} |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
export function setStoredUserGeoRegion(region: UserGeoRegion) { |
|||
cachedRegion = region; |
|||
if (typeof window !== "undefined") { |
|||
try { |
|||
localStorage.setItem(STORAGE_KEY, JSON.stringify(region)); |
|||
if (region.phoneCode) { |
|||
localStorage.setItem(PHONE_STORAGE_KEY, region.phoneCode); |
|||
} |
|||
} catch {} |
|||
} |
|||
listeners.forEach((fn) => fn(region)); |
|||
} |
|||
|
|||
function resolvePhoneCodeFromCountryCode(isoCode?: string): string | undefined { |
|||
if (!isoCode) return undefined; |
|||
try { |
|||
const callingCode = phoneUtil.getCountryCodeForRegion( |
|||
isoCode.toUpperCase(), |
|||
); |
|||
if (callingCode) { |
|||
return `+${callingCode}`; |
|||
} |
|||
} catch {} |
|||
return undefined; |
|||
} |
|||
|
|||
export function getUserGeoRegion(force = false): Promise<UserGeoRegion> { |
|||
if (!force) { |
|||
const existing = getStoredUserGeoRegion(); |
|||
if (existing && (existing.city || existing.country)) { |
|||
return Promise.resolve(existing); |
|||
} |
|||
} else { |
|||
cachedRegion = null; |
|||
geoRegionPromise = null; |
|||
} |
|||
|
|||
if (geoRegionPromise) { |
|||
return geoRegionPromise; |
|||
} |
|||
|
|||
geoRegionPromise = (async () => { |
|||
try { |
|||
// 1. Primary: Habib Backend User Region API (/account/auth/user/region/)
|
|||
try { |
|||
console.log("[GEO_AUTO_LOG] 🚀 Requesting /account/auth/user/region/ from backend..."); |
|||
const response = await http.get<{ |
|||
ip?: string; |
|||
country?: string; |
|||
country_code?: string; |
|||
city?: string; |
|||
}>("/account/auth/user/region/", { |
|||
timeout: 4000, |
|||
}); |
|||
|
|||
const data = response.data; |
|||
console.log("[GEO_AUTO_LOG] 📥 Backend Raw Response:", JSON.stringify(data)); |
|||
if (data && (data.country || data.country_code || data.city)) { |
|||
const isoCode = |
|||
data.country_code || |
|||
(data.country && data.country.trim().length === 2 |
|||
? data.country.trim().toUpperCase() |
|||
: undefined); |
|||
const phoneCode = resolvePhoneCodeFromCountryCode(isoCode); |
|||
const countryName = |
|||
resolveCountryName(data.country || isoCode, "en") || data.country; |
|||
const countryFa = |
|||
resolveCountryName(data.country || isoCode, "fa") || countryName; |
|||
|
|||
console.log( |
|||
`[GEO_AUTO_LOG] 🌍 Resolved Country: FA=${countryFa} | EN=${countryName} | Code=${isoCode} | City=${data.city || "None"} | IP=${data.ip || "None"}`, |
|||
); |
|||
|
|||
const region: UserGeoRegion = { |
|||
ip: data.ip, |
|||
city: data.city, |
|||
country: countryName, |
|||
countryCode: isoCode, |
|||
phoneCode: phoneCode || "+44", |
|||
}; |
|||
setStoredUserGeoRegion(region); |
|||
return region; |
|||
} |
|||
} catch (err: any) { |
|||
console.error( |
|||
"[GEO_AUTO_LOG] ❌ Backend Region Error:", |
|||
err?.response?.data || err?.message || err, |
|||
); |
|||
} |
|||
|
|||
// 2. Secondary fallback: ipapi.co with 2s timeout
|
|||
const controller = new AbortController(); |
|||
const timeoutId = setTimeout(() => controller.abort(), 2000); |
|||
try { |
|||
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 { |
|||
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); |
|||
} |
|||
|
|||
// 4. Default fallback
|
|||
const defaultRegion: UserGeoRegion = { |
|||
phoneCode: "+44", |
|||
}; |
|||
setStoredUserGeoRegion(defaultRegion); |
|||
return defaultRegion; |
|||
} catch { |
|||
const defaultRegion: UserGeoRegion = { |
|||
phoneCode: "+44", |
|||
}; |
|||
setStoredUserGeoRegion(defaultRegion); |
|||
return defaultRegion; |
|||
} |
|||
})(); |
|||
|
|||
return geoRegionPromise; |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue