41 Commits

Author SHA1 Message Date
Muhammad A. Ghorbani 8bce4dcd7c refactor: resolve merge conflict in question card icon handling 3 days ago
Muhammad A. Ghorbani f9ca38f8f5 Merge branch 'origin/master' into Dev 3 days ago
mortezaei cc31688ded fix input autofill code 4 days ago
mortezaei e98b405416 fix input autofill code 4 days ago
mortezaei 0443604714 fix input autofill code 4 days ago
mortezaei 4a4051ea93 fix input autofill code 4 days ago
mortezaei cb3c34ee90 fix input autofill code 4 days ago
mortezaei 29126ce5c2 refactor: remove Flutter bridge location lookup to prioritize backend-based geolocation 4 days ago
mortezaei 9d96917f6a feat(geo): integrate flutter bridge get_location to fetch native region from phone 4 days ago
mortezaei b18218f992 feat(proxy): add security key support and add api testing pages 4 days ago
mortezaei de7c2eb953 refactor: add object support to field formatter and update internationalization translations 4 days ago
mortezaei f90bdf84c9 refactor: add object support to field formatter and update internationalization translations 4 days ago
mortezaei 2a37174580 fix(geo): support force refresh in getUserGeoRegion and pass force on auto click 4 days ago
mortezaei d351f32b25 fix(proxy): remove prohibited CF-* headers to prevent Cloudflare Error 1000 4 days ago
mortezaei 73758ec53f refactor: improve geo-location handling by standardizing country name resolution and forwarding client IP headers to the backend 4 days ago
mortezaei cc68e1dc27 fix: restrict option_id to choice types and update tests 4 days ago
mortezaei 7e82acb605 feat: ensure 1-to-1 canonical option keys across all languages for countries and languages 4 days ago
mortezaei 923a7c6219 fix: robust option_id mapping in createQuestionField 4 days ago
mortezaei 6121938a17 feat: support full 195 countries dataset merging in QuestionSheet for nationality and country fields 4 days ago
mortezaei fe51e90efd fix: calibrate initial keyboard estimate dynamically based on viewport height and latch intermediate animation frames 4 days ago
mortezaei 2663ed078b fix: stabilize keyboard viewport lift during incremental animations by rounding values and adjusting height logic 4 days ago
mortezaei c09ad72ba7 refactor: remove web bootstrap pending state and set ready status directly upon web_ready signal 4 days ago
mortezaei 38fbf009da feat: add health and physical condition questions to schema and translations, and integrate new section assets and iconography 4 days ago
mortezaei c9ee7a7d37 feat: enable multi-select support for dropdown components and update related tests 4 days ago
mortezaei 9ab34a7609 feat: implement automatic location pre-filling for birthplace questions using stored geo-region data 5 days ago
mortezaei 75144fd6eb refactor(geo): centralize geolocation logic into a unified utility 5 days ago
mortezaei 872129fe3a refactor: remove swipe gestures from slider and improve keyboard viewport height estimation logic 5 days ago
mortezaei 879f864575 feat: implement phone number formatting, dynamic placeholders, and validation for QuestionPhone component with multi-language support 5 days ago
mortezaei 280599eb07 refactor: extract base geometry calculation and add phone number formatter import 5 days ago
mortezaei 2ddb4f31e5 feat: integrate Habib user region API for IP geolocation and update phone component shimmer UI 5 days ago
mortezaei 3f255ad185 refactor: optimize keyboard height updates and improve viewport coordinate state transitions 5 days ago
mortezaei 17a0db24fa perf(ui): optimize transition timing and keyboard viewport coordination 5 days ago
mortezaei fc95309d5b refactor(ui): remove unnecessary finally block in SliderPage submission 5 days ago
mortezaei 47f756aa23 refactor: standardize modal sheet dimensions and simplify SliderPage navigation logic 5 days ago
mortezaei afa38167f9 fix input autofill code 5 days ago
mortezaei 576b538f13 refactor(auth): optimize onboarding redirection logic 5 days ago
mortezaei eabf6df639 feat(ui): add completion callback and i18n support to SliderPage 5 days ago
mortezaei 9ac3b90e8d fix input autofill code 5 days ago
mortezaei 6f9557dd76 refactor: replace external SVG images with inline React components in Intro page 5 days ago
mortezaei 58100b685f feat: integrate SliderPage as an overlay in intro-client with hardware back button support 5 days ago
mortezaei 31fd5b8f50 feat(ui): improve slider touch gestures and add i18n support 5 days ago
  1. 16
      src/app/[lang]/page.tsx
  2. 273
      src/app/[lang]/test-api/page.tsx
  3. 28
      src/app/api/proxy/route.ts
  4. 6
      src/app/globals.css
  5. 109
      src/app/intro/intro-client.tsx
  6. 14
      src/app/layout.tsx
  7. 263
      src/app/new-match/new-match-client.tsx
  8. 6
      src/app/questions-list/questions-list-client.tsx
  9. 5
      src/app/test-api/page.tsx
  10. 213
      src/components/Componentes/intro-hero-illustration.tsx
  11. 22
      src/components/Componentes/question-answer-storage.tsx
  12. 72
      src/components/Componentes/question-answer.test.tsx
  13. 138
      src/components/Componentes/question-birthplace.tsx
  14. 35
      src/components/Componentes/question-card.tsx
  15. 2
      src/components/Componentes/question-checkbox.tsx
  16. 207
      src/components/Componentes/question-phone.test.tsx
  17. 330
      src/components/Componentes/question-phone.tsx
  18. 2
      src/components/Componentes/question-radio.tsx
  19. 14
      src/components/Componentes/question-sheet.test.tsx
  20. 108
      src/components/Componentes/question-sheet.tsx
  21. 40
      src/components/Componentes/question-snap-list.tsx
  22. 129
      src/components/Componentes/question-viewport-coordinator.ts
  23. 63
      src/components/Componentes/section-icon.tsx
  24. 1
      src/components/Componentes/slider-page.test.tsx
  25. 66
      src/components/Componentes/slider-page.tsx
  26. 32
      src/components/Componentes/slider-slide-two.tsx
  27. 43
      src/components/Componentes/ui-config.test.tsx
  28. 49
      src/data/countries.ts
  29. 79
      src/data/languages.ts
  30. 4
      src/hooks/marriage/use-profile-main.test.ts
  31. 2
      src/hooks/marriage/use-profile-main.ts
  32. 3
      src/lib/entry-route-cache.test.ts
  33. 1
      src/lib/entry-route-cache.ts
  34. 236
      src/lib/geo-region.ts
  35. 12
      src/lib/marriage-field-formatter.ts
  36. 11
      src/translations/locales/ar.json
  37. 19
      src/translations/locales/az.json
  38. 13
      src/translations/locales/bn.json
  39. 13
      src/translations/locales/da.json
  40. 7
      src/translations/locales/de.json
  41. 4
      src/translations/locales/en.json
  42. 11
      src/translations/locales/es.json
  43. 3
      src/translations/locales/fa.json
  44. 11
      src/translations/locales/fr.json
  45. 11
      src/translations/locales/gu.json
  46. 11
      src/translations/locales/ha.json
  47. 11
      src/translations/locales/he.json
  48. 11
      src/translations/locales/hi.json
  49. 11
      src/translations/locales/id.json
  50. 13
      src/translations/locales/ks.json
  51. 11
      src/translations/locales/pt.json
  52. 11
      src/translations/locales/ru.json
  53. 11
      src/translations/locales/sw.json
  54. 11
      src/translations/locales/tg.json
  55. 11
      src/translations/locales/tr.json
  56. 13
      src/translations/locales/ul.json
  57. 11
      src/translations/locales/ur.json
  58. 11
      src/translations/locales/uz.json
  59. 13
      src/translations/locales/zh.json
  60. 7
      src/translations/provider.tsx

16
src/app/[lang]/page.tsx

@ -39,15 +39,25 @@ export default async function LocaleEntryPage({
);
if (cachedEntryPath) {
if (cachedEntryPath === "/intro") {
return <Intro />;
}
redirect(localizePath(cachedEntryPath, lang));
}
// 3. Deep SSR Profile Resolution: resolve profile & redirect directly on server
const profile = await fetchProfileSSR(token);
if (profile) {
const targetPath = hasCompletedMarriageProfileBasics(profile)
? getSubmitPath(profile)
: "/intro";
const isBasicsCompleted = hasCompletedMarriageProfileBasics(profile);
if (!isBasicsCompleted) {
return <Intro />;
}
const targetPath = getSubmitPath(profile);
if (targetPath === "/intro") {
return <Intro />;
}
redirect(localizePath(targetPath, lang));
}

273
src/app/[lang]/test-api/page.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>
);
}

28
src/app/api/proxy/route.ts

@ -13,6 +13,8 @@ const REQUEST_HEADERS_TO_FORWARD = [
"cookie",
"token",
"content-type",
"security-key",
"x-security-key",
"x-csrf-token",
"x-csrftoken",
"x-requested-with",
@ -147,6 +149,15 @@ function getRequestHeaders(request: NextRequest, targetUrl: URL) {
headers.set("authorization", `Token ${authKey}`);
}
// Set security-key if available in environment and not already in headers
const securityKey =
process.env.NEXT_PUBLIC_SECURITY_KEY ||
process.env.SECURITY_KEY ||
"t5yugymks5458fd4ghfg6h6";
if (securityKey && !headers.has("security-key")) {
headers.set("security-key", securityKey);
}
// Dynamically set language headers
const requestedLanguages = [
request.headers.get("x-user-language"),
@ -165,9 +176,20 @@ function getRequestHeaders(request: NextRequest, targetUrl: URL) {
headers.set("x-user-language", lang);
headers.set("http_x_user_language", lang);
headers.set("origin", targetUrl.origin);
headers.set("platform", "android");
headers.set("referer", `${targetUrl.origin}/`);
headers.set("user-agent", "dart:io");
const clientUserAgent = request.headers.get("user-agent");
headers.set("user-agent", clientUserAgent || "dart:io");
// Forward client IP headers so backend can determine actual user location
const clientIp =
request.headers.get("cf-connecting-ip") ||
request.headers.get("x-real-ip") ||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
if (clientIp) {
headers.set("x-forwarded-for", clientIp);
headers.set("x-real-ip", clientIp);
headers.set("x-client-ip", clientIp);
}
return headers;
}

6
src/app/globals.css

@ -231,10 +231,6 @@ html:lang(ar) body,
-webkit-overflow-scrolling: touch;
}
html[data-web-bootstrap="pending"] .app-shell {
visibility: hidden;
}
@media (min-width: 640px) {
.app-shell {
width: 375px;
@ -351,7 +347,7 @@ body.section-overlay-open .app-shell {
calc(var(--question-lift-y, 0px) * -1),
0
);
transition: transform 340ms cubic-bezier(0.22, 1, 0.36, 1);
transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform;
}

109
src/app/intro/intro-client.tsx

@ -2,15 +2,24 @@
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import Button from "@/components/Componentes/button";
import NetworkImage from "@/components/Componentes/network-image";
import PageHeader from "@/components/Componentes/page-header";
import ReportActionsSheet from "@/components/Componentes/report-actions-sheet";
import SectionOverlayHost from "@/components/Componentes/section-overlay-host";
import SliderPage from "@/components/Componentes/slider-page";
import VideoPlayer from "@/components/Componentes/video-player";
import {
IntroMatchesIcon,
IntroMarriagesIcon,
IntroPlayButtonIcon,
IntroUsersIcon,
} from "@/components/Componentes/intro-hero-illustration";
import { useMarriageConfigQuery } from "@/hooks/marriage/use-marriage-config";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { authBridge } from "@/lib/auth-bridge";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
@ -26,6 +35,7 @@ export default function IntroClient() {
const [isReportSheetOpen, setIsReportSheetOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
const [isStepsOpen, setIsStepsOpen] = useState(false);
const { data: config } = useMarriageConfigQuery();
@ -33,6 +43,47 @@ export default function IntroClient() {
// image so we don't need to wait for it — announce immediately on mount.
useHabibWebReady(true);
useEffect(() => {
const readStepsFromUrl = () => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
setIsStepsOpen(params.get("steps") === "open");
};
readStepsFromUrl();
window.addEventListener("popstate", readStepsFromUrl);
return () => window.removeEventListener("popstate", readStepsFromUrl);
}, []);
const handleOpenSteps = useCallback(() => {
setIsStepsOpen(true);
if (typeof window !== "undefined") {
const url = new URL(window.location.href);
url.searchParams.set("steps", "open");
window.history.pushState({ steps: "open" }, "", url.toString());
}
}, []);
const handleCloseSteps = useCallback(() => {
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
if (params.get("steps") === "open") {
setIsStepsOpen(false);
window.history.back();
return;
}
}
setIsStepsOpen(false);
}, []);
useHardwareBackHandler(() => {
if (isStepsOpen) {
handleCloseSteps();
return true; // handled: keep WebView open, just close the overlay
}
return false; // Root: tell Flutter to close WebView screen
}, isStepsOpen);
const handleSubmit = async () => {
if (isSubmitting) {
return;
@ -61,20 +112,21 @@ export default function IntroClient() {
}
const submitPath = getSubmitPath(profileResponse);
const nextPath = localizePath(
submitPath === "/intro" ? "/terms" : submitPath,
locale,
);
router.push(nextPath);
if (submitPath === "/intro" || submitPath === "/terms") {
handleOpenSteps();
} else {
router.push(localizePath(submitPath, locale));
}
} catch (error) {
console.error("Submission/redirect failed", error);
router.push(localizePath("/terms", locale));
handleOpenSteps();
} finally {
setIsSubmitting(false);
}
};
return (
<>
<div className="pt-[max(12px,calc(var(--safe-top)+4px))]">
{isReportSheetOpen && (
<ReportActionsSheet onClose={() => setIsReportSheetOpen(false)} />
@ -92,6 +144,8 @@ export default function IntroClient() {
alt={t["heavenly marriage"]}
width={168}
height={155}
priority
loading="eager"
/>
<h2 className="group-16 text-[#475569] font-bold mt-5 text-center">
{t["A Path to Heavenly Marriage"]}
@ -106,12 +160,7 @@ export default function IntroClient() {
</div>
<div className="flex items-center justify-between mt-4">
<div className="flex items-center gap-0.5 p-2 border border-[#FD6ABB]/10 rounded-2xl">
<Image
src={"/assets/images/tabler_user-filled.svg"}
alt={t["user profiles"]}
width={37}
height={37}
/>
<IntroUsersIcon />
<div className="flex flex-col items-start justify-start -mb-2">
<p className="text-[#36363C] group-16 font-bold leading-3">120</p>
<p className="text-[#36363C] group-10 font-semibold capitalize">
@ -120,13 +169,7 @@ export default function IntroClient() {
</div>
</div>
<div className="flex items-center gap-0.5 p-2 border border-[#FD6ABB]/10 rounded-2xl">
<Image
src={"/assets/images/icon-park-solid_success.svg"}
alt={t["matches"]}
width={37}
height={37}
priority
/>
<IntroMatchesIcon />
<div className="flex flex-col items-start justify-start -mb-2">
<p className="text-[#36363C] group-16 font-bold leading-3">14</p>
<p className="text-[#36363C] group-10 font-semibold capitalize">
@ -135,13 +178,7 @@ export default function IntroClient() {
</div>
</div>
<div className="flex items-center gap-0.5 p-2 border border-[#FD6ABB]/10 rounded-2xl">
<Image
src={"/assets/images/typcn_heart-full-outline.svg"}
alt={t["marriages"]}
width={37}
height={37}
priority
/>
<IntroMarriagesIcon />
<div className="flex flex-col items-start justify-start -mb-2">
<p className="text-[#36363C] group-16 font-bold leading-3">14</p>
<p className="text-[#36363C] group-10 font-semibold capitalize">
@ -163,13 +200,7 @@ export default function IntroClient() {
priority
/>
<div className="absolute inset-0 bg-black/10 transition-colors duration-300 group-hover:bg-black/20" />
<Image
src={"/assets/images/Frame 1116607280.svg"}
alt={t["play"]}
width={68}
height={68}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transition-transform duration-300 group-hover:scale-110 active:scale-95"
/>
<IntroPlayButtonIcon className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transition-transform duration-300 group-hover:scale-110 active:scale-95" />
</div>
<VideoPlayer
@ -193,5 +224,15 @@ export default function IntroClient() {
</div>
</main>
</div>
<SectionOverlayHost
open={isStepsOpen}
onClose={handleCloseSteps}
>
{isStepsOpen && (
<SliderPage onClose={handleCloseSteps} />
)}
</SectionOverlayHost>
</>
);
}

14
src/app/layout.tsx

@ -248,9 +248,6 @@ export default async function RootLayout({
});
var isWebView = !!(window.HabibApp && window.HabibApp.postMessage);
if (isWebView && !configApplied) {
root.dataset.webBootstrap = 'pending';
}
// 4. Queued web_ready Delivery Protocol
//
@ -273,17 +270,8 @@ export default async function RootLayout({
if (!window.HabibApp || !window.HabibApp.postMessage) return false;
window.__habibWebReadySent = true;
if (!configApplied) {
root.dataset.webBootstrap = 'pending';
}
window.HabibApp.postMessage(JSON.stringify({ action: 'web_ready' }));
// Safety: release bootstrap-pending if initial_config never arrives
setTimeout(function() {
if (root.dataset.webBootstrap === 'pending') {
root.dataset.webBootstrap = 'ready';
}
}, 2500);
window.HabibApp.postMessage(JSON.stringify({ action: 'web_ready' }));
return true;
}

263
src/app/new-match/new-match-client.tsx

@ -53,28 +53,49 @@ const fieldCandidateMatchers = {
"first_name",
"last_name",
"display_name",
"q1_full_name",
],
occupation: [
age: ["age", "date_of_birth", "birth_date", "dob", "birth_year"],
residence: [
"current_residence",
"residence",
"current_country_city",
"location",
"current_city",
"residence_city",
"city",
"country",
"birth_city",
"birthplace",
],
educationLevel: [
"highest_level_of_education",
"highest_education_level",
"education_level",
"education",
],
fieldOfStudy: [
"field_of_study",
"study_field",
"study",
],
job: [
"job_title",
"occupation",
"job_title_and_description",
"job",
"occupation",
"profession",
"career",
"work",
"highest_level_of_education",
"field_of_study",
"employment_status",
"work",
],
age: ["age", "date_of_birth", "birth_date", "dob"],
city: [
"current_residence",
"city",
"current_city",
"residence_city",
"location",
"residence",
"birthplace",
"birth_city",
hobbies: [
"your_hobbies_and_main_interests",
"hobbies_and_interests",
"hobbies",
"interests",
"your_personality_traits",
"personality_traits",
],
maritalStatus: [
"current_marital_status",
@ -243,15 +264,7 @@ function useMatchSummaryDisplay(
displayName = `Profile #${matchSummary.id}`;
}
// 2. Occupation
const occupation = pickField(
fields,
fieldCandidateMatchers.occupation,
usedIndexes,
t,
);
// 3. Age (extract and calculate from date_of_birth if available)
// 2. Age (extract and calculate from date_of_birth if available)
const dobIdx = fields.findIndex(
(f) =>
f.key === "personal_identity.date_of_birth" ||
@ -275,10 +288,47 @@ function useMatchSummaryDisplay(
age = pickField(fields, fieldCandidateMatchers.age, usedIndexes, t);
}
// 4. City / Current Residence
const city = pickField(fields, fieldCandidateMatchers.city, usedIndexes, t);
// 3. Country / City / Residence
const residence = pickField(
fields,
fieldCandidateMatchers.residence,
usedIndexes,
t,
);
// 4. Highest level of education
const educationLevel = pickField(
fields,
fieldCandidateMatchers.educationLevel,
usedIndexes,
t,
);
// 5. Marital Status
// 5. Field of study
const fieldOfStudy = pickField(
fields,
fieldCandidateMatchers.fieldOfStudy,
usedIndexes,
t,
);
// 6. Job / Occupation
const job = pickField(
fields,
fieldCandidateMatchers.job,
usedIndexes,
t,
);
// 7. Hobbies & Interests
const hobbies = pickField(
fields,
fieldCandidateMatchers.hobbies,
usedIndexes,
t,
);
// 8. Marital Status
const maritalStatus = pickField(
fields,
fieldCandidateMatchers.maritalStatus,
@ -286,7 +336,7 @@ function useMatchSummaryDisplay(
t,
);
// 6. City Preference / Relocation
// 9. City Preference / Relocation
const cityPreference = pickField(
fields,
fieldCandidateMatchers.cityPreference,
@ -302,36 +352,41 @@ function useMatchSummaryDisplay(
if (typeof window !== "undefined") {
console.log(
"🔍 [useMatchSummaryDisplay] input matchSummary:",
matchSummary,
);
console.log("🔍 [useMatchSummaryDisplay] computed output:", {
"🔍 [useMatchSummaryDisplay] computed 8 fields output:",
{
displayName,
occupation,
age,
city,
residence,
educationLevel,
fieldOfStudy,
job,
hobbies,
maritalStatus,
cityPreference,
extraFieldsCount: extraFields.length,
});
},
);
}
return {
age,
city,
cityPreference,
extraFields,
residence,
educationLevel,
fieldOfStudy,
job,
hobbies,
maritalStatus,
cityPreference,
name: displayName,
occupation,
extraFields,
};
}, [matchSummary, t]);
}
function FieldLine({ field }: { field: DisplayField }) {
return (
<p className="break-words text-[10px] leading-[1.85] font-medium text-white">
<span>{field.label}: </span>
<p className="break-words text-[11px] leading-[1.65] font-medium text-white">
<span className="font-semibold">{field.label}: </span>
<span>{field.value}</span>
</p>
);
@ -467,41 +522,41 @@ export default function NewMatchClient() {
<PageBackground />
<main
style={{
paddingBottom: `${bottom + 20}px`,
paddingBottom: `${profile?.can_edit_profile === false ? 76 + bottom : 16 + bottom}px`,
paddingTop: `${Math.max(12, top + 4)}px`,
}}
className="-mx-[17px] min-h-screen h-full text-center px-4"
className="-mx-[17px] flex h-dvh flex-col overflow-hidden px-4 text-center"
>
<div className="shrink-0">
<PageHeader profile={profile} />
</div>
<div>
{/* Header Section Skeleton */}
<section className="flex flex-col items-center mt-9">
<div className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain flex flex-col justify-between py-1">
{/* 1. Header Section Skeleton */}
<section className="flex flex-col items-center shrink-0">
<LoadingSkeleton className="h-[60px] w-[60px] rounded-full" />
<LoadingSkeleton className="h-6 w-[220px] mt-4" />
<LoadingSkeleton className="h-4 w-[280px] mt-3" />
<LoadingSkeleton className="h-4 w-[240px] mt-1.5" />
<LoadingSkeleton className="h-6 w-[220px] mt-2.5 sm:mt-3" />
<LoadingSkeleton className="h-4 w-[280px] mt-2" />
<LoadingSkeleton className="h-4 w-[240px] mt-1" />
</section>
<div className="flex h-full flex-col justify-between">
{/* Match Card Skeleton */}
<section className="mt-[36px] rounded-[15px] border border-white/80 bg-white px-[17px] pt-[18px] pb-[17px] shadow-[0_18px_45px_rgba(15,23,42,0.06)] flex flex-col items-center">
{/* 2. Match Card Skeleton */}
<section className="w-full rounded-[15px] border border-white/80 bg-white px-[17px] pt-[16px] pb-[16px] shadow-[0_18px_45px_rgba(15,23,42,0.06)] flex flex-col items-center shrink-0">
{/* Name line */}
<LoadingSkeleton className="h-5 w-[140px]" />
{/* Subtitle / Details lines */}
<div className="mt-3 w-full flex flex-col items-center gap-2 min-h-[48px]">
<div className="mt-2 w-full flex flex-col items-center gap-2 min-h-[40px]">
<LoadingSkeleton className="h-3 w-[180px]" />
<LoadingSkeleton className="h-3 w-[120px]" />
<LoadingSkeleton className="h-3 w-[150px]" />
</div>
{/* Button skeleton */}
<LoadingSkeleton className="mt-[15px] h-[40px] w-full rounded-[10px]" />
<LoadingSkeleton className="mt-3 h-[40px] w-full rounded-[10px]" />
</section>
{/* Advisor Card Skeleton */}
<section className="mt-[36px]">
{/* 3. Advisor Card Skeleton */}
<section className="w-full shrink-0">
<div className="rounded-[13px] border border-white/80 bg-white px-3 py-3.5 text-left shadow-[0_18px_45px_rgba(15,23,42,0.06)] backdrop-blur-sm">
<LoadingSkeleton className="h-[16px] w-[140px]" />
<div className="mt-2 space-y-1">
@ -522,14 +577,20 @@ export default function NewMatchClient() {
</div>
</section>
</div>
</div>
</main>
</>
);
}
const pairedFields = [matchDisplay.age, matchDisplay.city].filter(
(field): field is DisplayField => Boolean(field),
);
const pairedPersonalFields = [
matchDisplay.age,
matchDisplay.residence,
].filter((field): field is DisplayField => Boolean(field));
const pairedEduFields = [
matchDisplay.educationLevel,
matchDisplay.fieldOfStudy,
].filter((field): field is DisplayField => Boolean(field));
const isFemaleProfile = profile?.gender === "female";
const matchHeadingTitle = isFemaleProfile
@ -556,15 +617,18 @@ export default function NewMatchClient() {
<main
style={{
paddingBottom: `${profile?.can_edit_profile === false ? 100 + bottom : 20 + bottom}px`,
paddingBottom: `${profile?.can_edit_profile === false ? 76 + bottom : 16 + bottom}px`,
paddingTop: `${Math.max(12, top + 4)}px`,
}}
className="-mx-[17px] min-h-screen h-full text-center px-4"
className="-mx-[17px] flex h-dvh flex-col overflow-hidden px-4 text-center"
>
<div className="shrink-0">
<PageHeader profile={profile} />
</div>
<div className="">
<section className="flex flex-col items-center mt-9">
<div className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain flex flex-col justify-between py-1">
{/* 1. Header Section */}
<section className="flex flex-col items-center shrink-0">
<div
aria-hidden="true"
className="relative flex h-[60px] w-[60px] items-center justify-center rounded-full bg-[#FF4E67] shadow-[0_12px_28px_rgba(240,68,91,0.22)]"
@ -576,61 +640,85 @@ export default function NewMatchClient() {
alt="notification"
/>
</div>
<h2 className="text-[22px] font-bold mt-3.5">
<h2 className="text-[22px] font-bold mt-2.5 sm:mt-3">
{matchHeadingTitle}
</h2>
<p className="mt-[11px] max-w-[322px] text-[12px] leading-[1.35] font-semibold text-[#7C7C7C]">
<p className="mt-2 max-w-[322px] text-[12px] leading-[1.35] font-semibold text-[#7C7C7C]">
{matchHeadingDescription}
</p>
</section>
<div className="flex h-full flex-col justify-between">
{/* 2. Match Summary Card Section */}
<div className="w-full shrink-0">
{isLoading ? (
<section className="mt-[36px] rounded-[15px] border border-white/80 bg-white px-[17px] pt-[18px] pb-[17px] shadow-[0_18px_45px_rgba(15,23,42,0.06)]">
<section className="rounded-[15px] border border-white/80 bg-white px-[17px] pt-[16px] pb-[16px] shadow-[0_18px_45px_rgba(15,23,42,0.06)]">
<div className="flex flex-col items-center py-2">
{/* Name line */}
<LoadingSkeleton className="h-5 w-[140px]" />
{/* Subtitle / Details lines */}
<div className="mt-3 w-full flex flex-col items-center gap-2 min-h-[48px]">
<div className="mt-2 w-full flex flex-col items-center gap-2 min-h-[40px]">
<LoadingSkeleton className="h-3 w-[180px]" />
<LoadingSkeleton className="h-3 w-[120px]" />
<LoadingSkeleton className="h-3 w-[150px]" />
</div>
{/* Button skeleton */}
<LoadingSkeleton className="mt-[15px] h-[40px] w-full rounded-[10px]" />
<LoadingSkeleton className="mt-3 h-[40px] w-full rounded-[10px]" />
</div>
</section>
) : (
<section className="mt-[36px] rounded-[15px] bg-[linear-gradient(180deg,#F0445B_0%,#F4556E_100%)] px-[17px] pt-[18px] pb-[17px] text-white shadow-[0_18px_38px_rgba(240,68,91,0.25)]">
<section className="rounded-[15px] bg-[linear-gradient(180deg,#F0445B_0%,#F4556E_100%)] px-[17px] pt-[16px] pb-[16px] text-white shadow-[0_18px_38px_rgba(240,68,91,0.25)]">
{isError ? (
<p className="py-8 text-[13px] font-semibold">
Unable to load match summary.
</p>
) : matchSummary ? (
<>
<h2 className="break-words text-[13px] leading-[1.4] font-bold">
<h2 className="break-words text-[14px] leading-[1.4] font-bold">
<span>{matchDisplay.name}</span>
</h2>
<div className="mt-[3px] min-h-[48px]">
{matchDisplay.occupation ? (
<FieldLine field={matchDisplay.occupation} />
<div className="mt-1.5 min-h-[40px] space-y-0.5 text-left">
{pairedPersonalFields.length ? (
<p className="break-words text-[11px] leading-[1.65] font-medium text-white">
{pairedPersonalFields.map((field, index) => (
<span key={field.id}>
{index > 0 ? (
<span className="opacity-75"> | </span>
) : null}
<span className="font-semibold">
{field.label}:{" "}
</span>
<span>{field.value}</span>
</span>
))}
</p>
) : null}
{pairedFields.length ? (
<p className="break-words text-[10px] leading-[1.85] font-medium text-white">
{pairedFields.map((field, index) => (
{pairedEduFields.length ? (
<p className="break-words text-[11px] leading-[1.65] font-medium text-white">
{pairedEduFields.map((field, index) => (
<span key={field.id}>
{index > 0 ? <span> | </span> : null}
<span>
{field.label}: {field.value}
{index > 0 ? (
<span className="opacity-75"> | </span>
) : null}
<span className="font-semibold">
{field.label}:{" "}
</span>
<span>{field.value}</span>
</span>
))}
</p>
) : null}
{matchDisplay.job ? (
<FieldLine field={matchDisplay.job} />
) : null}
{matchDisplay.hobbies ? (
<FieldLine field={matchDisplay.hobbies} />
) : null}
{matchDisplay.maritalStatus ? (
<FieldLine field={matchDisplay.maritalStatus} />
) : null}
@ -648,7 +736,7 @@ export default function NewMatchClient() {
openProfile();
}
}}
className="mt-[15px] inline-flex w-full items-center justify-center rounded-[10px] border-none bg-white h-[40px] text-[16px] font-semibold text-[#F0445B] no-underline shadow-none hover:bg-white/95 transition-colors cursor-pointer"
className="mt-3 inline-flex w-full items-center justify-center rounded-[10px] border-none bg-white h-[40px] text-[16px] font-semibold text-[#F0445B] no-underline shadow-none hover:bg-white/95 transition-colors cursor-pointer"
>
{t["View more details"]}
</button>
@ -660,9 +748,11 @@ export default function NewMatchClient() {
)}
</section>
)}
<div className="w-full space-y-3">
</div>
{/* 3. Advisor Section */}
<div className="w-full shrink-0">
<AdvisorActionsCard
className="mt-[36px]"
title={t["Get an advisor"]}
description={
t[
@ -676,7 +766,6 @@ export default function NewMatchClient() {
/>
</div>
</div>
</div>
</main>
{profile?.can_edit_profile === false && (

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);

5
src/app/test-api/page.tsx

@ -0,0 +1,5 @@
import TestApiPage from "../[lang]/test-api/page";
export default function RootTestApiPage() {
return <TestApiPage />;
}

213
src/components/Componentes/intro-hero-illustration.tsx

@ -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>
);
}

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

@ -132,11 +132,27 @@ function createQuestionField(
): MarriageField {
let option_id: string | string[] | undefined;
if (question.options && Array.isArray(question.options) && question.options.length > 0) {
const isChoiceType =
question.type === "dropdown" ||
question.type === "radio" ||
question.type === "checkbox" ||
question.type === "scale";
if (isChoiceType && question.options && Array.isArray(question.options) && question.options.length > 0) {
if (Array.isArray(value)) {
option_id = value;
} else if (typeof value === "string" && value) {
const selectedOpt = question.options.find((opt) => opt.id === value || opt.value === value);
const strVal = value.trim().toLowerCase();
const selectedOpt = question.options.find(
(opt) =>
opt.id === value ||
opt.value === value ||
opt.label === value ||
opt.id.toLowerCase() === strVal ||
(typeof opt.value === "string" && opt.value.toLowerCase() === strVal) ||
(typeof opt.label === "string" && opt.label.toLowerCase() === strVal) ||
opt.id.toLowerCase().endsWith(`.${strVal}`)
);
option_id = selectedOpt ? selectedOpt.id : value;
}
}
@ -149,7 +165,7 @@ function createQuestionField(
type: question.type,
value,
private: question.private,
option_id: option_id,
option_id: isChoiceType ? option_id : undefined,
} as MarriageField;
}

72
src/components/Componentes/question-answer.test.tsx

@ -90,6 +90,33 @@ function TestComponent({ slug }: { slug: string }) {
Set Checkbox
</button>
<button
data-testid="set-multi-dropdown"
onClick={() =>
setAnswerValue(
{
id: "q_multi_drop",
type: "dropdown",
title: "Q Multi Drop",
order: 3,
required: true,
baseRequired: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "", options: [], range: [1, 10] },
options: [
{ id: "opt_d1", value: "D1", label: "Option D1", order: 1 },
{ id: "opt_d2", value: "D2", label: "Option D2", order: 2 },
],
},
["opt_d1", "opt_d2"],
)
}
>
Set Multi Dropdown
</button>
<button data-testid="save" onClick={() => flushAnswers()}>
Save
</button>
@ -214,6 +241,51 @@ describe("Question Answer & Schema Integration", () => {
});
});
it("should send array of option_ids for multi-select dropdown", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider
slug="test_slug"
questions={[
{
id: "q_multi_drop",
type: "dropdown",
title: "Q Multi Drop",
order: 3,
required: true,
baseRequired: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "", options: [], range: [1, 10] },
options: [
{ id: "opt_d1", value: "D1", label: "Option D1", order: 1 },
{ id: "opt_d2", value: "D2", label: "Option D2", order: 2 },
],
},
]}
>
<TestComponent slug="test_slug" />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByTestId("set-multi-dropdown"));
fireEvent.click(screen.getByTestId("save"));
await waitFor(() => {
expect(capturedPayload).not.toBeNull();
const field = capturedPayload.fields.find(
(f: any) => f.key === "q_multi_drop",
);
expect(field.option_id).toEqual(["opt_d1", "opt_d2"]);
});
});
it("batches all dirty answers into one mutation", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },

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

@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { getCountryList, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries";
import { getCountryList, resolveCountryName, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage";
@ -10,6 +10,7 @@ import QuestionTitle from "./question-title";
import { LoadingThreeDot } from "./loading-three-dot";
import { useSheetScrollLock } from "./use-sheet-scroll-lock";
import { Input } from "@/components/ui/input";
import { getUserGeoRegion, getStoredUserGeoRegion } from "@/lib/geo-region";
const EXIT_ANIMATION_MS = 220;
@ -88,9 +89,26 @@ export function QuestionBirthplace({
const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers();
const rawValue = getAnswerValue(question);
const isResidence = question.ui_config?.enable_geoip === true;
const storedRegion = isResidence ? getStoredUserGeoRegion() : null;
const initial = parseValue(rawValue);
const [selectedCountry, setSelectedCountry] = useState(initial.country);
const [cityInput, setCityInput] = useState(initial.city);
const localizedInitialCountry = resolveCountryName(
initial.country || storedRegion?.country,
locale,
);
const initialCity = initial.city || storedRegion?.city || "";
const initialLoc =
localizedInitialCountry || initialCity
? [initialCity, localizedInitialCountry].filter(Boolean).join(", ")
: "";
const [selectedCountry, setSelectedCountry] = useState(
() => localizedInitialCountry || "",
);
const [cityInput, setCityInput] = useState(
() => initialCity,
);
const [isOpen, setIsOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
@ -99,11 +117,9 @@ export function QuestionBirthplace({
const listRef = useRef<HTMLDivElement>(null);
const isMountedRef = useRef(true);
const isResidence = question.ui_config?.enable_geoip === true;
const [mode, setMode] = useState<"auto" | "manual">("auto");
const [isDetecting, setIsDetecting] = useState(false);
const [detectedLocation, setDetectedLocation] = useState("");
const [detectedLocation, setDetectedLocation] = useState(initialLoc);
useEffect(() => {
isMountedRef.current = true;
@ -152,89 +168,59 @@ export function QuestionBirthplace({
setAnswerValue(question, formatted);
};
// GeoIP detection logic
const detectLocation = (force = false) => {
const alreadyChecked =
typeof window !== "undefined"
? localStorage.getItem("hasCheckedGeoIPResidence")
: "true";
if (alreadyChecked && !force) {
if (rawValue) {
const parsed = parseValue(rawValue);
if (parsed.country && parsed.city) {
setDetectedLocation(`${parsed.city}, ${parsed.country}`);
setMode("auto");
} else {
setMode("manual");
}
} else {
setMode("auto");
}
return;
}
// If there is already a saved value and we are not forcing, do not fetch again
// GeoIP detection logic using unified getUserGeoRegion
const detectLocation = useCallback(async (force = false) => {
// If there is already a saved answer and we are not forcing, display it
if (rawValue && !force) {
const parsed = parseValue(rawValue);
if (parsed.country && parsed.city) {
setDetectedLocation(`${parsed.city}, ${parsed.country}`);
const cName = resolveCountryName(parsed.country, locale);
if (cName || parsed.city) {
const loc = [parsed.city, cName].filter(Boolean).join(", ");
setSelectedCountry(cName);
setCityInput(parsed.city);
setDetectedLocation(loc);
setMode("auto");
} else {
setMode("manual");
}
return;
}
}
setIsDetecting(true);
if (typeof window !== "undefined") {
localStorage.setItem("hasCheckedGeoIPResidence", "true");
}
fetch("https://ipapi.co/json/")
.then((res) => res.json())
.then((data) => {
if (data && data.city && data.country_name) {
const city = data.city;
const country = data.country_name;
setSelectedCountry(country);
setCityInput(city);
setDetectedLocation(`${city}, ${country}`);
updateAnswers(country, city);
setMode("auto");
} else {
throw new Error("Missing data");
}
})
.catch(() => {
fetch("https://ipwho.is/")
.then((res) => res.json())
.then((data) => {
if (data && data.city && data.country) {
const city = data.city;
const country = data.country;
try {
const region = await getUserGeoRegion(force);
if (!isMountedRef.current) return;
const city = region.city || "";
const rawCountry = region.country || region.countryCode || "";
const country = resolveCountryName(rawCountry, locale) || rawCountry;
if (city || country) {
const loc = [city, country].filter(Boolean).join(", ");
setSelectedCountry(country);
setCityInput(city);
setDetectedLocation(`${city}, ${country}`);
setDetectedLocation(loc);
updateAnswers(country, city);
setMode("auto");
} else {
setMode("manual");
}
})
.catch(() => {
} catch {
if (isMountedRef.current) {
setMode("manual");
});
})
.finally(() => {
}
} finally {
if (isMountedRef.current) {
setIsDetecting(false);
});
};
}
}
}, [rawValue, locale]);
useEffect(() => {
if (isLoading) return;
if (isResidence) {
detectLocation();
void detectLocation();
}
}, [isResidence, isLoading]);
}, [isResidence, isLoading, detectLocation]);
const handleAutoClick = () => {
setMode("auto");
@ -247,7 +233,8 @@ export function QuestionBirthplace({
}
setMode("manual");
const parsed = parseValue(rawValue);
const country = selectedCountry || parsed.country;
const resolvedC = resolveCountryName(selectedCountry || parsed.country, locale);
const country = resolvedC || selectedCountry || parsed.country;
const city = cityInput || parsed.city;
setSelectedCountry(country);
setCityInput(city);
@ -257,16 +244,17 @@ export function QuestionBirthplace({
// Synchronize state if rawValue changes externally
useEffect(() => {
const updated = parseValue(rawValue);
if (updated.country !== selectedCountry) {
setSelectedCountry(updated.country);
const resolvedC = resolveCountryName(updated.country, locale);
if (resolvedC && resolvedC !== selectedCountry) {
setSelectedCountry(resolvedC);
}
if (updated.city.trim() !== cityInput.trim()) {
setCityInput(updated.city);
}
if (updated.country && updated.city) {
setDetectedLocation(`${updated.city}, ${updated.country}`);
if (resolvedC || updated.city) {
setDetectedLocation([updated.city, resolvedC].filter(Boolean).join(", "));
}
}, [rawValue]);
}, [rawValue, locale]);
const options = getCountryList(locale);
const filteredOptions = options.filter((option) =>
@ -576,7 +564,7 @@ export function QuestionBirthplace({
>
<section
className={[
"flex max-h-[85vh] w-full flex-col overflow-hidden rounded-t-[24px] bg-white shadow-[0_-12px_40px_rgba(0,0,0,0.15)] transition-transform duration-[220ms] ease-out will-change-transform sm:max-w-[440px] animate-in slide-in-from-bottom",
"flex h-[82svh] min-h-[82svh] max-h-[82svh] w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[220ms] ease-out will-change-transform sm:max-w-[375px] animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0",
].join(" ")}
onClick={(e) => e.stopPropagation()}

35
src/components/Componentes/question-card.tsx

@ -1,4 +1,3 @@
import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef } from "react";
import { IoInformation } from "react-icons/io5";
@ -9,6 +8,7 @@ import {
} from "@/lib/schema-adapter";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import { SectionIcon } from "./section-icon";
import { UiIcon, type UiIconName } from "./ui-icon";
type QuestionCardProps = {
@ -42,21 +42,6 @@ const iconNameMap: Record<QuestionCardIcon, UiIconName> = {
family_marital: "marriage",
};
const sectionIconImageMap: Partial<Record<QuestionCardIcon, string>> = {
profile: "/assets/images/sections/01_personal_identity_details.png",
contact: "/assets/images/sections/02_contact_residence_family_communication.png",
health: "/assets/images/sections/03_physical_appearance_health.png",
education: "/assets/images/sections/04_education_career_economic_status.png",
family: "/assets/images/sections/05_family_background.png",
marriage: "/assets/images/sections/06_marital_status_marriage_history_children.png",
family_marital: "/assets/images/sections/06_marital_status_marriage_history_children.png",
lifestyle: "/assets/images/sections/07_beliefs_lifestyle_personal_boundaries.png",
criteria: "/assets/images/sections/08_future_spouse_criteria_red_lines.png",
verification: "/assets/images/sections/09_identity_verification_documents.png",
personality: "/assets/images/sections/10_personality_test.png",
glasser: "/assets/images/sections/10_personality_test.png",
};
export function QuestionCard({
item,
progress = item.progress,
@ -72,7 +57,6 @@ export function QuestionCard({
: 0;
const dashOffset = CIRCUMFERENCE - (normalizedProgress / 100) * CIRCUMFERENCE;
const resolvedIconKey = item.icon || resolveSectionIcon(item.slug);
const iconImageSrc = sectionIconImageMap[resolvedIconKey];
const iconName = iconNameMap[resolvedIconKey] ?? "details";
const cardRef = useRef<HTMLElement>(null);
@ -122,22 +106,11 @@ export function QuestionCard({
>
<div className="flex items-start gap-2.5">
<div className="relative flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-[12px] bg-linear-to-br from-[#E03950]/15 to-[#FE6F82]/15 text-white">
{iconImageSrc ? (
<Image
src={iconImageSrc}
alt=""
aria-hidden="true"
width={30}
height={30}
className="size-[30px] object-contain"
/>
) : (
<UiIcon
name={iconName}
<SectionIcon
name={resolvedIconKey}
aria-hidden="true"
className="size-[22px]"
className="size-[30px]"
/>
)}
</div>
<div className="min-w-0 flex-1">

2
src/components/Componentes/question-checkbox.tsx

@ -42,7 +42,7 @@ export function QuestionCheckbox({
const isShortOptions =
options.length <= 4 &&
options.every((opt) => (opt.label || opt.value || "").length <= 15);
options.every((opt) => String(opt.label || opt.value || "").length <= 15);
return (
<div

207
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",
@ -22,6 +30,8 @@ vi.mock("@/translations/provider", () => ({
"Select country": "Select country",
Close: "Close",
Confirm: "Confirm",
"Enter a valid phone number with country code.":
"Enter a valid phone number with country code.",
},
}),
}));
@ -62,8 +72,6 @@ const phoneQuestion2: QuestionField = {
options: [],
};
describe("QuestionPhone IP country detection and shimmer", () => {
beforeEach(() => {
answerMap = {};
@ -71,13 +79,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 +137,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 +148,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 +166,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 +180,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,41 +205,127 @@ 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(screen.getByDisplayValue("202-555-0143")).toBeDefined();
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} />);
render(<QuestionPhone question={phoneQuestion1} countryCode="+44" />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "123456" } });
fireEvent.change(input, { target: { value: "7400123456" } });
await act(async () => {
resolveIpFetch({ country_calling_code: "+98" });
resolveRegion({
data: {
country: "Iran",
country_code: "IR",
},
});
});
expect(screen.getByDisplayValue("7400 123456")).toBeDefined();
});
expect(screen.getByDisplayValue("123456")).toBeDefined();
it("formats Iranian phone numbers as 3-3-4 Telegram style as digits are typed", async () => {
httpMocks.get.mockResolvedValue({
data: { country: "Iran", country_code: "IR" },
});
render(<QuestionPhone question={phoneQuestion1} countryCode="+98" />);
const input = screen.getByRole("textbox") as HTMLInputElement;
// Type 3 digits
fireEvent.change(input, { target: { value: "912" } });
expect(input.value).toBe("912");
// Type 6 digits
fireEvent.change(input, { target: { value: "912345" } });
expect(input.value).toBe("912 345");
// Type 10 digits
fireEvent.change(input, { target: { value: "9123456789" } });
expect(input.value).toBe("912 345 6789");
});
it("shows dynamic placeholder based on resolved country code", async () => {
let resolveRegion!: (value: unknown) => void;
const regionPromise = new Promise((resolve) => {
resolveRegion = resolve;
});
httpMocks.get.mockReturnValue(regionPromise);
render(<QuestionPhone question={phoneQuestion1} countryCode="+44" />);
const input = screen.getByRole("textbox") as HTMLInputElement;
expect(input.placeholder).toBe("7400 123456");
await act(async () => {
resolveRegion({
data: {
country: "Iran",
country_code: "IR",
},
});
});
await waitFor(() => {
expect(input.placeholder).toBe("912 345 6789");
});
});
it("does not show validation error on active typing, only shows error on blur if incomplete", async () => {
httpMocks.get.mockResolvedValue({
data: { country: "Iran", country_code: "IR" },
});
const { container } = render(
<QuestionPhone question={phoneQuestion1} countryCode="+98" />,
);
const input = screen.getByRole("textbox") as HTMLInputElement;
// Focus and start typing incomplete number
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "912" } });
// No error while focused and typing
expect(
screen.queryByText("Enter a valid phone number with country code."),
).toBeNull();
expect(container.querySelector(".border-\\[\\#F2465F\\]")).toBeNull();
// Blur input without completing
fireEvent.blur(input);
// Error appears after blur
expect(
screen.getByText("Enter a valid phone number with country code."),
).toBeDefined();
expect(container.querySelector(".border-\\[\\#F2465F\\]")).not.toBeNull();
// Focus back and complete valid number
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "9123456789" } });
fireEvent.blur(input);
// Error is gone
expect(
screen.queryByText("Enter a valid phone number with country code."),
).toBeNull();
expect(container.querySelector(".border-\\[\\#F2465F\\]")).toBeNull();
});
});

330
src/components/Componentes/question-phone.tsx

@ -1,9 +1,14 @@
"use client";
import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber";
import {
AsYouTypeFormatter,
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";
@ -23,134 +28,35 @@ type PhoneValueParts = {
phoneValue: string;
};
const phoneUtil = PhoneNumberUtil.getInstance();
import {
getUserGeoRegion,
getStoredUserGeoRegion,
setStoredUserGeoRegion,
subscribeToUserGeoRegion,
resetUserGeoRegionForTesting,
} from "@/lib/geo-region";
// Module-level singleton state for IP phone country resolution
let cachedGeoCountryCode: string | null = null;
let geoIpPromise: Promise<string | null> | null = null;
const geoListeners = new Set<(code: string) => void>();
const phoneUtil = PhoneNumberUtil.getInstance();
export function resetGeoPhoneStateForTesting() {
cachedGeoCountryCode = null;
geoIpPromise = null;
geoListeners.clear();
resetUserGeoRegionForTesting();
}
function getStoredGeoCode(): string | null {
if (cachedGeoCountryCode) return cachedGeoCountryCode;
if (typeof window !== "undefined") {
try {
const stored = localStorage.getItem("geoIPPhoneCode");
if (stored) {
cachedGeoCountryCode = stored;
return stored;
}
} catch {}
}
return null;
const region = getStoredUserGeoRegion();
return region?.phoneCode || null;
}
export function setManuallySelectedGeoCode(code: string) {
cachedGeoCountryCode = code;
if (typeof window !== "undefined") {
try {
localStorage.setItem("geoIPPhoneCode", code);
localStorage.setItem("hasCheckedGeoIPPhone", "true");
} catch {}
}
const current = getStoredUserGeoRegion() || {};
setStoredUserGeoRegion({ ...current, phoneCode: code });
}
export function fetchGeoCountryCode(defaultCode = "+44"): Promise<string> {
const existing = getStoredGeoCode();
if (existing) {
return Promise.resolve(existing);
}
if (geoIpPromise) {
return geoIpPromise.then((res) => res || defaultCode);
}
geoIpPromise = (async () => {
try {
// 1. Primary: 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?.country_calling_code) {
const rawCode = String(data.country_calling_code).trim();
const formatted = rawCode.startsWith("+") ? rawCode : `+${rawCode}`;
setManuallySelectedGeoCode(formatted);
geoListeners.forEach((fn) => {
fn(formatted);
});
return formatted;
}
}
} catch {
clearTimeout(timeoutId);
}
// 2. Secondary 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?.calling_code) {
const rawCode = String(data.calling_code).trim();
const formatted = rawCode.startsWith("+") ? rawCode : `+${rawCode}`;
setManuallySelectedGeoCode(formatted);
geoListeners.forEach((fn) => {
fn(formatted);
});
return formatted;
}
}
} catch {
clearTimeout(secondaryTimeoutId);
}
// 3. Fallback to default
if (typeof window !== "undefined") {
try {
localStorage.setItem("geoIPPhoneCode", defaultCode);
localStorage.setItem("hasCheckedGeoIPPhone", "true");
} catch {}
}
cachedGeoCountryCode = defaultCode;
geoListeners.forEach((fn) => {
fn(defaultCode);
});
return defaultCode;
} catch {
if (typeof window !== "undefined") {
try {
localStorage.setItem("geoIPPhoneCode", defaultCode);
localStorage.setItem("hasCheckedGeoIPPhone", "true");
} catch {}
}
cachedGeoCountryCode = defaultCode;
geoListeners.forEach((fn) => {
fn(defaultCode);
});
return defaultCode;
}
})();
return geoIpPromise.then((res) => res || defaultCode);
export async function fetchGeoCountryCode(
defaultCode = "+44",
): Promise<string> {
const region = await getUserGeoRegion();
return region.phoneCode || defaultCode;
}
function isMarriagePhoneFieldValue(
@ -235,10 +141,11 @@ function readPhoneValue(value: unknown, fallbackCode: string): PhoneValueParts {
function getMaxLengthForCountry(codeValue: string): number {
try {
const cleanCode = codeValue.replace(/[^\d]/g, "");
if (!cleanCode) return 15;
if (!cleanCode) return 18;
if (cleanCode === "98") return 12; // 3 + 1 + 3 + 1 + 4 = 12 chars
const countryCode = Number(cleanCode);
const regionCode = phoneUtil.getRegionCodeForCountryCode(countryCode);
if (!regionCode || regionCode === "ZZ") return 15;
if (!regionCode || regionCode === "ZZ") return 18;
const exampleMobile = phoneUtil.getExampleNumberForType(
regionCode,
@ -254,9 +161,93 @@ function getMaxLengthForCountry(codeValue: string): number {
: 0;
const baseLen = Math.max(lenMobile, lenGeneral, 8);
return baseLen + 1;
return baseLen + 6;
} catch {
return 18;
}
}
function getCountryPlaceholder(
codeValue: string,
defaultPlaceholder?: string,
): string {
try {
const cleanCode = codeValue.replace(/[^\d]/g, "");
if (!cleanCode) {
return defaultPlaceholder?.replace(/^\+\d+\s*/, "") || "912 345 6789";
}
if (cleanCode === "98") {
return "912 345 6789";
}
const countryCode = Number(cleanCode);
const regionCode = phoneUtil.getRegionCodeForCountryCode(countryCode);
if (!regionCode || regionCode === "ZZ") {
return defaultPlaceholder?.replace(/^\+\d+\s*/, "") || "912 345 6789";
}
const example =
phoneUtil.getExampleNumberForType(
regionCode,
1, // MOBILE
) || phoneUtil.getExampleNumber(regionCode);
if (!example) {
return defaultPlaceholder?.replace(/^\+\d+\s*/, "") || "912 345 6789";
}
const intl = phoneUtil.format(example, PhoneNumberFormat.INTERNATIONAL);
const prefix = `+${cleanCode} `;
if (intl.startsWith(prefix)) {
return intl.slice(prefix.length);
}
return String(example.getNationalNumber());
} catch {
return 15;
return defaultPlaceholder?.replace(/^\+\d+\s*/, "") || "912 345 6789";
}
}
function formatPhoneNumberAsYouType(
rawInput: string,
codeValue: string,
): string {
const cleanCode = codeValue.replace(/[^\d]/g, "");
const rawDigits = rawInput.replace(/\D/g, "");
if (!rawDigits) return "";
// Special telegram-style handling for Iran (+98): 3 - 3 - 4 (e.g. 912 345 6789)
if (cleanCode === "98") {
const digits = rawDigits.startsWith("0") ? rawDigits.slice(1) : rawDigits;
const max10 = digits.slice(0, 10);
if (max10.length <= 3) return max10;
if (max10.length <= 6) return `${max10.slice(0, 3)} ${max10.slice(3)}`;
return `${max10.slice(0, 3)} ${max10.slice(3, 6)} ${max10.slice(6)}`;
}
const countryCode = Number(cleanCode);
const regionCode = phoneUtil.getRegionCodeForCountryCode(countryCode);
if (!regionCode || regionCode === "ZZ") {
return rawDigits;
}
try {
const formatter = new AsYouTypeFormatter(regionCode);
let formatted = "";
const fullNumber = `+${cleanCode}${rawDigits}`;
for (const char of fullNumber) {
formatted = formatter.inputDigit(char);
}
const codePrefix = `+${cleanCode} `;
if (formatted.startsWith(codePrefix)) {
return formatted.slice(codePrefix.length);
}
if (formatted.startsWith(`+${cleanCode}`)) {
return formatted.slice(`+${cleanCode}`.length).trim();
}
return rawDigits;
} catch {
return rawDigits;
}
}
@ -404,13 +395,23 @@ export function QuestionPhone({
}, [value, defaultCodeValue]);
const initialPhone = useMemo(() => {
return readPhoneValue(value, defaultCodeValue).phoneValue;
}, [value, defaultCodeValue]);
const rawPhone = readPhoneValue(value, defaultCodeValue).phoneValue;
return formatPhoneNumberAsYouType(rawPhone, initialCode);
}, [value, defaultCodeValue, initialCode]);
const [codeValue, setCodeValue] = useState(initialCode);
const [phoneValue, setPhoneValue] = useState(initialPhone);
const [isFocused, setIsFocused] = useState(false);
const [isTouched, setIsTouched] = useState(false);
const lastCommittedValueRef = useRef(value);
const dynamicPlaceholder = useMemo(() => {
return getCountryPlaceholder(
codeValue || defaultCodeValue,
question.extras?.placeHolder,
);
}, [codeValue, defaultCodeValue, question.extras?.placeHolder]);
const [isOpen, setIsOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@ -439,19 +440,30 @@ export function QuestionPhone({
let isMounted = true;
const onGeoCodeResolved = (resolvedCode: string) => {
const unsubscribe = subscribeToUserGeoRegion((region) => {
if (!isMounted || userInteractedRef.current) return;
setCodeValue(resolvedCode);
if (region.phoneCode) {
setCodeValue(region.phoneCode);
if (phoneValue) {
setPhoneValue((prev) =>
formatPhoneNumberAsYouType(prev, region.phoneCode!),
);
}
setIsResolvingCountry(false);
};
geoListeners.add(onGeoCodeResolved);
}
});
fetchGeoCountryCode(defaultCodeValue)
.then((resolvedCode) => {
if (!isMounted) return;
if (!userInteractedRef.current) {
setCodeValue(resolvedCode || defaultCodeValue);
const finalCode = resolvedCode || defaultCodeValue;
setCodeValue(finalCode);
if (phoneValue) {
setPhoneValue((prev) =>
formatPhoneNumberAsYouType(prev, finalCode),
);
}
}
setIsResolvingCountry(false);
})
@ -465,12 +477,14 @@ export function QuestionPhone({
return () => {
isMounted = false;
geoListeners.delete(onGeoCodeResolved);
unsubscribe();
};
}, [hasExplicitValue, isResolvingCountry, defaultCodeValue]);
}, [hasExplicitValue, isResolvingCountry, defaultCodeValue, phoneValue]);
const normalizedPhoneState = getNormalizedPhoneValue(codeValue, phoneValue);
const showInvalidState =
isTouched &&
!isFocused &&
codeValue.trim().length > 0 &&
phoneValue.trim().length > 0 &&
!normalizedPhoneState.isValid;
@ -602,13 +616,19 @@ export function QuestionPhone({
if (explicit) {
setIsResolvingCountry(false);
setCodeValue(nextValue.codeValue);
const maxLen = getMaxLengthForCountry(nextValue.codeValue);
setPhoneValue(nextValue.phoneValue.slice(0, maxLen));
const formatted = formatPhoneNumberAsYouType(
nextValue.phoneValue,
nextValue.codeValue,
);
setPhoneValue(formatted);
} else if (value !== null && value !== undefined) {
const resolvedCode = cachedCode || defaultCodeValue;
setCodeValue(resolvedCode);
const maxLen = getMaxLengthForCountry(resolvedCode);
setPhoneValue(nextValue.phoneValue.slice(0, maxLen));
const formatted = formatPhoneNumberAsYouType(
nextValue.phoneValue,
resolvedCode,
);
setPhoneValue(formatted);
}
lastCommittedValueRef.current = value;
@ -635,12 +655,11 @@ export function QuestionPhone({
userInteractedRef.current = true;
setIsResolvingCountry(false);
setManuallySelectedGeoCode(selectedCode);
const maxLen = getMaxLengthForCountry(selectedCode);
const truncatedPhone = phoneValue.slice(0, maxLen);
const reformatted = formatPhoneNumberAsYouType(phoneValue, selectedCode);
setCodeValue(selectedCode);
setPhoneValue(truncatedPhone);
updateStoredValue(selectedCode, truncatedPhone);
setPhoneValue(reformatted);
updateStoredValue(selectedCode, reformatted);
closeSheet();
};
@ -691,12 +710,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>
) : (
<>
@ -741,19 +758,27 @@ export function QuestionPhone({
data-bwignore="true"
data-form-type="other"
disabled={disabled}
placeholder={question.extras.placeHolder?.replace(/^\+\d+\s*/, "")}
placeholder={dynamicPlaceholder}
value={phoneValue}
maxLength={getMaxLengthForCountry(codeValue)}
onFocus={() => {
setIsFocused(true);
}}
onBlur={() => {
setIsFocused(false);
setIsTouched(true);
}}
onChange={(event) => {
userInteractedRef.current = true;
setIsResolvingCountry(false);
setManuallySelectedGeoCode(codeValue);
const nextPhoneValue = sanitizePhoneNumber(event.target.value);
const maxLen = getMaxLengthForCountry(codeValue);
const truncatedPhone = nextPhoneValue.slice(0, maxLen);
const formatted = formatPhoneNumberAsYouType(
event.target.value,
codeValue,
);
setPhoneValue(truncatedPhone);
updateStoredValue(codeValue, truncatedPhone);
setPhoneValue(formatted);
updateStoredValue(codeValue, formatted);
}}
dir="ltr"
className="h-full w-full border-0 bg-transparent p-0 text-left text-[15px] font-medium leading-none text-[#181818] tabular-nums outline-none placeholder:text-[#98A2B3]"
@ -762,7 +787,8 @@ export function QuestionPhone({
</div>
{showInvalidState ? (
<span className="block group-10 font-semibold text-[#F2465F]">
Enter a valid phone number with country code.
{t["Enter a valid phone number with country code."] ||
"Enter a valid phone number with country code."}
</span>
) : null}
@ -782,7 +808,7 @@ export function QuestionPhone({
>
<section
className={[
"flex h-[82svh] w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom",
"flex h-[82svh] min-h-[82svh] max-h-[82svh] w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0",
].join(" ")}
>

2
src/components/Componentes/question-radio.tsx

@ -25,7 +25,7 @@ export function QuestionRadio({
// Render horizontally if all options are short (e.g. Single, Divorced, Widowed)
const isShortOptions =
options.length <= 4 &&
options.every((opt) => (opt.label || opt.value || "").length <= 15);
options.every((opt) => String(opt.label || opt.value || "").length <= 15);
return (
<div

14
src/components/Componentes/question-sheet.test.tsx

@ -15,6 +15,13 @@ vi.mock("@/translations/provider", () => ({
useI18n: vi.fn(() => ({ locale: "fa", dictionary: { Confirm: "تایید" } })),
}));
vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: vi.fn(() => ({
data: { id: 1, can_edit_profile: true },
isLoading: false,
})),
}));
vi.mock("@/hooks/marriage/use-section-data", () => ({
applyProfilePatchResultToCache: vi.fn(),
useMarriageSectionDataQuery: vi.fn(() => ({
@ -91,7 +98,8 @@ describe("QuestionSheet component", () => {
fireEvent.click(iranBtn);
// Trigger should now show Iran
await waitFor(() => {
await waitFor(
() => {
expect(screen.getByText("ایران")).toBeDefined();
expect(document.body.classList.contains("dropdown-open")).toBe(false);
expect(document.body.classList.contains("question-sheet-open")).toBe(
@ -100,7 +108,9 @@ describe("QuestionSheet component", () => {
expect(
(document.querySelector(".app-shell") as HTMLElement).style.overflowY,
).toBe("");
});
},
{ timeout: 1000 },
);
});
it("supports multi-selection with confirm button", async () => {

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

@ -2,7 +2,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { getLanguageList, LANGUAGES_EN, LANGUAGES_FA } from "@/data/languages";
import { COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries";
import { LANGUAGE_EN_TO_FA, LANGUAGES_EN, LANGUAGES_FA } from "@/data/languages";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider";
import { Button } from "./button";
@ -81,12 +82,26 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
question.title?.includes("زبان") ||
question.ui_config?.dataset === "languages";
const isCountryQuestion =
question.id?.toLowerCase().includes("nationality") ||
question.id?.toLowerCase().includes("citizenship") ||
question.id?.toLowerCase().includes("country") ||
question.id?.toLowerCase().includes("birthplace") ||
question.id?.toLowerCase().includes("residence") ||
question.title?.toLowerCase().includes("nationality") ||
question.title?.toLowerCase().includes("citizenship") ||
question.title?.toLowerCase().includes("country") ||
question.title?.includes("ملیت") ||
question.title?.includes("تابعیت") ||
question.title?.includes("کشور") ||
question.title?.includes("سکونت") ||
question.ui_config?.dataset === "countries" ||
question.ui_config?.dataset === "nationalities";
const options = useMemo(() => {
const rawOptions = question.options || [];
if (!isLanguageQuestion) {
return rawOptions;
}
if (isLanguageQuestion) {
const existingByLabel = new Map(
rawOptions.map((opt) => [opt.label.toLowerCase().trim(), opt]),
);
@ -103,7 +118,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
LANGUAGES_EN.forEach((enLang, idx) => {
const localizedLabel =
(locale === "fa" || locale === "fa-ir"
? LANGUAGES_FA[idx]
? (LANGUAGE_EN_TO_FA[enLang] || LANGUAGES_FA[idx])
: (t as any)[enLang]) || enLang;
const cleanSlug = enLang
.toLowerCase()
@ -121,10 +136,15 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
if (seenIds.has(finalId)) return;
seenIds.add(finalId);
const isPersian = locale === "fa" || locale === "fa-ir";
const displayLabel = isPersian
? (LANGUAGE_EN_TO_FA[enLang] || localizedLabel)
: enLang;
mergedOptions.push({
id: finalId,
value: existing?.value ?? cleanSlug,
label: existing?.label ?? localizedLabel,
label: displayLabel,
order: existing?.order ?? idx,
});
});
@ -137,7 +157,68 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
});
return mergedOptions;
}, [question, isLanguageQuestion, locale, t]);
}
if (isCountryQuestion) {
const existingByLabel = new Map(
rawOptions.map((opt) => [opt.label.toLowerCase().trim(), opt]),
);
const existingByVal = new Map(
rawOptions.map((opt) => [String(opt.value).toLowerCase().trim(), opt]),
);
const existingById = new Map(
rawOptions.map((opt) => [opt.id.toLowerCase().trim(), opt]),
);
const mergedOptions: typeof rawOptions = [];
const seenIds = new Set<string>();
COUNTRIES_EN.forEach((enCountry, idx) => {
const localizedLabel =
(locale === "fa" || locale === "fa-ir"
? COUNTRIES_FA[idx]
: (t as any)[enCountry]) || enCountry;
const cleanSlug = enCountry
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
const generatedId = `${question.id}.${cleanSlug}`;
const existing =
existingByLabel.get(enCountry.toLowerCase()) ||
existingByLabel.get(localizedLabel.toLowerCase()) ||
existingByVal.get(cleanSlug) ||
existingById.get(generatedId.toLowerCase());
const finalId = existing?.id || generatedId;
if (seenIds.has(finalId)) return;
seenIds.add(finalId);
const isPersian = locale === "fa" || locale === "fa-ir";
const displayLabel = isPersian
? (COUNTRIES_FA[idx] || localizedLabel)
: enCountry;
mergedOptions.push({
id: finalId,
value: existing?.value ?? cleanSlug,
label: displayLabel,
order: existing?.order ?? idx,
});
});
rawOptions.forEach((opt) => {
if (!seenIds.has(opt.id)) {
seenIds.add(opt.id);
mergedOptions.push(opt);
}
});
return mergedOptions;
}
return rawOptions;
}, [question, isLanguageQuestion, isCountryQuestion, locale, t]);
const COMPACT_OPTIONS_MAX = 6;
@ -166,7 +247,14 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
);
const getCleanLabel = (optId: string) => {
const opt = options.find((o) => o.id === optId);
const normalized = String(optId || "").toLowerCase().trim();
const opt = options.find(
(o) =>
o.id === optId ||
o.id.toLowerCase() === normalized ||
String(o.value).toLowerCase() === normalized ||
o.id.endsWith(`.${normalized}`),
);
if (!opt) return optId;
return opt.label.split(" - ")[0];
};
@ -287,7 +375,9 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
ref={sheetRef}
className={[
"flex w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom",
isCompact ? "h-auto max-h-[82svh]" : "h-[82svh]",
isCompact && !showSearch
? "h-auto max-h-[82svh]"
: "h-[82svh] min-h-[82svh] max-h-[82svh]",
isClosing ? "translate-y-full" : "translate-y-0",
].join(" ")}
>

40
src/components/Componentes/question-snap-list.tsx

@ -14,20 +14,20 @@ import {
useQuestionViewportCoordinator,
} from "./question-viewport-coordinator";
const WHEEL_GESTURE_IDLE_MS = 320;
const BACKGROUND_DRAG_SLOP = 10;
const OPTION_CARD_DRAG_SLOP = 16;
const FAST_FLICK_MIN_DISTANCE = 36;
const DRAG_COMMIT_RATIO = 0.3;
const DRAG_FLICK_VELOCITY = 0.4;
const SNAP_ANIMATION_MS = 340;
const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const RUBBER_BAND_RESISTANCE = 0.4;
const WHEEL_GESTURE_IDLE_MS = 280;
const BACKGROUND_DRAG_SLOP = 8;
const OPTION_CARD_DRAG_SLOP = 14;
const TEXT_INPUT_DRAG_SLOP = 16;
const FAST_FLICK_MIN_DISTANCE = 24;
const DRAG_COMMIT_RATIO = 0.15;
const DRAG_FLICK_VELOCITY = 0.22;
const SNAP_ANIMATION_MS = 240;
const SNAP_EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
const RUBBER_BAND_RESISTANCE = 0.35;
/**
* Text inputs and direct embedded controls that MUST be completely isolated
* from the snap/drag gesture recognizer so first-tap focus, virtual keyboard,
* and caret manipulation work 100% reliably.
* Text inputs and direct embedded controls that MUST allow clean 1st-tap focus
* while still permitting intentional vertical swipe when dragged beyond slop.
*/
const TEXT_INPUT_SELECTOR = [
'input:not([type="radio"]):not([type="checkbox"])',
@ -58,6 +58,7 @@ const OPTION_CARD_SELECTOR = [
type SnapDragState = {
pointerDown: boolean;
hardIgnored: boolean;
isTextInput: boolean;
isOptionCard: boolean;
engaged: boolean;
didDrag: boolean;
@ -113,6 +114,7 @@ export function QuestionSnapList({
const dragRef = useRef<SnapDragState>({
pointerDown: false,
hardIgnored: false,
isTextInput: false,
isOptionCard: false,
engaged: false,
didDrag: false,
@ -397,14 +399,19 @@ export function QuestionSnapList({
const movedPastFlick = Math.abs(drag.offset) >= FAST_FLICK_MIN_DISTANCE;
let direction: 0 | 1 | -1 = 0;
let reason = "none";
if (!cancelled && (draggedFar || flicked || movedPastFlick)) {
if ((drag.offset > 0 || drag.velocity > 0) && canNext) {
direction = 1;
reason = flicked ? "flick_next" : (draggedFar ? "drag_far_next" : "moved_past_flick_next");
} else if ((drag.offset < 0 || drag.velocity < 0) && canPrev) {
direction = -1;
reason = flicked ? "flick_prev" : (draggedFar ? "drag_far_prev" : "moved_past_flick_prev");
}
}
console.log(`[Snap] FinishDrag -> dir: ${direction} (${reason}) | offset: ${drag.offset.toFixed(0)}px | vel: ${drag.velocity.toFixed(2)} | threshold: ${(drag.height * DRAG_COMMIT_RATIO).toFixed(0)}px`);
if (direction === 0) {
snapPanelsTo(0);
return;
@ -491,6 +498,8 @@ export function QuestionSnapList({
drag.startTime = performance.now();
drag.lastMoveTime = drag.startTime;
touchStartYRef.current = drag.startY;
console.log(`[Snap] TouchStart at (${drag.startX.toFixed(0)}, ${drag.startY.toFixed(0)}) on <${target?.tagName?.toLowerCase()}> (isOption: ${isOptionCard})`);
};
const onTouchMove = (event: TouchEvent) => {
@ -526,7 +535,7 @@ export function QuestionSnapList({
}
// Must be primarily vertical gesture (avoid hijacking horizontal swipe / scroll)
if (absY < absX * 1.1) {
if (absY < absX * 0.9) {
return;
}
@ -535,8 +544,13 @@ export function QuestionSnapList({
drag.didDrag = true;
suppressNextClickRef.current = true;
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
const elapsed = Math.max(1, now - drag.startTime);
drag.velocity = (drag.startY - currentY) / elapsed;
console.log(`[Snap] Drag Engaged: deltaY=${deltaY.toFixed(0)}px, slop=${slop}px`);
}
// Non-passive preventDefault stops native pan and guarantees gesture ownership

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

@ -3,7 +3,7 @@
import { useEffect } from "react";
import { viewPaddingsBridge } from "@/lib/view-paddings";
const KEYBOARD_THRESHOLD = 80;
const KEYBOARD_THRESHOLD = 20;
const QUESTION_GAP = 16;
const LIFT_VARIABLE = "--question-lift-y";
@ -82,19 +82,31 @@ export function isActiveQuestionKeyboardInput(
);
}
function getDefaultKeyboardHeight(): number {
if (typeof window !== "undefined" && window.innerHeight > 0) {
// 37.5% of screen height matches standard mobile soft keyboards (e.g. 287px on 765px screen, 316px on 844px screen)
return Math.round(window.innerHeight * 0.375);
}
return 285;
}
function setLift(nextLift: number) {
currentLift = Math.max(0, nextLift);
const prevLift = currentLift;
currentLift = Math.max(0, Math.round(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() {
if (!keyboardVisible || !activeQuestionInput) return null;
const flutterTop = window.innerHeight - keyboardHeight;
const fullKeyboardHeight =
lastKeyboardHeight > 200 ? lastKeyboardHeight : getDefaultKeyboardHeight();
const targetHeight = Math.max(keyboardHeight, fullKeyboardHeight);
const flutterTop = window.innerHeight - targetHeight;
const viewport = window.visualViewport;
const viewportBottom = viewport
? viewport.offsetTop + viewport.height
@ -103,11 +115,29 @@ function getKeyboardTop() {
return Math.min(flutterTop, viewportBottom);
}
function getBaseGeometry(content: HTMLElement) {
const item = content.closest<HTMLElement>(".question-snap-item");
if (item && typeof item.getBoundingClientRect === "function" && content.offsetHeight > 0) {
const itemRect = item.getBoundingClientRect();
const top = itemRect.top + content.offsetTop;
return {
baseTop: top,
baseBottom: top + content.offsetHeight,
};
}
const rect = content.getBoundingClientRect();
return {
baseTop: rect.top + currentLift,
baseBottom: rect.bottom + currentLift,
};
}
function updateLift() {
const content = document.querySelector<HTMLElement>(
'.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,20 +147,18 @@ function updateLift() {
(value): value is number => value !== null,
);
if (visibleBottomCandidates.length === 0) {
console.log('[Coord] updateLift: no visibleBottom candidates -> lift 0');
setLift(0);
return;
}
const rect = content.getBoundingClientRect();
const snapList = content.closest<HTMLElement>(".question-snap-list");
const baseTop = rect.top + currentLift;
const baseBottom = rect.bottom + currentLift;
const { baseTop, baseBottom } = getBaseGeometry(content);
const safeTop = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--safe-top"),
);
setLift(
computeQuestionLift({
const computed = computeQuestionLift({
baseTop,
baseBottom,
visibleTop: Math.max(
@ -138,8 +166,9 @@ function updateLift() {
snapList?.getBoundingClientRect().top ?? 0,
),
visibleBottom: Math.min(...visibleBottomCandidates),
}),
);
});
console.log(`[Coord] computed lift: ${computed}px (baseTop: ${baseTop.toFixed(0)}, baseBottom: ${baseBottom.toFixed(0)}, kTop: ${keyboardTop?.toFixed(0)})`);
setLift(computed);
}
function scheduleLiftUpdate() {
@ -151,13 +180,34 @@ function scheduleLiftUpdate() {
}
export function updateQuestionKeyboardHeight(height: number) {
keyboardHeight = Math.max(0, height);
keyboardVisible = keyboardHeight > KEYBOARD_THRESHOLD;
if (keyboardVisible) {
lastKeyboardHeight = keyboardHeight;
const nextHeight = Math.max(0, height);
if (Math.abs(nextHeight - keyboardHeight) < 1) return;
console.log(`[Coord] updateHeight: ${keyboardHeight} -> ${nextHeight}`);
if (nextHeight > 0) {
keyboardVisible = true;
const defaultKHeight = getDefaultKeyboardHeight();
const expectedHeight =
lastKeyboardHeight > 200 ? lastKeyboardHeight : defaultKHeight;
if (activeQuestionInput && nextHeight < expectedHeight - 15) {
// Keyboard is in the middle of opening animation frames (e.g. Samsung 8.7px, 182px, 205px, 246px, 262px, 273px)
// Keep keyboardHeight at expectedHeight so lift doesn't stutter/jitter during intermediate animation frames.
keyboardHeight = expectedHeight;
} else {
// Settled at or above target height
lastKeyboardHeight = Math.max(lastKeyboardHeight, nextHeight);
keyboardHeight = nextHeight;
}
if (isActiveQuestionKeyboardInput(document.activeElement)) {
activeQuestionInput = document.activeElement;
}
} else {
// nextHeight === 0: keyboard was closed
keyboardHeight = 0;
keyboardVisible = false;
}
scheduleLiftUpdate();
}
@ -176,13 +226,16 @@ export function resetQuestionKeyboardState() {
window.cancelAnimationFrame(geometryFrame);
geometryFrame = null;
}
if (isActiveQuestionKeyboardInput(document.activeElement)) {
if (
typeof document !== "undefined" &&
document.activeElement instanceof HTMLElement &&
isActiveQuestionKeyboardInput(document.activeElement)
) {
document.activeElement.blur();
}
activeQuestionInput = null;
keyboardVisible = false;
keyboardHeight = 0;
compactSheetTop = null;
activeQuestionInput = null;
setLift(0);
}
@ -193,25 +246,29 @@ export function useQuestionViewportCoordinator() {
const handleFocusIn = (event: FocusEvent) => {
if (!isActiveQuestionKeyboardInput(event.target)) return;
activeQuestionInput = event.target;
// Start with the last known keyboard geometry so lifting begins in the
// same frame as focus; Flutter then replaces it with the exact height.
if (!keyboardVisible && lastKeyboardHeight > KEYBOARD_THRESHOLD) {
keyboardHeight = lastKeyboardHeight;
const expectedHeight =
lastKeyboardHeight > 200 ? lastKeyboardHeight : getDefaultKeyboardHeight();
keyboardHeight = expectedHeight;
keyboardVisible = true;
}
console.log('[Coord] focusin on input:', (event.target as HTMLElement).tagName, 'targetKHeight:', expectedHeight);
scheduleLiftUpdate();
};
const handleFocusOut = () => {
console.log('[Coord] focusout event');
window.setTimeout(() => {
activeQuestionInput = isActiveQuestionKeyboardInput(
document.activeElement,
)
? document.activeElement
: null;
if (!activeQuestionInput) scheduleLiftUpdate();
}, 0);
console.log('[Coord] focusout settled, active:', activeQuestionInput?.tagName ?? 'none');
if (!activeQuestionInput) {
keyboardVisible = false;
keyboardHeight = 0;
scheduleLiftUpdate();
}
}, 50);
};
const unsubscribeConfig = viewPaddingsBridge.subscribeConfig((config) => {
@ -222,21 +279,21 @@ 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)) {
activeQuestionInput = document.activeElement;
keyboardVisible = true;
keyboardHeight = Math.max(keyboardHeight, reduction);
lastKeyboardHeight = keyboardHeight;
scheduleLiftUpdate();
if (reduction > 250) {
lastKeyboardHeight = Math.max(lastKeyboardHeight, reduction);
keyboardHeight = reduction;
}
} else if (keyboardHeight <= KEYBOARD_THRESHOLD) {
keyboardVisible = false;
keyboardHeight = 0;
}
} else if (reduction <= KEYBOARD_THRESHOLD && !keyboardVisible && !activeQuestionInput) {
closedViewportHeight = viewportHeight;
scheduleLiftUpdate();
}
scheduleLiftUpdate();
};
document.addEventListener("focusin", handleFocusIn);

63
src/components/Componentes/section-icon.tsx
File diff suppressed because it is too large
View File

1
src/components/Componentes/slider-page.test.tsx

@ -32,6 +32,7 @@ vi.mock('@/hooks/marriage/query-keys', () => ({
vi.mock('next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
prefetch: vi.fn(),
back: vi.fn(),
}),
}));

66
src/components/Componentes/slider-page.tsx

@ -1,7 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import type { TouchEvent } from "react";
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useUpdateMarriageProfileBasicMutation } from "@/hooks/marriage/use-profile-basic";
@ -21,10 +20,13 @@ import { SliderSlideThree } from "./slider-slide-three";
import { SliderSlideTwo } from "./slider-slide-two";
const FINAL_SLIDE_COUNT = 5;
const SWIPE_THRESHOLD = 40;
const SLIDER_ANSWERS_STORAGE_KEY = "marriage-slider-answers";
export default function SliderPage() {
export type SliderPageProps = {
onClose?: () => void;
};
export default function SliderPage({ onClose }: SliderPageProps = {}) {
const router = useRouter();
const { locale } = useI18n();
const [activeSlide, setActiveSlide] = useState(0);
@ -32,7 +34,6 @@ export default function SliderPage() {
const [selectedRegistration, setSelectedRegistration] =
useState<RegistrationAnswer>("self");
const [hasReadRules, setHasReadRules] = useState(false);
const [touchStartX, setTouchStartX] = useState<number | null>(null);
const updateProfileBasicMutation = useUpdateMarriageProfileBasicMutation();
const queryClient = useQueryClient();
const [submitError, setSubmitError] = useState<string | null>(null);
@ -109,42 +110,17 @@ export default function SliderPage() {
window.localStorage.removeItem(SLIDER_ANSWERS_STORAGE_KEY);
}
router.replace(localizePath(getSubmitPath(freshProfile), locale));
const targetPath = getSubmitPath(freshProfile);
const localizedTarget = localizePath(targetPath, locale);
router.prefetch?.(localizedTarget);
router.replace(localizedTarget);
} catch (error) {
console.error("Failed to complete onboarding:", error);
setSubmitError(t["Failed to update profile basic details. Please try again."] || "Failed to update profile basic details. Please try again.");
} finally {
setIsSubmitting(false);
}
};
const handleTouchStart = (event: TouchEvent<HTMLDivElement>) => {
setTouchStartX(event.touches[0]?.clientX ?? null);
};
const handleTouchEnd = (event: TouchEvent<HTMLDivElement>) => {
if (touchStartX === null) {
return;
}
const touchEndX = event.changedTouches[0]?.clientX ?? touchStartX;
const deltaX = touchStartX - touchEndX;
if (Math.abs(deltaX) >= SWIPE_THRESHOLD) {
if (deltaX > 0) {
if (activeSlide === 0 && !hasReadRules) {
setTouchStartX(null);
return;
}
goToNextSlide();
} else {
goToPreviousSlide();
}
}
setTouchStartX(null);
};
return (
<div className="relative flex h-[100dvh] max-h-[100dvh] flex-col overflow-hidden">
<header className="relative -mx-[17px] rounded-b-[32px] px-[17px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-3">
@ -153,6 +129,10 @@ export default function SliderPage() {
icon="back"
iconLabel="Close slider"
onClick={() => {
if (activeSlide > 0) {
goToPreviousSlide();
return;
}
if (typeof window !== "undefined") {
try {
window.sessionStorage.setItem("skip_intro_redirect", "true");
@ -160,7 +140,11 @@ export default function SliderPage() {
console.warn("sessionStorage is not accessible:", e);
}
}
if (onClose) {
onClose();
} else {
router.back();
}
}}
/>
<nav
@ -191,11 +175,7 @@ export default function SliderPage() {
</div>
</header>
<main
className="flex min-h-0 flex-1 touch-pan-y overflow-hidden pt-6"
onTouchEnd={handleTouchEnd}
onTouchStart={handleTouchStart}
>
<main className="flex min-h-0 flex-1 overflow-hidden pt-6">
<div
className="flex h-full w-full transition-transform duration-300 ease-out"
style={{ transform: `translateX(-${activeSlide * 100}%)` }}
@ -262,13 +242,14 @@ type SliderStepActionsProps = {
};
function SliderStepActions({ onBack, onNext }: SliderStepActionsProps) {
const { dictionary: t } = useI18n();
return (
<div className="flex items-center gap-3 [&>button]:flex-1">
<Button variant="outlined" arrowDirection="left" onClick={onBack}>
Back
{t["Back"] || "Back"}
</Button>
<Button arrowDirection="right" onClick={onNext}>
Next
{t["Next"] || "Next"}
</Button>
</div>
);
@ -285,6 +266,7 @@ function SliderFinalActions({
onBack,
onFinish,
}: SliderFinalActionsProps) {
const { dictionary: t } = useI18n();
return (
<div className="flex items-center gap-3 [&>button]:flex-1">
<Button
@ -293,10 +275,10 @@ function SliderFinalActions({
disabled={isFinishing}
onClick={onBack}
>
Back
{t["Back"] || "Back"}
</Button>
<Button disabled={isFinishing} isLoading={isFinishing} onClick={onFinish}>
Finish
{t["Finish"] || "Finish"}
</Button>
</div>
);

32
src/components/Componentes/slider-slide-two.tsx

@ -3,6 +3,7 @@
import Image from "next/image";
import { useState } from "react";
import { useMarriageConfigQuery } from "@/hooks/marriage/use-marriage-config";
import { useI18n } from "@/translations/provider";
import { SliderHeader } from "./slider-header";
import type { SliderSlideProps } from "./slider-slide";
import VideoPlayer from "./video-player";
@ -11,6 +12,7 @@ import NetworkImage from "./network-image";
export function SliderSlideTwo({ index }: SliderSlideProps) {
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
const { data: config } = useMarriageConfigQuery();
const { dictionary: t } = useI18n();
const thumbnailSrc =
config?.video_step_2_thumbnail_url ||
@ -24,13 +26,16 @@ export function SliderSlideTwo({ index }: SliderSlideProps) {
aria-label={`Slide ${index + 1}`}
className="flex h-full min-h-0 w-full shrink-0 flex-col"
>
<div className="min-h-0 flex-1 overflow-y-auto pb-[86px]">
<SliderHeader index={index} title="Watch Video" />
<div className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain pb-[110px] sm:pb-[130px] flex flex-col items-center">
<div className="w-full">
<SliderHeader index={index} title={t["Watch Video"] || "Watch Video"} />
</div>
<div
className="mt-6 flex min-h-0 justify-center cursor-pointer group"
className="mt-3 sm:mt-5 flex min-h-0 justify-center cursor-pointer group w-full"
onClick={() => setIsPlayerOpen(true)}
>
<div className="relative w-full max-w-[344px] aspect-[344/488] overflow-hidden rounded-[24px] shadow-xs">
<div className="relative w-full max-w-[344px] aspect-[344/430] max-h-[46dvh] overflow-hidden rounded-[24px] shadow-xs">
<NetworkImage
src={thumbnailSrc}
fallbackSrc="/assets/images/Frame 20953586523.png"
@ -43,28 +48,31 @@ export function SliderSlideTwo({ index }: SliderSlideProps) {
<Image
src={"/assets/images/Frame 1116607280.svg"}
alt="play"
width={68}
height={68}
width={64}
height={64}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transition-transform duration-300 group-hover:scale-110 active:scale-95 z-10"
/>
</div>
</div>
<div className="mt-4">
<div className="mt-2.5 sm:mt-3.5 shrink-0">
<div className="flex items-center justify-center gap-1.5">
<Image
src={"/assets/images/Avatar Image.png"}
alt="Dr. Hasti Masoudi"
alt={t["Dr. Hasti Masoudi"] || "Dr. Hasti Masoudi"}
width={20}
height={20}
className="rounded-full"
/>
<p className="text-[#2F3A48] group-14 font-semibold">
Dr. Hasti Masoudi
{t["Dr. Hasti Masoudi"] || "Dr. Hasti Masoudi"}
</p>
</div>
<p className="group-12 text-[#4D4D4D] text-center mt-2 max-w-[340px] mx-auto leading-relaxed">
We have come together with the goal of creating a secure and
confidential path for "permanent marriage" among Muslims
<p className="group-12 text-[#4D4D4D] text-center mt-1.5 max-w-[340px] mx-auto leading-relaxed px-2">
{t[
'We have come together with the goal of creating a secure and confidential path for "permanent marriage" among Muslims'
] ||
'We have come together with the goal of creating a secure and confidential path for "permanent marriage" among Muslims'}
</p>
</div>
</div>

43
src/components/Componentes/ui-config.test.tsx

@ -78,6 +78,49 @@ describe("UI Config based behavior", () => {
expect(screen.queryByText("خودکار")).toBeNull();
});
it("should auto-detect and display city and country when ui_config.enable_geoip is true", async () => {
const qWithGeo = {
id: "q_residence",
title: "Current Residence",
type: "birthplace",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: {},
options: [],
ui_config: { enable_geoip: true },
} as any;
const { setStoredUserGeoRegion } = await import("@/lib/geo-region");
const { marriageQueryKeys } = await import("@/hooks/marriage/query-keys");
setStoredUserGeoRegion({
city: "Tehran",
country: "Iran",
countryCode: "IR",
phoneCode: "+98",
});
queryClient.setQueryData(marriageQueryKeys.sectionData("test-geoip"), {
status: "success",
data: [],
version: 1,
});
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test-geoip" questions={[qWithGeo]}>
<QuestionBirthplace question={qWithGeo} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
await waitFor(() => {
expect(screen.getByText(/Tehran.*(Iran|ایران)/i)).toBeDefined();
});
});
it("should trigger currency behavior only when ui_config.currency_enabled is true", () => {
// Title is random, but currency_enabled is true
const qWithCurrency = {

49
src/data/countries.ts

@ -399,3 +399,52 @@ export function getCountryList(locale: string): string[] {
}
return COUNTRIES_EN;
}
export function resolveCountryName(
countryOrCode: string | undefined | null,
locale: string = "fa",
): string {
if (!countryOrCode) return "";
const trimmed = countryOrCode.trim();
if (!trimmed) return "";
const isFa = String(locale || "en").toLowerCase().startsWith("fa");
// 1. If 2-letter ISO code (e.g. "DE", "IR", "US", "TJ")
if (trimmed.length === 2 && /^[a-zA-Z]{2}$/.test(trimmed)) {
try {
const displayNames = new Intl.DisplayNames([isFa ? "fa" : "en"], {
type: "region",
});
const name = displayNames.of(trimmed.toUpperCase());
if (name) return name;
} catch {}
}
// 2. If it matches an English country name in COUNTRIES_EN
const enIndex = COUNTRIES_EN.findIndex(
(c) => c.toLowerCase() === trimmed.toLowerCase(),
);
if (enIndex !== -1) {
return isFa ? COUNTRIES_FA[enIndex] || COUNTRIES_EN[enIndex] : COUNTRIES_EN[enIndex];
}
// 3. If it matches a Persian country name in COUNTRIES_FA
const faIndex = COUNTRIES_FA.findIndex((c) => c === trimmed);
if (faIndex !== -1) {
return isFa ? COUNTRIES_FA[faIndex] : COUNTRIES_EN[faIndex] || COUNTRIES_FA[faIndex];
}
// 4. Try Intl.DisplayNames fallback if 2-3 chars
if (trimmed.length <= 3) {
try {
const displayNames = new Intl.DisplayNames([isFa ? "fa" : "en"], {
type: "region",
});
const name = displayNames.of(trimmed.toUpperCase());
if (name) return name;
} catch {}
}
return trimmed;
}

79
src/data/languages.ts

@ -153,6 +153,85 @@ export const LANGUAGES_FA = [
"سایر",
];
export const LANGUAGE_EN_TO_FA: Record<string, string> = {
Afrikaans: "آفریکانس",
Albanian: "آلبانیایی",
Amharic: "امهری",
Arabic: "عربی",
Armenian: "ارمنی",
Azerbaijani: "آذربایجانی",
Balochi: "بلوچی",
Bengali: "بنگالی",
Bosnian: "بوسنیایی",
Bulgarian: "بلغاری",
Burmese: "میانماری (برمه‌ای)",
Catalan: "کاتالان",
Chinese: "چینی",
Croatian: "کرواتی",
Czech: "چکی",
Danish: "دانمارکی",
Dutch: "هلندی",
English: "انگلیسی",
Estonian: "استونیایی",
Finnish: "فنلاندی",
French: "فرانسوی",
Georgian: "گرجی",
German: "آلمانی",
Greek: "یونانی",
Gujarati: "گجراتی",
Hausa: "هوسا",
Hebrew: "عبری",
Hindi: "هندی",
Hungarian: "مجاری",
Icelandic: "ایسلندی",
Indonesian: "اندونزیایی",
Irish: "ایرلندی",
Italian: "ایتالیایی",
Japanese: "ژاپنی",
Kashmiri: "کشمیری",
Kazakh: "قزاقی",
Khmer: "خمری",
Korean: "کره‌ای",
Kurdish: "کردی",
Kyrgyz: "قرقیزی",
Latvian: "لتونیایی",
Lithuanian: "لیتوانیایی",
Macedonian: "مقدونیه‌ای",
Malay: "مالایی",
Maltese: "مالتی",
Mongolian: "مغولی",
Nepali: "نپالی",
Norwegian: "نروژی",
Pashto: "پشتو",
Persian: "فارسی",
"Persian (Farsi)": "فارسی",
Polish: "لهستانی",
Portuguese: "پرتغالی",
Punjabi: "پنجابی",
Romanian: "رومانیایی",
Russian: "روسی",
Serbian: "صربی",
Sinhala: "سینهالی",
Slovak: "اسلواکی",
Slovenian: "اسلوونیایی",
Somali: "سومالیایی",
Spanish: "اسپانیایی",
Swahili: "سواحیلی",
Swedish: "سوئدی",
Tajik: "تاجیکی",
Tamil: "تامیلی",
Telugu: "تلوگو",
Thai: "تایلندی",
Turkish: "ترکی",
Turkmen: "ترکمنی",
Ukrainian: "اوکراینی",
Urdu: "اردو",
"Urdu Roman (Latin)": "اردو (لاتین)",
Uzbek: "ازبکی",
Vietnamese: "ویتنامی",
Other: "سایر",
};
export function getLanguageList(locale: string): string[] {
const normalized = String(locale || "en").toLowerCase();
if (normalized === "fa" || normalized === "fa-ir") {

4
src/hooks/marriage/use-profile-main.test.ts

@ -32,7 +32,7 @@ describe("getMarriageProfile", () => {
expect(mocks.setCachedEntryPath).toHaveBeenCalledWith("/questions-list");
});
it("clears the route for an incomplete onboarding profile", async () => {
it("caches the intro route for an incomplete onboarding profile", async () => {
mocks.get.mockResolvedValue({
data: {
status: "pending_onboarding",
@ -43,7 +43,7 @@ describe("getMarriageProfile", () => {
await getMarriageProfile();
expect(mocks.setCachedEntryPath).toHaveBeenCalledWith(null);
expect(mocks.setCachedEntryPath).toHaveBeenCalledWith("/intro");
});
it("overwrites the old questions route when the backend advances state", async () => {

2
src/hooks/marriage/use-profile-main.ts

@ -31,7 +31,7 @@ export async function getMarriageProfile() {
});
setCachedMarriageEntryPath(
hasCompletedMarriageProfileBasics(data) ? getSubmitPath(data) : null,
hasCompletedMarriageProfileBasics(data) ? getSubmitPath(data) : "/intro",
);
return data;

3
src/lib/entry-route-cache.test.ts

@ -21,7 +21,8 @@ describe("entry route cache", () => {
);
expect(normalizeCachedMarriageEntryPath("https://example.com")).toBeNull();
expect(normalizeCachedMarriageEntryPath("//example.com")).toBeNull();
expect(normalizeCachedMarriageEntryPath("/intro")).toBeNull();
expect(normalizeCachedMarriageEntryPath("/intro")).toBe("/intro");
expect(normalizeCachedMarriageEntryPath("/invalid-path")).toBeNull();
});
it("requires a real token before trusting the cached route", () => {

1
src/lib/entry-route-cache.ts

@ -7,6 +7,7 @@ import {
export const MARRIAGE_ENTRY_PATH_COOKIE = "HABIB_MARRIAGE_ENTRY_PATH";
const AUTHENTICATED_ENTRY_PATHS = new Set([
"/intro",
"/terms",
"/questions-list",
"/finding-match",

236
src/lib/geo-region.ts

@ -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;
}

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

@ -36,7 +36,7 @@ export function isMarriagePhoneFieldValue(
}
export function formatFieldValue(value: MarriageFieldValue): string | null {
if (value === null || value === "") {
if (value === null || value === undefined || value === "") {
return null;
}
@ -48,6 +48,16 @@ export function formatFieldValue(value: MarriageFieldValue): string | null {
return value ? "Yes" : "No";
}
if (typeof value === "object") {
if ("country" in value || "city" in value || "state" in value) {
const v = value as { country?: string; state?: string; city?: string };
const parts = [v.country, v.state, v.city]
.map((p) => (typeof p === "string" ? p.trim() : ""))
.filter(Boolean);
return parts.join(", ");
}
}
return String(value);
}

11
src/translations/locales/ar.json

@ -141,7 +141,7 @@
"Current Housing Status": "حالة السكن الحالية",
"Current Marital Status": "الحالة الاجتماعية الحالية",
"Current Nationality / Citizenship": "الجنسية الحالية / المواطنة",
"Current Residence": "الإقامة الحالية",
"Current Residence": "مكان الإقامة الحالي",
"Current user": "المستخدم الحالي",
"Currently building suitable financial conditions": "حاليا بناء الظروف المالية المناسبة",
"Curvy/Full": "متعرج / كامل",
@ -260,7 +260,7 @@
"Height in Centimeters": "Height in Centimeters",
"Help": "يساعد",
"High School Diploma": "دبلوم المدرسة الثانوية",
"Highest Level of Education": "أعلى مستوى من التعليم",
"Highest Level of Education": "أعلى مستوى تعليمي",
"Homemaker": "ربة منزل",
"Homeowner": "صاحب المنزل",
"Hookah": "الشيشة",
@ -308,7 +308,7 @@
"Iraq": "Iraq",
"Items Per Page": "العناصر لكل صفحة",
"Job Seeking / Unemployed": "Job Seeking / Unemployed",
"Job Title": "مسمى وظيفي",
"Job Title": "المسمى الوظيفي",
"Joint custody or periodic visitation/relocation between parents.": "الحضانة المشتركة أو الزيارة الدورية/الانتقال بين الوالدين.",
"Kurdish": "كردي",
"Language Learning": "تعلم اللغة",
@ -725,7 +725,7 @@
"You have active access to view candidates.": "لديك حق الوصول النشط لعرض المرشحين.",
"You will be contacted by your consultant.": "سيتم التواصل معك من قبل المستشار الخاص بك.",
"You've completed all required fields. However, filling in all sections will help us find better matches for you": "لقد أكملت كافة الحقول المطلوبة. ومع ذلك، فإن ملء جميع الأقسام سيساعدنا في العثور على تطابقات أفضل لك",
"Your Hobbies and Main Interests": "هواياتك واهتماماتك الرئيسية",
"Your Hobbies and Main Interests": "الهوايات والاهتمامات الرئيسية",
"Your Personality Traits": "سمات شخصيتك",
"Your details are only used for the matching process.": "يتم استخدام التفاصيل الخاصة بك فقط لعملية المطابقة.",
"Your information is kept strictly confidential.": "يتم الاحتفاظ بمعلوماتك بسرية تامة.",
@ -807,5 +807,6 @@
"Enter medication name and reason for use...": "أدخل اسم الدواء وسبب الاستخدام...",
"نام دارو و دلیل مصرف را وارد نمایید...": "أدخل اسم الدواء وسبب الاستخدام...",
"وضعیت سلامت جسمانی": "حالة الصحة الجسدية",
"توضیحات وضعیت جسمانی": "وصف الصحة الجسدية"
"توضیحات وضعیت جسمانی": "وصف الصحة الجسدية",
"Name": "الاسم"
}

19
src/translations/locales/az.json

@ -141,7 +141,7 @@
"Current Housing Status": "Cari Mənzil Vəziyyəti",
"Current Marital Status": "Cari Ailə Vəziyyəti",
"Current Nationality / Citizenship": "Hazırkı Vətəndaşlıq / Vətəndaşlıq",
"Current Residence": "Hazırkı Yaşayış yeri",
"Current Residence": "Hazırkı yaşayış yeri",
"Current user": "Cari istifadəçi",
"Currently building suitable financial conditions": "Hal-hazırda uyğun maliyyə şəraiti yaratmaq",
"Curvy/Full": "Qıvrımlı/Tam",
@ -199,7 +199,7 @@
"Education, Career, and Economic Status": "Təhsil, Karyera və İqtisadi Vəziyyət",
"Emotional": "Emosional",
"Employed": "İşlədi",
"Employment Status": "Məşğulluq Vəziyyəti",
"Employment Status": "Məşğulluq vəziyyəti",
"English": "İngilis dili",
"English, French, etc.": "İngilis, Fransız və s.",
"Enter details here...": "Təfərrüatları bura daxil edin...",
@ -219,7 +219,7 @@
"Father alive": "Ata sağdır",
"Father has passed away": "Ata rəhmətə gedib",
"Feel free to briefly explain your decision...": "Qərarınızı qısaca izah etməkdən çekinmeyin...",
"Field of Study": "Tədqiqat sahəsi",
"Field of Study": "Təhsil sahəsi",
"Final Consent": "Yekun razılıq",
"Final Match Introduction": "Final matçının təqdimatı",
"Final Notice": "Yekun bildiriş",
@ -235,7 +235,7 @@
"Full Hijab with modest clothing - Modest styling with hair completely covered.": "Təvazökar geyimli tam hicab - Tamamilə örtülmüş saçlarla təvazökar üslub.",
"Full Islamic covering (Maximum Hijab) - Abaya, Jilbab, Chador, or Niqab with full observance.": "Tam İslam örtüyü (Maksimum Hicab) - Abaya, Cilbab, Çador və ya Niqab tam riayət etməklə.",
"Full Islamic covering mandatory": "Tam İslami örtük məcburidir",
"Full Name": "Tam adı",
"Full Name": "Tam ad",
"Full makeup": "Tam makiyaj",
"Full-time Employed": "Tam iş günü",
"Fully able to support expenses": "Xərcləri tam dəstəkləyə bilir",
@ -260,7 +260,7 @@
"Height in Centimeters": "Hündürlüyü Santimetrlə",
"Help": "Kömək edin",
"High School Diploma": "Orta məktəb diplomu",
"Highest Level of Education": "Ən Yüksək Təhsil Səviyyəsi",
"Highest Level of Education": "Ən yüksək təhsil səviyyəsi",
"Homemaker": "Evdar",
"Homeowner": "Ev sahibi",
"Hookah": "Qəlyan",
@ -308,7 +308,7 @@
"Iraq": "İraq",
"Items Per Page": "Səhifə Başına Elementlər",
"Job Seeking / Unemployed": "İş Axtarıram/İşsiz",
"Job Title": "Vəzifə adı",
"Job Title": "Vəzifə / Peşə adı",
"Joint custody or periodic visitation/relocation between parents.": "Valideynlər arasında birgə qəyyumluq və ya dövri ziyarət/köçürmə.",
"Kurdish": "kürd",
"Language Learning": "Dil Öyrənilməsi",
@ -684,7 +684,7 @@
"View More Details": "Ətraflı Məlumata Baxın",
"View Profile": "Profilə baxın",
"View contact number": "Əlaqə nömrəsinə baxın",
"View more details": "Ətraflı təfərrüata baxın",
"View more details": "Daha çox təfərrüata baxın",
"View profile": "Profilə baxın",
"Watch Video": "Videoya baxın",
"We did not reach an agreement": "Razılığa gəlmədik",
@ -725,7 +725,7 @@
"You have active access to view candidates.": "Namizədlərə baxmaq üçün aktiv girişiniz var.",
"You will be contacted by your consultant.": "Məsləhətçiniz sizinlə əlaqə saxlayacaq.",
"You've completed all required fields. However, filling in all sections will help us find better matches for you": "Bütün tələb olunan sahələri doldurmusunuz. Bununla belə, bütün bölmələri doldurmaq bizə sizin üçün daha yaxşı uyğunluqlar tapmağa kömək edəcək",
"Your Hobbies and Main Interests": "Hobbiləriniz və Əsas Maraqlarınız",
"Your Hobbies and Main Interests": "Hobbiləriniz və əsas maraqlarınız",
"Your Personality Traits": "Şəxsiyyət xüsusiyyətləriniz",
"Your details are only used for the matching process.": "Detallarınız yalnız uyğunlaşma prosesi üçün istifadə olunur.",
"Your information is kept strictly confidential.": "Məlumatlarınız ciddi şəkildə məxfi saxlanılır.",
@ -807,5 +807,6 @@
"Enter medication name and reason for use...": "Dərmanın adını və istifadə səbəbini daxil edin...",
"نام دارو و دلیل مصرف را وارد نمایید...": "Dərmanın adını və istifadə səbəbini daxil edin...",
"وضعیت سلامت جسمانی": "Fiziki Sağlamlıq Vəziyyəti",
"توضیحات وضعیت جسمانی": "Fiziki Sağlamlıq Təsviri"
"توضیحات وضعیت جسمانی": "Fiziki Sağlamlıq Təsviri",
"Name": "Ad"
}

13
src/translations/locales/bn.json

@ -199,7 +199,7 @@
"Education, Career, and Economic Status": "শিক্ষা, কর্মজীবন, এবং অর্থনৈতিক অবস্থা",
"Emotional": "আবেগপ্রবণ",
"Employed": "নিযুক্ত",
"Employment Status": "কর্মসংস্থানের অবস্থা",
"Employment Status": "কর্মসংস্থান অবস্থা",
"English": "ইংরেজি",
"English, French, etc.": "ইংরেজি, ফরাসি, ইত্যাদি",
"Enter details here...": "এখানে বিস্তারিত লিখুন...",
@ -260,7 +260,7 @@
"Height in Centimeters": "সেন্টিমিটারে উচ্চতা",
"Help": "সাহায্য",
"High School Diploma": "High School Diploma",
"Highest Level of Education": "শিক্ষার সর্বোচ্চ স্তর",
"Highest Level of Education": "সর্বোচ্চ শিক্ষাগত যোগ্যতা",
"Homemaker": "গৃহিণী",
"Homeowner": "বাড়ির মালিক",
"Hookah": "হুক্কা",
@ -308,7 +308,7 @@
"Iraq": "ইরাক",
"Items Per Page": "পৃষ্ঠা প্রতি আইটেম",
"Job Seeking / Unemployed": "চাকরির খোঁজ / বেকার",
"Job Title": "চাকরির শিরোনাম",
"Job Title": "কাজের পদবি",
"Joint custody or periodic visitation/relocation between parents.": "যৌথ হেফাজত বা পিতামাতার মধ্যে পর্যায়ক্রমিক পরিদর্শন/স্থানান্তর।",
"Kurdish": "কুর্দি",
"Language Learning": "ভাষা শিক্ষা",
@ -684,7 +684,7 @@
"View More Details": "আরো বিস্তারিত দেখুন",
"View Profile": "প্রোফাইল দেখুন",
"View contact number": "যোগাযোগের নম্বর দেখুন",
"View more details": "আর বিস্তারিত দেখুন",
"View more details": "আর বিস্তারিত দেখুন",
"View profile": "প্রোফাইল দেখুন",
"Watch Video": "ভিডিও দেখুন",
"We did not reach an agreement": "আমরা সমঝোতায় পৌঁছাইনি",
@ -725,7 +725,7 @@
"You have active access to view candidates.": "প্রার্থীদের দেখতে আপনার সক্রিয় অ্যাক্সেস আছে।",
"You will be contacted by your consultant.": "আপনার পরামর্শদাতা দ্বারা আপনার সাথে যোগাযোগ করা হবে।",
"You've completed all required fields. However, filling in all sections will help us find better matches for you": "আপনি সমস্ত প্রয়োজনীয় ক্ষেত্র সম্পূর্ণ করেছেন। যাইহোক, সমস্ত বিভাগ পূরণ করা আমাদের আপনার জন্য আরও ভাল মিল খুঁজে পেতে সাহায্য করবে",
"Your Hobbies and Main Interests": "আপনার শখ এবং প্রধান আগ্রহ",
"Your Hobbies and Main Interests": "আপনার শখ ও প্রধান আগ্রহসমূহ",
"Your Personality Traits": "আপনার ব্যক্তিত্ব বৈশিষ্ট্য",
"Your details are only used for the matching process.": "আপনার বিবরণ শুধুমাত্র মিল প্রক্রিয়ার জন্য ব্যবহার করা হয়.",
"Your information is kept strictly confidential.": "আপনার তথ্য কঠোরভাবে গোপন রাখা হয়.",
@ -807,5 +807,6 @@
"Enter medication name and reason for use...": "ওষুধের নাম এবং কারণ লিখুন...",
"نام دارو و دلیل مصرف را وارد نمایید...": "ওষুধের নাম এবং কারণ লিখুন...",
"وضعیت سلامت جسمانی": "শারীরিক স্বাস্থ্যের অবস্থা",
"توضیحات وضعیت جسمانی": "শারীরিক স্বাস্থ্যের বিবরণ"
"توضیحات وضعیت جسمانی": "শারীরিক স্বাস্থ্যের বিবরণ",
"Name": "নাম"
}

13
src/translations/locales/da.json

@ -141,7 +141,7 @@
"Current Housing Status": "Nuværende boligstatus",
"Current Marital Status": "Nuværende ægteskabelig status",
"Current Nationality / Citizenship": "Nuværende nationalitet / statsborgerskab",
"Current Residence": "Nuværende bolig",
"Current Residence": "Nuværende bol",
"Current user": "Nuværende bruger",
"Currently building suitable financial conditions": "Opbygger i øjeblikket passende økonomiske forhold",
"Curvy/Full": "Kurvet/fuld",
@ -199,7 +199,7 @@
"Education, Career, and Economic Status": "Uddannelse, karriere og økonomisk status",
"Emotional": "Følelsesmæssigt",
"Employed": "Ansat",
"Employment Status": "Ansættelsesstatus",
"Employment Status": "Beskæftigelsesstatus",
"English": "engelsk",
"English, French, etc.": "engelsk, fransk osv.",
"Enter details here...": "Indtast detaljer her...",
@ -235,7 +235,7 @@
"Full Hijab with modest clothing - Modest styling with hair completely covered.": "Fuld Hijab med beskedent tøj - Beskeden styling med hår helt dækket.",
"Full Islamic covering (Maximum Hijab) - Abaya, Jilbab, Chador, or Niqab with full observance.": "Fuld islamisk dækning (Maksimal Hijab) - Abaya, Jilbab, Chador eller Niqab med fuld overholdelse.",
"Full Islamic covering mandatory": "Fuld islamisk dækning obligatorisk",
"Full Name": "Fuldt navn",
"Full Name": "Fulde navn",
"Full makeup": "Fuld makeup",
"Full-time Employed": "Fuldtidsansat",
"Fully able to support expenses": "Fuldt ud i stand til at dække udgifter",
@ -308,7 +308,7 @@
"Iraq": "Irak",
"Items Per Page": "Items Per Page",
"Job Seeking / Unemployed": "Jobsøgende / ledig",
"Job Title": "Jobtitel",
"Job Title": "Stilling",
"Joint custody or periodic visitation/relocation between parents.": "Fælles forældremyndighed eller periodisk samvær/flytning mellem forældre.",
"Kurdish": "kurdisk",
"Language Learning": "Sprogindlæring",
@ -725,7 +725,7 @@
"You have active access to view candidates.": "Du har aktiv adgang til at se kandidater.",
"You will be contacted by your consultant.": "Du vil blive kontaktet af din konsulent.",
"You've completed all required fields. However, filling in all sections will help us find better matches for you": "You've completed all required fields. However, filling in all sections will help us find better matches for you",
"Your Hobbies and Main Interests": "Dine hobbyer og hovedinteresser",
"Your Hobbies and Main Interests": "Dine hobbyer og vigtigste interesser",
"Your Personality Traits": "Dine personlighedstræk",
"Your details are only used for the matching process.": "Your details are only used for the matching process.",
"Your information is kept strictly confidential.": "Your information is kept strictly confidential.",
@ -807,5 +807,6 @@
"Enter medication name and reason for use...": "Indtast medicinnavn og årsag...",
"نام دارو و دلیل مصرف را وارد نمایید...": "Indtast medicinnavn og årsag...",
"وضعیت سلامت جسمانی": "Fysisk helbredstilstand",
"توضیحات وضعیت جسمانی": "Beskrivelse af fysisk helbred"
"توضیحات وضعیت جسمانی": "Beskrivelse af fysisk helbred",
"Name": "Navn"
}

7
src/translations/locales/de.json

@ -141,7 +141,7 @@
"Current Housing Status": "Aktueller Wohnstatus",
"Current Marital Status": "Aktueller Familienstand",
"Current Nationality / Citizenship": "Aktuelle Nationalität/Staatsbürgerschaft",
"Current Residence": "Aktueller Wohnsitz",
"Current Residence": "Aktueller Wohnort",
"Current user": "Aktueller Benutzer",
"Currently building suitable financial conditions": "Derzeit werden geeignete finanzielle Rahmenbedingungen aufgebaut",
"Curvy/Full": "Kurvig/voll",
@ -260,7 +260,7 @@
"Height in Centimeters": "Höhe in Zentimetern",
"Help": "Help",
"High School Diploma": "High-School-Diplom",
"Highest Level of Education": "Höchstes Bildungsniveau",
"Highest Level of Education": "Höchster Bildungsabschluss",
"Homemaker": "Hausfrau",
"Homeowner": "Hausbesitzer",
"Hookah": "Shisha",
@ -807,5 +807,6 @@
"Enter medication name and reason for use...": "Geben Sie den Medikamentennamen und den Grund ein...",
"نام دارو و دلیل مصرف را وارد نمایید...": "Geben Sie den Medikamentennamen und den Grund ein...",
"وضعیت سلامت جسمانی": "Körperlicher Gesundheitszustand",
"توضیحات وضعیت جسمانی": "Beschreibung des körperlichen Zustands"
"توضیحات وضعیت جسمانی": "Beschreibung des körperlichen Zustands",
"Name": "Name"
}

4
src/translations/locales/en.json

@ -843,5 +843,7 @@
"Do you have a history of treatment or counseling related to mental health in the past?": "Do you have a history of treatment or counseling related to mental health in the past?",
"Do you currently take any medication on an ongoing basis?": "Do you currently take any medication on an ongoing basis?",
"Medication Name and Reason for Use": "Medication Name and Reason for Use",
"Enter medication name and reason for use...": "Enter medication name and reason for use..."
"Enter medication name and reason for use...": "Enter medication name and reason for use...",
"Enter a valid phone number with country code.": "Enter a valid phone number with country code.",
"Name": "Name"
}

11
src/translations/locales/es.json

@ -46,7 +46,7 @@
"Additional Comments on Economic and Housing Status": "Comentarios adicionales sobre la situación económica y de vivienda",
"Additional details about family responsibility": "Detalles adicionales sobre la responsabilidad familiar",
"Afghanistan": "Afganistán",
"Age": "edad",
"Age": "Edad",
"Alcohol is a serious red line": "El alcohol es una línea roja grave",
"Alcohol is a serious red line for me": "El alcohol es una línea roja grave para mí.",
"Alcoholic Beverages": "Bebidas Alcohólicas",
@ -199,7 +199,7 @@
"Education, Career, and Economic Status": "Educación, carrera y situación económica",
"Emotional": "Emocional",
"Employed": "Empleado",
"Employment Status": "Estado de empleo",
"Employment Status": "Estado laboral",
"English": "ingles",
"English, French, etc.": "inglés, francés, etc.",
"Enter details here...": "Ingrese los detalles aquí...",
@ -260,7 +260,7 @@
"Height in Centimeters": "Altura en centímetros",
"Help": "Help",
"High School Diploma": "Diploma de escuela secundaria",
"Highest Level of Education": "Nivel más alto de educación",
"Highest Level of Education": "Nivel educativo más alto",
"Homemaker": "ama de casa",
"Homeowner": "Propietario de casa",
"Hookah": "cachimba",
@ -308,7 +308,7 @@
"Iraq": "Irak",
"Items Per Page": "Items Per Page",
"Job Seeking / Unemployed": "Buscando empleo / Desempleado",
"Job Title": "Título del trabajo",
"Job Title": "Título del puesto",
"Joint custody or periodic visitation/relocation between parents.": "Custodia compartida o visitas periódicas/reubicación entre padres.",
"Kurdish": "kurdo",
"Language Learning": "Aprendizaje de idiomas",
@ -807,5 +807,6 @@
"Enter medication name and reason for use...": "Ingrese el nombre del medicamento y el motivo...",
"نام دارو و دلیل مصرف را وارد نمایید...": "Ingrese el nombre del medicamento y el motivo...",
"وضعیت سلامت جسمانی": "Estado de salud física",
"توضیحات وضعیت جسمانی": "Descripción de la salud física"
"توضیحات وضعیت جسمانی": "Descripción de la salud física",
"Name": "Nombre"
}

3
src/translations/locales/fa.json

@ -855,5 +855,6 @@
"Enter medication name and reason for use...": "نام دارو و دلیل مصرف را وارد نمایید...",
"نام دارو و دلیل مصرف را وارد نمایید...": "نام دارو و دلیل مصرف را وارد نمایید...",
"وضعیت سلامت جسمانی": "وضعیت سلامت جسمانی",
"توضیحات وضعیت جسمانی": "توضیحات وضعیت جسمانی"
"توضیحات وضعیت جسمانی": "توضیحات وضعیت جسمانی",
"Name": "نام"
}

11
src/translations/locales/fr.json

@ -199,7 +199,7 @@
"Education, Career, and Economic Status": "Éducation, carrière et statut économique",
"Emotional": "Émotionnel",
"Employed": "Employé",
"Employment Status": "Statut d'emploi",
"Employment Status": "Statut professionnel",
"English": "Anglais",
"English, French, etc.": "Anglais, français, etc.",
"Enter details here...": "Entrez les détails ici...",
@ -219,7 +219,7 @@
"Father alive": "Père vivant",
"Father has passed away": "Le père est décédé",
"Feel free to briefly explain your decision...": "Your explanatory text ...",
"Field of Study": "Domaine d'études",
"Field of Study": "Domaine détudes",
"Final Consent": "Final Consent",
"Final Match Introduction": "Final Match Introduction",
"Final Notice": "Final Notice",
@ -260,7 +260,7 @@
"Height in Centimeters": "Hauteur en centimètres",
"Help": "Help",
"High School Diploma": "Diplôme d'études secondaires",
"Highest Level of Education": "Niveau d'éducation le plus élevé",
"Highest Level of Education": "Plus haut niveau d’études",
"Homemaker": "Femme au foyer",
"Homeowner": "Propriétaire",
"Hookah": "Narguilé",
@ -725,7 +725,7 @@
"You have active access to view candidates.": "Vous disposez d'un accès actif pour visualiser les candidats.",
"You will be contacted by your consultant.": "Vous serez contacté par votre consultant.",
"You've completed all required fields. However, filling in all sections will help us find better matches for you": "You've completed all required fields. However, filling in all sections will help us find better matches for you",
"Your Hobbies and Main Interests": "Vos passe-temps et principaux intérêts",
"Your Hobbies and Main Interests": "Vos loisirs et principaux centres d’intérêt",
"Your Personality Traits": "Vos traits de personnalité",
"Your details are only used for the matching process.": "Your details are only used for the matching process.",
"Your information is kept strictly confidential.": "Your information is kept strictly confidential.",
@ -807,5 +807,6 @@
"Enter medication name and reason for use...": "Entrez le nom du médicament et le motif...",
"نام دارو و دلیل مصرف را وارد نمایید...": "Entrez le nom du médicament et le motif...",
"وضعیت سلامت جسمانی": "État de santé physique",
"توضیحات وضعیت جسمانی": "Description de la santé physique"
"توضیحات وضعیت جسمانی": "Description de la santé physique",
"Name": "Nom"
}

11
src/translations/locales/gu.json

@ -141,7 +141,7 @@
"Current Housing Status": "વર્તમાન હાઉસિંગ સ્થિતિ",
"Current Marital Status": "વર્તમાન વૈવાહિક સ્થિતિ",
"Current Nationality / Citizenship": "વર્તમાન રાષ્ટ્રીયતા / નાગરિકતા",
"Current Residence": "વર્તમાન રહેઠાણ",
"Current Residence": "હાલનું રહેઠાણ",
"Current user": "વર્તમાન વપરાશકર્તા",
"Currently building suitable financial conditions": "હાલમાં યોગ્ય નાણાકીય પરિસ્થિતિઓ બનાવી રહી છે",
"Curvy/Full": "કર્વી/પૂર્ણ",
@ -219,7 +219,7 @@
"Father alive": "પિતા જીવંત",
"Father has passed away": "પિતા ગુજરી ગયા છે",
"Feel free to briefly explain your decision...": "Your explanatory text ...",
"Field of Study": "અભ્યાસ ક્ષેત્ર",
"Field of Study": "અભ્યાસનું ક્ષેત્ર",
"Final Consent": "Final Consent",
"Final Match Introduction": "Final Match Introduction",
"Final Notice": "Final Notice",
@ -260,7 +260,7 @@
"Height in Centimeters": "સેન્ટીમીટરમાં ઊંચાઈ",
"Help": "Help",
"High School Diploma": "હાઇ સ્કૂલ ડિપ્લોમા",
"Highest Level of Education": "શિક્ષણનું ઉચ્ચતમ સ્તર",
"Highest Level of Education": "ઉચ્ચતમ શિક્ષણ સ્તર",
"Homemaker": "ગૃહિણી",
"Homeowner": "ઘરમાલિક",
"Hookah": "હુક્કો",
@ -308,7 +308,7 @@
"Iraq": "ઈરાક",
"Items Per Page": "Items Per Page",
"Job Seeking / Unemployed": "નોકરીની શોધ / બેરોજગાર",
"Job Title": "જોબ શીર્ષક",
"Job Title": "નોકરીનું પદ",
"Joint custody or periodic visitation/relocation between parents.": "સંયુક્ત કસ્ટડી અથવા માતા-પિતા વચ્ચે સમયાંતરે મુલાકાત/સ્થાપન.",
"Kurdish": "કુર્દિશ",
"Language Learning": "ભાષા શીખવી",
@ -807,5 +807,6 @@
"Enter medication name and reason for use...": "દવાનું નામ અને કારણ દાખલ કરો...",
"نام دارو و دلیل مصرف را وارد نمایید...": "દવાનું નામ અને કારણ દાખલ કરો...",
"وضعیت سلامت جسمانی": "શારીરિક સ્વાસ્થ્ય સ્થિતિ",
"توضیحات وضعیت جسمانی": "શારીરિક સ્વાસ્થ્ય વર્ણન"
"توضیحات وضعیت جسمانی": "શારીરિક સ્વાસ્થ્ય વર્ણન",
"Name": "નામ"
}

11
src/translations/locales/ha.json

@ -141,7 +141,7 @@
"Current Housing Status": "Matsayin Gidajen Yanzu",
"Current Marital Status": "Matsayin Aure na Yanzu",
"Current Nationality / Citizenship": "Dan Kasa / Dan Kasa na Yanzu",
"Current Residence": "Mazauni na Yanzu",
"Current Residence": "Wurin zama na yanzu",
"Current user": "Mai amfani na yanzu",
"Currently building suitable financial conditions": "A halin yanzu gina yanayin kuɗi masu dacewa",
"Curvy/Full": "Curvy/cikakke",
@ -219,7 +219,7 @@
"Father alive": "Uba a raye",
"Father has passed away": "Uban ya rasu",
"Feel free to briefly explain your decision...": "Your explanatory text ...",
"Field of Study": "Filin Karatu",
"Field of Study": "Fannin Karatu",
"Final Consent": "Final Consent",
"Final Match Introduction": "Final Match Introduction",
"Final Notice": "Final Notice",
@ -260,7 +260,7 @@
"Height in Centimeters": "Tsawon santimita",
"Help": "Help",
"High School Diploma": "High School Diploma",
"Highest Level of Education": "Highest Level of Education",
"Highest Level of Education": "Mafi girman matakin ilimi",
"Homemaker": "Homemaker",
"Homeowner": "Mai gida",
"Hookah": "Hookah",
@ -725,7 +725,7 @@
"You have active access to view candidates.": "Kuna da damar shiga don duba 'yan takara.",
"You will be contacted by your consultant.": "Mai ba da shawara zai tuntube ku.",
"You've completed all required fields. However, filling in all sections will help us find better matches for you": "You've completed all required fields. However, filling in all sections will help us find better matches for you",
"Your Hobbies and Main Interests": "Abubuwan sha'awarku da Babban Sha'awar ku",
"Your Hobbies and Main Interests": "Abubuwan sha’awa da manyan bukatu",
"Your Personality Traits": "Halayen Halinku",
"Your details are only used for the matching process.": "Your details are only used for the matching process.",
"Your information is kept strictly confidential.": "Your information is kept strictly confidential.",
@ -807,5 +807,6 @@
"Enter medication name and reason for use...": "Shigar da sunan magani da dalilin sha...",
"نام دارو و دلیل مصرف را وارد نمایید...": "Shigar da sunan magani da dalilin sha...",
"وضعیت سلامت جسمانی": "Yanayin Lafiyar Jiki",
"توضیحات وضعیت جسمانی": "Bayanin Lafiyar Jiki"
"توضیحات وضعیت جسمانی": "Bayanin Lafiyar Jiki",
"Name": "Suna"
}

11
src/translations/locales/he.json

@ -346,5 +346,14 @@
"Physical Health Status": "מצב בריאות פיזית",
"وضعیت سلامت جسمانی": "מצב בריאות פיזית",
"Physical Health Description": "תיאור בריאות פיזית",
"توضیحات وضعیت جسمانی": "תיאור בריאות פיזית"
"توضیحات وضعیت جسمانی": "תיאור בריאות פיזית",
"Full Name": "שם מלא",
"Name": "שם",
"Current Residence": "מקום מגורים נוכחי",
"Highest Level of Education": "רמת ההשכלה הגבוהה ביותר",
"Field of Study": "תחום לימודים",
"Job Title": "תואר התפקיד",
"Employment Status": "מצב תעסוקתי",
"Your Hobbies and Main Interests": "התחביבים ותחומי העניין העיקריים שלך",
"View more details": "הצג פרטים נוספים"
}

11
src/translations/locales/hi.json

@ -199,7 +199,7 @@
"Education, Career, and Economic Status": "शिक्षा, करियर और आर्थिक स्थिति",
"Emotional": "भावुक",
"Employed": "कार्यरत",
"Employment Status": "रोजगार की स्थिति",
"Employment Status": "रोजगार की स्थिति",
"English": "अंग्रेजी",
"English, French, etc.": "अंग्रेजी, फ्रेंच, आदि",
"Enter details here...": "यहां विवरण दर्ज करें...",
@ -260,7 +260,7 @@
"Height in Centimeters": "ऊंचाई सेंटीमीटर में",
"Help": "Help",
"High School Diploma": "हाई स्कूल डिप्लोमा",
"Highest Level of Education": "शिक्षा का उच्चतम स्तर",
"Highest Level of Education": "उच्चतम शिक्षा स्तर",
"Homemaker": "गृहिणी",
"Homeowner": "गृहस्वामी",
"Hookah": "हुक्का",
@ -308,7 +308,7 @@
"Iraq": "इराक",
"Items Per Page": "Items Per Page",
"Job Seeking / Unemployed": "नौकरी तलाशने वाले/बेरोजगार",
"Job Title": "नौकरी का शीर्षक",
"Job Title": "पद / व्यवसाय",
"Joint custody or periodic visitation/relocation between parents.": "माता-पिता के बीच संयुक्त अभिरक्षा या आवधिक मुलाक़ात/स्थानांतरण।",
"Kurdish": "कुर्दिश",
"Language Learning": "भाषा सीखना",
@ -725,7 +725,7 @@
"You have active access to view candidates.": "उम्मीदवारों को देखने के लिए आपके पास सक्रिय पहुंच है।",
"You will be contacted by your consultant.": "आपका सलाहकार आपसे संपर्क करेगा.",
"You've completed all required fields. However, filling in all sections will help us find better matches for you": "You've completed all required fields. However, filling in all sections will help us find better matches for you",
"Your Hobbies and Main Interests": "आपके शौक और मुख्य रुचिया",
"Your Hobbies and Main Interests": "आपके शौक और मुख्य रुचिया",
"Your Personality Traits": "आपके व्यक्तित्व के लक्षण",
"Your details are only used for the matching process.": "Your details are only used for the matching process.",
"Your information is kept strictly confidential.": "Your information is kept strictly confidential.",
@ -807,5 +807,6 @@
"Enter medication name and reason for use...": "दवा का नाम और उपयोग का कारण दर्ज करें...",
"نام دارو و دلیل مصرف را وارد نمایید...": "दवा का नाम और उपयोग का कारण दर्ज करें...",
"وضعیت سلامت جسمانی": "शारीरिक स्वास्थ्य की स्थिति",
"توضیحات وضعیت جسمانی": "शारीरिक स्वास्थ्य का विवरण"
"توضیحات وضعیت جسمانی": "शारीरिक स्वास्थ्य का विवरण",
"Name": "नाम"
}

11
src/translations/locales/id.json

@ -346,5 +346,14 @@
"Physical Health Status": "Status Kesehatan Fisik",
"وضعیت سلامت جسمانی": "Status Kesehatan Fisik",
"Physical Health Description": "Deskripsi Kesehatan Fisik",
"توضیحات وضعیت جسمانی": "Deskripsi Kesehatan Fisik"
"توضیحات وضعیت جسمانی": "Deskripsi Kesehatan Fisik",
"Full Name": "Nama Lengkap",
"Name": "Nama",
"Current Residence": "Tempat Tinggal Saat Ini",
"Highest Level of Education": "Tingkat Pendidikan Tertinggi",
"Field of Study": "Bidang Studi",
"Job Title": "Jabatan / Pekerjaan",
"Employment Status": "Status Pekerjaan",
"Your Hobbies and Main Interests": "Hobi dan Minat Utama Anda",
"View more details": "Lihat detail selengkapnya"
}

13
src/translations/locales/ks.json

@ -285,7 +285,7 @@
"Day": "دوہ",
"Month": "ماہ",
"Year": "ؤری",
"Age": "عمر",
"Age": "وٲنٛس",
"Confirm": "تصدیق کریں",
"Failed to load advisors.": "مشیرن لوڈ کرنأک ناکام۔",
"Try again": "دوبآ کوشش کریو",
@ -346,5 +346,14 @@
"Physical Health Status": "جسمانی صحتٕچ حالت",
"وضعیت سلامت جسمانی": "جسمانی صحتٕچ حالت",
"Physical Health Description": "جسمانی صحتٕچ وضاحت",
"توضیحات وضعیت جسمانی": "جسمانی صحتٕچ وضاحت"
"توضیحات وضعیت جسمانی": "جسمانی صحتٕچ وضاحت",
"Full Name": "پوٗرٕ ناڤ",
"Name": "ناو",
"Current Residence": "مَوجوٗدٕ رِہایش",
"Highest Level of Education": "ساروِی کھۄتہٕ زیٛادٕ تٲلیٖم",
"Field of Study": "تٲلیٖمی شۄعبہٕ",
"Job Title": "کٲم ہُنٛد ناو",
"Employment Status": "مُلازمتٕچ حالت",
"Your Hobbies and Main Interests": "تُہنٛدؠ شۄق تہٕ اَہَم دِلچسپی",
"View more details": "مزید تفصیل وُچھِو"
}

11
src/translations/locales/pt.json

@ -346,5 +346,14 @@
"Physical Health Status": "Estado de Saúde Física",
"وضعیت سلامت جسمانی": "Estado de Saúde Física",
"Physical Health Description": "Descrição da Saúde Física",
"توضیحات وضعیت جسمانی": "Descrição da Saúde Física"
"توضیحات وضعیت جسمانی": "Descrição da Saúde Física",
"Full Name": "Nome completo",
"Name": "Nome",
"Current Residence": "Residência atual",
"Highest Level of Education": "Nível de escolaridade mais alto",
"Field of Study": "Área de estudo",
"Job Title": "Cargo / Título profissional",
"Employment Status": "Situação profissional",
"Your Hobbies and Main Interests": "Seus hobbies e principais interesses",
"View more details": "Ver mais detalhes"
}

11
src/translations/locales/ru.json

@ -221,7 +221,7 @@
"Father alive": "Отец жив",
"Father has passed away": "Отец скончался",
"Feel free to briefly explain your decision...": "Не стесняйтесь кратко объяснить свое решение...",
"Field of Study": "Область исследования",
"Field of Study": "Специальность / область обучения",
"Final Consent": "Окончательное согласие",
"Final Match Introduction": "Введение в финальный матч",
"Final Notice": "Последнее уведомление",
@ -262,7 +262,7 @@
"Height in Centimeters": "Рост в сантиметрах",
"Help": "Помощь",
"High School Diploma": "Диплом средней школы",
"Highest Level of Education": "Высший уровень образования",
"Highest Level of Education": "Наивысший уровень образования",
"Homemaker": "Домохозяйка",
"Homeowner": "домовладелец",
"Hookah": "Кальян",
@ -310,7 +310,7 @@
"Iraq": "Ирак",
"Items Per Page": "Элементов на странице",
"Job Seeking / Unemployed": "Ищу работу / Безработный",
"Job Title": "Должность",
"Job Title": "Должность / Профессия",
"Joint custody or periodic visitation/relocation between parents.": "Совместная опека или периодическое посещение/переезд родителей.",
"Kurdish": "курдский",
"Language Learning": "Изучение языка",
@ -693,7 +693,7 @@
"View More Details": "Посмотреть более подробную информацию",
"View Profile": "Посмотреть профиль",
"View contact number": "Посмотреть контактный номер",
"View more details": "Посмотреть более подробную информацию",
"View more details": "Посмотреть подробнее",
"View profile": "Посмотреть профиль",
"Watch Video": "Посмотреть видео",
"We are in the acquaintance/proposal process and nothing is finalized yet": "Мы находимся в процессе знакомства/предложения, и еще ничего не решено",
@ -811,5 +811,6 @@
"Enter medication name and reason for use...": "Введите название препарата и причину приема...",
"نام دارو و دلیل مصرف را وارد نمایید...": "Введите название препарата и причину приема...",
"وضعیت سلامت جسمانی": "Физическое состояние здоровья",
"توضیحات وضعیت جسمانی": "Описание физического состояния"
"توضیحات وضعیت جسمانی": "Описание физического состояния",
"Name": "Имя"
}

11
src/translations/locales/sw.json

@ -346,5 +346,14 @@
"Physical Health Status": "Hali ya Afya ya Kimwili",
"وضعیت سلامت جسمانی": "Hali ya Afya ya Kimwili",
"Physical Health Description": "Maelezo ya Afya ya Kimwili",
"توضیحات وضعیت جسمانی": "Maelezo ya Afya ya Kimwili"
"توضیحات وضعیت جسمانی": "Maelezo ya Afya ya Kimwili",
"Full Name": "Jina Kamili",
"Name": "Jina",
"Current Residence": "Makazi ya Sasa",
"Highest Level of Education": "Kiwango cha Juu cha Elimu",
"Field of Study": "Uwanja wa Masomo",
"Job Title": "Wadhifa wa Kazi",
"Employment Status": "Hali ya Ajira",
"Your Hobbies and Main Interests": "Mambo unayopenda na Maslahi Kuu",
"View more details": "Angalia maelezo zaidi"
}

11
src/translations/locales/tg.json

@ -346,5 +346,14 @@
"Physical Health Status": "Вазъи саломатии ҷисмонӣ",
"وضعیت سلامت جسمانی": "Вазъи саломатии ҷисмонӣ",
"Physical Health Description": "Тавсифи саломатии ҷисмонӣ",
"توضیحات وضعیت جسمانی": "Тавсифи саломатии ҷисмонӣ"
"توضیحات وضعیت جسمانی": "Тавсифи саломатии ҷисмонӣ",
"Full Name": "Ному насаб",
"Name": "Ном",
"Current Residence": "Ҷои истиқомати ҳозира",
"Highest Level of Education": "Дараҷаи олии таҳсилот",
"Field of Study": "Соҳаи таҳсил",
"Job Title": "Унвони вазифа",
"Employment Status": "Вазъи шуғл",
"Your Hobbies and Main Interests": "Машғулиятҳо ва манфиатҳои асосии шумо",
"View more details": "Дидани тафсилоти бештар"
}

11
src/translations/locales/tr.json

@ -346,5 +346,14 @@
"Physical Health Status": "Fiziksel Sağlık Durumu",
"وضعیت سلامت جسمانی": "Fiziksel Sağlık Durumu",
"Physical Health Description": "Fiziksel Sağlık Açıklaması",
"توضیحات وضعیت جسمانی": "Fiziksel Sağlık Açıklaması"
"توضیحات وضعیت جسمانی": "Fiziksel Sağlık Açıklaması",
"Full Name": "Tam Adı",
"Name": "İsim",
"Current Residence": "Mevcut İkametgah",
"Highest Level of Education": "En Yüksek Eğitim Düzeyi",
"Field of Study": "Öğrenim Alanı / Bölüm",
"Job Title": "Meslek / Unvan",
"Employment Status": "Çalışma Durumu",
"Your Hobbies and Main Interests": "Hobileriniz ve Temel İlgi Alanlarınız",
"View more details": "Daha fazla ayrıntı gör"
}

13
src/translations/locales/ul.json

@ -285,7 +285,7 @@
"Day": "كۈن",
"Month": "ئاي",
"Year": "يىل",
"Age": "يېشى",
"Age": "Age",
"Confirm": "جەزملەش",
"Failed to load advisors.": "مشاورین لوڈ نہیں ہو سکے۔",
"Try again": "دوبارہ کوشش کریں",
@ -346,5 +346,14 @@
"Physical Health Status": "Jismoniy salomatlik",
"وضعیت سلامت جسمانی": "Jismoniy salomatlik",
"Physical Health Description": "Jismoniy salomatlik tavsifi",
"توضیحات وضعیت جسمانی": "Jismoniy salomatlik tavsifi"
"توضیحات وضعیت جسمانی": "Jismoniy salomatlik tavsifi",
"Full Name": "Full Name",
"Name": "Name",
"Current Residence": "Current Residence",
"Highest Level of Education": "Highest Level of Education",
"Field of Study": "Field of Study",
"Job Title": "Job Title",
"Employment Status": "Employment Status",
"Your Hobbies and Main Interests": "Your Hobbies and Main Interests",
"View more details": "View more details"
}

11
src/translations/locales/ur.json

@ -346,5 +346,14 @@
"Physical Health Status": "جسمانی صحت کی حالت",
"وضعیت سلامت جسمانی": "جسمانی صحت کی حالت",
"Physical Health Description": "جسمانی صحت کی تفصیل",
"توضیحات وضعیت جسمانی": "جسمانی صحت کی تفصیل"
"توضیحات وضعیت جسمانی": "جسمانی صحت کی تفصیل",
"Full Name": "مکمل نام",
"Name": "نام",
"Current Residence": "موجودہ رہائش",
"Highest Level of Education": "اعلیٰ ترین تعلیمی قابلیت",
"Field of Study": "شعبہ تعلیم",
"Job Title": "عہدہ / ملازمت کا عنوان",
"Employment Status": "ملازمت کی صورتحال",
"Your Hobbies and Main Interests": "آپ کے مشاغل اور اہم دلچسپیاں",
"View more details": "مزید تفصیلات دیکھیں"
}

11
src/translations/locales/uz.json

@ -346,5 +346,14 @@
"Physical Health Status": "Jismoniy salomatlik holati",
"وضعیت سلامت جسمانی": "Jismoniy salomatlik holati",
"Physical Health Description": "Jismoniy salomatlik tavsifi",
"توضیحات وضعیت جسمانی": "Jismoniy salomatlik tavsifi"
"توضیحات وضعیت جسمانی": "Jismoniy salomatlik tavsifi",
"Full Name": "Toʻliq ism",
"Name": "Ism",
"Current Residence": "Hozirgi yashash joyi",
"Highest Level of Education": "Eng yuqori taʼlim darajasi",
"Field of Study": "Oʻqish sohasi / Yoʻnalishi",
"Job Title": "Kasb / Lavozim",
"Employment Status": "Bandlik holati",
"Your Hobbies and Main Interests": "Qiziqishlaringiz va asosiy mashgʻulotlaringiz",
"View more details": "Batafsil maʼlumotni koʻrish"
}

13
src/translations/locales/zh.json

@ -46,7 +46,7 @@
"Additional Comments on Economic and Housing Status": "关于经济和住房状况的补充意见",
"Additional details about family responsibility": "有关家庭责任的其他详细信息",
"Afghanistan": "Afghanistan",
"Age": "Age",
"Age": "年龄",
"Alcohol is a serious red line": "酒精是一条严重的红线",
"Alcohol is a serious red line for me": "Alcohol is a serious red line for me",
"Alcoholic Beverages": "Alcoholic Beverages",
@ -141,7 +141,7 @@
"Current Housing Status": "目前的住房状况",
"Current Marital Status": "Current Marital Status",
"Current Nationality / Citizenship": "Current Nationality / Citizenship",
"Current Residence": "Current Residence",
"Current Residence": "现居住地",
"Current user": "当前用户",
"Currently building suitable financial conditions": "目前正在建立适当的财务条件",
"Curvy/Full": "曲线/丰满",
@ -219,7 +219,7 @@
"Father alive": "父亲还活着",
"Father has passed away": "父亲去世了",
"Feel free to briefly explain your decision...": "Your explanatory text ...",
"Field of Study": "研究领域",
"Field of Study": "专业 / 学习领域",
"Final Consent": "Final Consent",
"Final Match Introduction": "Final Match Introduction",
"Final Notice": "Final Notice",
@ -260,7 +260,7 @@
"Height in Centimeters": "高度(厘米)",
"Help": "Help",
"High School Diploma": "高中文凭",
"Highest Level of Education": "最高教育水平",
"Highest Level of Education": "最高学历",
"Homemaker": "家庭主妇",
"Homeowner": "房主",
"Hookah": "水烟",
@ -725,7 +725,7 @@
"You have active access to view candidates.": "您可以主动访问查看候选人。",
"You will be contacted by your consultant.": "您的顾问将与您联系。",
"You've completed all required fields. However, filling in all sections will help us find better matches for you": "You've completed all required fields. However, filling in all sections will help us find better matches for you",
"Your Hobbies and Main Interests": "的爱好和主要兴趣",
"Your Hobbies and Main Interests": "的爱好和主要兴趣",
"Your Personality Traits": "你的性格特征",
"Your details are only used for the matching process.": "Your details are only used for the matching process.",
"Your information is kept strictly confidential.": "Your information is kept strictly confidential.",
@ -807,5 +807,6 @@
"Enter medication name and reason for use...": "输入药物名称和使用原因...",
"نام دارو و دلیل مصرف را وارد نمایید...": "输入药物名称和使用原因...",
"وضعیت سلامت جسمانی": "身体健康状况",
"توضیحات وضعیت جسمانی": "身体健康说明"
"توضیحات وضعیت جسمانی": "身体健康说明",
"Name": "姓名"
}

7
src/translations/provider.tsx

@ -1,8 +1,9 @@
"use client";
import { createContext, type ReactNode, useContext, useMemo } from "react";
import { createContext, type ReactNode, useContext, useEffect, useMemo } from "react";
import { defaultLocale, type Locale } from "@/translations/config";
import { type Dictionary, getDictionary } from "@/translations/dictionaries";
import { getUserGeoRegion } from "@/lib/geo-region";
type I18nContextValue = {
locale: Locale;
@ -20,6 +21,10 @@ type I18nProviderProps = {
};
export function I18nProvider({ children, locale }: I18nProviderProps) {
useEffect(() => {
void getUserGeoRegion(true);
}, []);
const value = useMemo(
() => ({
locale,

Loading…
Cancel
Save