Browse Source

feat: implement core marriage matching application architecture including UI components, question workflows, and multi-language support.

front-test-2
ghorbani 3 weeks ago
parent
commit
0facb88510
  1. 8
      next.config.ts
  2. 22668
      public/openapi.json
  3. 4
      src/app/[lang]/layout.tsx
  4. 2
      src/app/[lang]/questions-list/[slug]/page.tsx
  5. 14
      src/app/[lang]/test-webview/page.tsx
  6. 7
      src/app/api/dev-storage/route.ts
  7. 10
      src/app/api/open-in-ide/route.ts
  8. 2
      src/app/api/proxy/route.ts
  9. 6
      src/app/api/remote-logs/route.ts
  10. 29
      src/app/api/remote-network/route.ts
  11. 2
      src/app/candidate-contact/page.tsx
  12. 8
      src/app/finding-match/page.tsx
  13. 21
      src/app/globals.css
  14. 21
      src/app/intro/page.tsx
  15. 5
      src/app/layout.tsx
  16. 57
      src/app/new-match/page.tsx
  17. 10
      src/app/new-match/profile/page.tsx
  18. 16
      src/app/providers.tsx
  19. 5
      src/app/questions-list/[slug]/answer-pace-sheet.tsx
  20. 54
      src/app/questions-list/[slug]/question-detail-client.tsx
  21. 2
      src/app/questions-list/[slug]/test-intro-client.tsx
  22. 29
      src/app/questions-list/page.tsx
  23. 59
      src/app/request-accepted/page.tsx
  24. 23
      src/app/request-sent/page.tsx
  25. 199
      src/components/dev/dev-click-to-component.tsx
  26. 74
      src/components/questions/progress-helper.ts
  27. 24
      src/components/questions/question-answer-storage.tsx
  28. 716
      src/components/questions/question-birthplace.tsx
  29. 2
      src/components/questions/question-card.tsx
  30. 25
      src/components/questions/question-date.tsx
  31. 69
      src/components/questions/question-dropdown.tsx
  32. 2
      src/components/questions/question-exit-navigation-button.tsx
  33. 18
      src/components/questions/question-file.tsx
  34. 33
      src/components/questions/question-number.tsx
  35. 352
      src/components/questions/question-phone.tsx
  36. 22
      src/components/questions/question-photo.tsx
  37. 2
      src/components/questions/question-progress-tracker.tsx
  38. 11
      src/components/questions/question-section-flow.tsx
  39. 8
      src/components/questions/question-slider.tsx
  40. 39
      src/components/questions/question-snap-list.tsx
  41. 11
      src/components/questions/question-text.tsx
  42. 15
      src/components/questions/question-title.tsx
  43. 19
      src/components/questions/required-steps-card.tsx
  44. 36
      src/components/questions/test-loading-screen.tsx
  45. 16
      src/components/questions/test-questions-flow.tsx
  46. 2
      src/components/sliders/slider-page.tsx
  47. 8
      src/components/sliders/slider-slide-four.tsx
  48. 24
      src/components/sliders/slider-slide-one.tsx
  49. 4
      src/components/sliders/slider-slide-two.tsx
  50. 4
      src/components/ui/button.tsx
  51. 9
      src/components/ui/female-consent-sheet.tsx
  52. 15
      src/components/ui/help-modal.tsx
  53. 8
      src/components/ui/navigation-button.tsx
  54. 45
      src/components/ui/report-actions-sheet.tsx
  55. 4
      src/components/ui/token-switcher.tsx
  56. 5
      src/components/utils/dev-click-to-component.tsx
  57. 5
      src/components/utils/fixed-bottom.tsx
  58. 2024
      src/data/cattell-fallback.ts
  59. 401
      src/data/countries.ts
  60. 72
      src/data/glasser-fallback.ts
  61. 157
      src/data/languages.ts
  62. 283
      src/data/nationalities.ts
  63. 10
      src/data/question-data.ts
  64. 660
      src/data/questions/en.json
  65. 660
      src/data/questions/fa.json
  66. 14
      src/hooks/marriage/query-keys.ts
  67. 10
      src/hooks/marriage/types.ts
  68. 3
      src/hooks/marriage/use-section-data.ts
  69. 6
      src/hooks/use-close-service-on-back.ts
  70. 114
      src/hooks/useFlutterBridge.ts
  71. 17
      src/lib/auth-bridge.ts
  72. 6
      src/lib/http.ts
  73. 24
      src/lib/performance.ts
  74. 26
      src/lib/view-paddings.ts
  75. 4
      src/plugins/jsx-locator-loader.cjs
  76. 5
      src/translations/dictionaries.ts
  77. 4
      src/translations/locales/ar.json
  78. 4
      src/translations/locales/az.json
  79. 4
      src/translations/locales/bn.json
  80. 4
      src/translations/locales/da.json
  81. 4
      src/translations/locales/de.json
  82. 4
      src/translations/locales/en.json
  83. 4
      src/translations/locales/es.json
  84. 4
      src/translations/locales/fa.json
  85. 4
      src/translations/locales/fr.json
  86. 4
      src/translations/locales/gu.json
  87. 4
      src/translations/locales/ha.json
  88. 4
      src/translations/locales/he.json
  89. 4
      src/translations/locales/hi.json
  90. 4
      src/translations/locales/id.json
  91. 4
      src/translations/locales/ks.json
  92. 4
      src/translations/locales/pt.json
  93. 4
      src/translations/locales/ru.json
  94. 4
      src/translations/locales/sw.json
  95. 4
      src/translations/locales/tg.json
  96. 4
      src/translations/locales/tr.json
  97. 4
      src/translations/locales/ul.json
  98. 4
      src/translations/locales/ur.json
  99. 4
      src/translations/locales/uz.json
  100. 4
      src/translations/locales/zh.json

8
next.config.ts

@ -28,7 +28,10 @@ const nextConfig: NextConfig = {
exclude: /node_modules/,
use: [
{
loader: path.resolve(__dirname, "src/plugins/jsx-locator-loader.cjs"),
loader: path.resolve(
__dirname,
"src/plugins/jsx-locator-loader.cjs",
),
},
],
});
@ -45,7 +48,8 @@ const nextConfig: NextConfig = {
headers: [
{
key: "Cache-Control",
value: "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
value:
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
},
{
key: "Pragma",

22668
public/openapi.json
File diff suppressed because it is too large
View File

4
src/app/[lang]/layout.tsx

@ -22,9 +22,7 @@ export default async function LocaleLayout({
return (
<I18nProvider locale={lang}>
<div dir={localeDirections[lang]}>
{children}
</div>
<div dir={localeDirections[lang]}>{children}</div>
</I18nProvider>
);
}

2
src/app/[lang]/questions-list/[slug]/page.tsx

@ -1,6 +1,6 @@
import QuestionDetailPage from "@/app/questions-list/[slug]/page";
import { getQuestionListItems } from "@/data/question-data";
import { locales } from "@/translations/config";
import QuestionDetailPage from "@/app/questions-list/[slug]/page";
export function generateStaticParams() {
return locales.flatMap((lang) =>

14
src/app/[lang]/test-webview/page.tsx

@ -1,8 +1,8 @@
"use client";
import { useState, useEffect } from "react";
import { useFlutterBridge } from "@/hooks/useFlutterBridge";
import { useEffect, useState } from "react";
import Button from "@/components/ui/button";
import { useFlutterBridge } from "@/hooks/useFlutterBridge";
export default function TestWebViewPage() {
const { sendToFlutter, logs, lastEvent, events, isReady } = useFlutterBridge({
@ -24,7 +24,7 @@ export default function TestWebViewPage() {
window.dispatchEvent(
new CustomEvent("flutterEvent", {
detail: { type, payload, timestamp: Date.now() },
})
}),
);
};
@ -183,7 +183,7 @@ export default function TestWebViewPage() {
</div>
<div className="text-gray-400 mt-2 text-[10px]">
{new Date(lastEvent.timestamp || Date.now()).toLocaleString(
"fa-IR"
"fa-IR",
)}
</div>
</div>
@ -289,9 +289,9 @@ export default function TestWebViewPage() {
{event.type}
</span>
<span className="text-xs text-gray-500">
{new Date(event.timestamp || Date.now()).toLocaleTimeString(
"fa-IR"
)}
{new Date(
event.timestamp || Date.now(),
).toLocaleTimeString("fa-IR")}
</span>
</div>
<pre className="font-mono text-xs text-gray-600 whitespace-pre-wrap break-all">

7
src/app/api/dev-storage/route.ts

@ -1,4 +1,4 @@
import { NextRequest } from "next/server";
import type { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@ -10,7 +10,10 @@ export async function POST(request: NextRequest) {
try {
const data = await request.json();
console.log("\x1b[35m💾 [DEV STORAGE VALUES]\x1b[0m", JSON.stringify(data, null, 2));
console.log(
"\x1b[35m💾 [DEV STORAGE VALUES]\x1b[0m",
JSON.stringify(data, null, 2),
);
return Response.json({ success: true });
} catch (error) {
console.error("Failed to process dev storage request:", error);

10
src/app/api/open-in-ide/route.ts

@ -1,5 +1,5 @@
import { NextRequest } from "next/server";
import { exec } from "child_process";
import type { NextRequest } from "next/server";
import os from "os";
export const dynamic = "force-dynamic";
@ -23,11 +23,15 @@ export async function POST(request: NextRequest) {
exec(command, (error) => {
if (error) {
console.error(`[open-in-ide] Primary CLI command failed: ${error.message}. Trying URL scheme fallback...`);
console.error(
`[open-in-ide] Primary CLI command failed: ${error.message}. Trying URL scheme fallback...`,
);
const fallbackCmd = `start "" "antigravity://file/${locator}"`;
exec(fallbackCmd, (fallbackErr) => {
if (fallbackErr) {
console.error(`[open-in-ide] Fallback failed: ${fallbackErr.message}`);
console.error(
`[open-in-ide] Fallback failed: ${fallbackErr.message}`,
);
}
});
}

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

@ -303,8 +303,6 @@ async function proxyRequest(request: NextRequest) {
logProxyResponse(upstreamResponse, responseBody);
const responseHeaders = getResponseHeaders(upstreamResponse.headers);
// Force no caching on API proxy responses – always fetch fresh data

6
src/app/api/remote-logs/route.ts

@ -1,4 +1,4 @@
import { NextRequest } from "next/server";
import type { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@ -15,8 +15,8 @@ export async function POST(request: NextRequest) {
type === "error"
? "\x1b[31m📱 [MOBILE ERROR]\x1b[0m" // Red color in terminal
: type === "warn"
? "\x1b[33m📱 [MOBILE WARN]\x1b[0m" // Yellow color in terminal
: "\x1b[36m📱 [MOBILE LOG]\x1b[0m"; // Cyan color in terminal
? "\x1b[33m📱 [MOBILE WARN]\x1b[0m" // Yellow color in terminal
: "\x1b[36m📱 [MOBILE LOG]\x1b[0m"; // Cyan color in terminal
const message = args
.map((arg: any) => {

29
src/app/api/remote-network/route.ts

@ -1,4 +1,4 @@
import { NextRequest } from "next/server";
import type { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@ -9,7 +9,8 @@ export async function POST(request: NextRequest) {
}
try {
const { method, url, status, requestBody, responseBody, duration } = await request.json();
const { method, url, status, requestBody, responseBody, duration } =
await request.json();
const statusColor =
status >= 500
@ -23,12 +24,20 @@ export async function POST(request: NextRequest) {
const resetColor = "\x1b[0m";
const headerPrefix = "\x1b[35m📱 🌐 [HTTP]\x1b[0m"; // Magenta
console.log(`\n${headerPrefix} ${method} ${url} -> ${statusColor}${status}${resetColor} (${duration}ms)`);
console.log(
`\n${headerPrefix} ${method} ${url} -> ${statusColor}${status}${resetColor} (${duration}ms)`,
);
if (requestBody) {
try {
const parsedReq = typeof requestBody === "string" ? JSON.parse(requestBody) : requestBody;
console.log(` \x1b[90mRequest Body:\x1b[0m`, JSON.stringify(parsedReq, null, 2));
const parsedReq =
typeof requestBody === "string"
? JSON.parse(requestBody)
: requestBody;
console.log(
` \x1b[90mRequest Body:\x1b[0m`,
JSON.stringify(parsedReq, null, 2),
);
} catch {
console.log(` \x1b[90mRequest Body:\x1b[0m`, requestBody);
}
@ -36,8 +45,14 @@ export async function POST(request: NextRequest) {
if (responseBody) {
try {
const parsedRes = typeof responseBody === "string" ? JSON.parse(responseBody) : responseBody;
console.log(` \x1b[90mResponse Body:\x1b[0m`, JSON.stringify(parsedRes, null, 2));
const parsedRes =
typeof responseBody === "string"
? JSON.parse(responseBody)
: responseBody;
console.log(
` \x1b[90mResponse Body:\x1b[0m`,
JSON.stringify(parsedRes, null, 2),
);
} catch {
console.log(` \x1b[90mResponse Body:\x1b[0m`, responseBody);
}

2
src/app/candidate-contact/page.tsx

@ -50,7 +50,7 @@ export default function CandidateContactPage() {
</div>
</section>
<section
<section
style={{ paddingBottom: `calc(2rem + var(--safe-bottom))` }}
className="fixed right-0 bottom-0 left-0 z-20 mx-auto flex w-full sm:max-w-[375px] gap-3 rounded-t-[30px] bg-white px-[17px] pt-6 shadow-[0_-18px_50px_rgba(15,23,42,0.08)]"
>

8
src/app/finding-match/page.tsx

@ -2,16 +2,16 @@
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { FaPen } from "react-icons/fa6";
import AdvisorActionsCard from "@/components/ui/advisor-actions-card";
import NavigationButton from "@/components/ui/navigation-button";
import { PageBackground } from "@/components/utils/page-background";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import { getSubmitPath } from "@/lib/get-submit-path";
const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
@ -46,7 +46,7 @@ export default function FindingMatchPage() {
<>
<PageBackground />
<main
<main
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }}
className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(16px,calc(var(--safe-top)+8px))] text-center"
>

21
src/app/globals.css

@ -11,11 +11,11 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-sans: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
--font-mono: var(--font-geist-mono);
--font-arabic: var(--font-amiri);
--font-ryling: var(--font-amiri);
--font-faminela: var(--font-faminela-local);
--font-arabic: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
--font-ryling: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
--font-faminela: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
/* Typography Configuration - Centralized Design System Scale */
@ -66,12 +66,12 @@ body {
html:lang(ar) body,
:lang(ar) {
font-family: var(--font-arabic), "Times New Roman", serif;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
.font-arabic,
.font-ryling {
font-family: var(--font-arabic), "Times New Roman", serif;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
.font-faminela:lang(ar),
@ -79,8 +79,9 @@ html:lang(ar) body,
[dir="rtl"] .font-faminela,
[dir="rtl"] .font-ryling,
[dir="rtl"].font-faminela,
[dir="rtl"].font-ryling {
font-family: var(--font-arabic), "Times New Roman", serif;
[dir="rtl"].font-ryling,
.font-faminela {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
.app-shell {
@ -163,7 +164,9 @@ body[data-page-background="custom"] .app-shell {
/* 3 Dots Bouncing Line Circle Loader for Buttons */
@keyframes button-dot-bounce {
0%, 80%, 100% {
0%,
80%,
100% {
transform: translateY(0) scale(0.7);
opacity: 0.4;
}

21
src/app/intro/page.tsx

@ -1,17 +1,17 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import Button from "@/components/ui/button";
import NavigationButton from "@/components/ui/navigation-button";
import ReportActionsSheet from "@/components/ui/report-actions-sheet";
import { authBridge } from "@/lib/auth-bridge";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import type { MarriageProfileResponse } from "@/hooks/marriage/types";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { authBridge } from "@/lib/auth-bridge";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import { getSubmitPath } from "@/lib/get-submit-path";
const REDIRECT_SESSION_KEY = "redirect";
@ -104,7 +104,10 @@ export default function Intro() {
const { data: freshProfile } = await refetch();
profileResponse = freshProfile ?? profile;
} catch (refetchError) {
console.warn("Could not refetch profile data – using fallback", refetchError);
console.warn(
"Could not refetch profile data – using fallback",
refetchError,
);
}
const nextPath = localizePath(getSubmitPath(profileResponse), locale);
@ -210,11 +213,15 @@ export default function Intro() {
/>
</div>
<div className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]">
<div
<div
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }}
className="pointer-events-auto px-[17px] pt-3"
>
<Button onClick={handleSubmit} disabled={isSubmitting} isLoading={isSubmitting}>
<Button
onClick={handleSubmit}
disabled={isSubmitting}
isLoading={isSubmitting}
>
{t.common.submit}
</Button>
</div>

5
src/app/layout.tsx

@ -169,7 +169,10 @@ export default function RootLayout({
}}
/>
</head>
<body className={`${faminela.variable} ${amiri.variable}`} suppressHydrationWarning>
<body
className={`${faminela.variable} ${amiri.variable}`}
suppressHydrationWarning
>
<Providers>
{isDevelopment ? (
<div className="fixed top-3 left-3 z-[9999] pointer-events-auto">

57
src/app/new-match/page.tsx

@ -2,14 +2,12 @@
import Image from "next/image";
import Link from "next/link";
import { useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { localizePath } from "@/translations/config";
import { getSubmitPath } from "@/lib/get-submit-path";
import { useEffect, useMemo } from "react";
import { FaLock } from "react-icons/fa6";
import AdvisorActionsCard from "@/components/ui/advisor-actions-card";
import NavigationButton from "@/components/ui/navigation-button";
import { DotsLoader } from "@/components/ui/button";
import NavigationButton from "@/components/ui/navigation-button";
import { PageBackground } from "@/components/utils/page-background";
import type {
MarriageField,
@ -18,8 +16,10 @@ import type {
MarriagePhoneFieldValue,
} from "@/hooks/marriage/types";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useI18n } from "@/translations/provider";
import { useViewPaddings } from "@/hooks/use-view-paddings";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
@ -29,10 +29,30 @@ const advisorAvatars = [
const fieldCandidates = {
name: ["name", "full_name", "fullname", "first_name", "display_name"],
occupation: ["occupation", "job", "profession", "career", "work", "education", "highest_level_of_education"],
occupation: [
"occupation",
"job",
"profession",
"career",
"work",
"education",
"highest_level_of_education",
],
age: ["age"],
city: ["city", "current_city", "residence_city", "location", "residence", "birth_city"],
maritalStatus: ["marital_status", "maritalstatus", "relationship_status", "current_marital_status"],
city: [
"city",
"current_city",
"residence_city",
"location",
"residence",
"birth_city",
],
maritalStatus: [
"marital_status",
"maritalstatus",
"relationship_status",
"current_marital_status",
],
cityPreference: [
"city_preference",
"citypreference",
@ -222,7 +242,10 @@ export default function NewMatchPage() {
<PageBackground />
<main
style={{ paddingBottom: `${20 + bottom}px`, paddingTop: `${Math.max(12, top + 4)}px` }}
style={{
paddingBottom: `${20 + bottom}px`,
paddingTop: `${Math.max(12, top + 4)}px`,
}}
className="-mx-[17px] min-h-screen h-full text-center px-4"
>
<header className="flex items-center justify-between pb-3">
@ -244,7 +267,9 @@ export default function NewMatchPage() {
alt="notification"
/>
</div>
<h2 className="text-[22px] font-bold mt-3.5">{matchHeadingTitle}</h2>
<h2 className="text-[22px] font-bold mt-3.5">
{matchHeadingTitle}
</h2>
<p className="mt-[11px] max-w-[322px] text-[12px] leading-[1.35] font-semibold text-[#7C7C7C]">
{matchHeadingDescription}
</p>
@ -316,13 +341,13 @@ export default function NewMatchPage() {
getAdvisorHref="/questions-list"
/>
<div className="flex cursor-not-allowed w-full items-center justify-center rounded-[11px] border-none bg-[#DBDBDB] px-6 py-3.5 text-[#747474] no-underline shadow-none">
<span className="flex items-center gap-1">
<FaLock className="size-6 shrink-0 text-[#747474]" />
<span className="leading-none font-semibold tracking-[-0.03em]">
Profile locked
</span>
<div className="flex cursor-not-allowed w-full items-center justify-center rounded-[11px] border-none bg-[#DBDBDB] px-6 py-3.5 text-[#747474] no-underline shadow-none">
<span className="flex items-center gap-1">
<FaLock className="size-6 shrink-0 text-[#747474]" />
<span className="leading-none font-semibold tracking-[-0.03em]">
Profile locked
</span>
</span>
</div>
</div>
</div>

10
src/app/new-match/profile/page.tsx

@ -221,7 +221,11 @@ export default function NewMatchProfilePage() {
const [isRejectSheetOpen, setIsRejectSheetOpen] = useState(false);
const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] =
useState(false);
const { data: profile, isLoading, refetch: refetchProfile } = useMarriageProfileQuery();
const {
data: profile,
isLoading,
refetch: refetchProfile,
} = useMarriageProfileQuery();
useEffect(() => {
if (!profile) {
@ -361,7 +365,9 @@ export default function NewMatchProfilePage() {
</Button>
<Button
className="py-[18px]"
disabled={!isAcceptProfileEnabled || respondMutation.isPending}
disabled={
!isAcceptProfileEnabled || respondMutation.isPending
}
isLoading={respondMutation.isPending}
onClick={async () => {
close();

16
src/app/providers.tsx

@ -1,7 +1,11 @@
"use client";
import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useState, useEffect } from "react";
import {
QueryClient,
QueryClientProvider,
useQueryClient,
} from "@tanstack/react-query";
import { type ReactNode, useEffect, useState } from "react";
import { ViewPaddingsProvider } from "@/components/utils/view-paddings-provider";
function AppFocusReloader({ children }: { children: ReactNode }) {
@ -33,9 +37,10 @@ function AppFocusReloader({ children }: { children: ReactNode }) {
// Listen for Flutter response events if available
const win = window as any;
const unbindFlutter = typeof win.addFlutterResponseListener === "function"
? win.addFlutterResponseListener(() => handleReload())
: null;
const unbindFlutter =
typeof win.addFlutterResponseListener === "function"
? win.addFlutterResponseListener(() => handleReload())
: null;
return () => {
window.removeEventListener("focus", handleReload);
@ -80,4 +85,3 @@ export default function Providers({ children }: ProvidersProps) {
</QueryClientProvider>
);
}

5
src/app/questions-list/[slug]/answer-pace-sheet.tsx

@ -6,7 +6,10 @@ import {
hasQuestionAnswerValue,
} from "@/components/questions/question-answer-storage";
import InformationSheet from "@/components/ui/information-sheet";
import type { MarriageField, MarriagePhoneFieldValue } from "@/hooks/marriage/types";
import type {
MarriageField,
MarriagePhoneFieldValue,
} from "@/hooks/marriage/types";
import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
type AnswerPaceSheetProps = {

54
src/app/questions-list/[slug]/question-detail-client.tsx

@ -7,9 +7,9 @@ import {
QuestionAnswersProvider,
useQuestionAnswers,
} from "@/components/questions/question-answer-storage";
import QuestionBirthplace from "@/components/questions/question-birthplace";
import QuestionButton from "@/components/questions/question-button";
import { DotsLoader } from "@/components/ui/button";
import { QuestionCheckbox } from "@/components/questions/question-checkbox";
import QuestionCheckbox from "@/components/questions/question-checkbox";
import QuestionDate from "@/components/questions/question-date";
import QuestionDropdown from "@/components/questions/question-dropdown";
import QuestionExitNavigationButton from "@/components/questions/question-exit-navigation-button";
@ -21,9 +21,16 @@ import QuestionRadio from "@/components/questions/question-radio";
import QuestionSectionFlow from "@/components/questions/question-section-flow";
import QuestionSlider from "@/components/questions/question-slider";
import QuestionText from "@/components/questions/question-text";
import TestIntroPage from "@/components/questions/test-intro-page";
import TestQuestionsFlow, {
type TestQuestion,
} from "@/components/questions/test-questions-flow";
import { DotsLoader } from "@/components/ui/button";
import NavigationButton from "@/components/ui/navigation-button";
import StickyHeader from "@/components/ui/sticky-header";
import { PageBackground } from "@/components/utils/page-background";
import { cattellFallbackQuestions } from "@/data/cattell-fallback";
import { glasserFallbackQuestions } from "@/data/glasser-fallback";
import {
getQuestionListItemBySlug,
isQuestionListItemVisibleForProfile,
@ -32,9 +39,6 @@ import {
type QuestionField,
} from "@/data/question-data";
import type { MarriageGender } from "@/hooks/marriage/types";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { defaultLocale, type Locale } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import {
useCattellQuestionsQuery,
useSubmitCattellAssessmentMutation,
@ -43,10 +47,9 @@ import {
useGlasserQuestionsQuery,
useSubmitGlasserAssessmentMutation,
} from "@/hooks/marriage/use-glasser";
import { cattellFallbackQuestions } from "@/data/cattell-fallback";
import { glasserFallbackQuestions } from "@/data/glasser-fallback";
import TestIntroPage from "@/components/questions/test-intro-page";
import TestQuestionsFlow, { type TestQuestion } from "@/components/questions/test-questions-flow";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { defaultLocale, type Locale } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import AnswerPaceSheet from "./answer-pace-sheet";
type QuestionDetailClientProps = {
@ -180,6 +183,14 @@ function renderQuestion(
: undefined;
switch (question.type) {
case "birthplace":
return (
<QuestionBirthplace
question={question}
questionIndex={questionIndex}
disabled={disabled}
/>
);
case "button":
return (
<QuestionButton
@ -348,6 +359,11 @@ function QuestionFlowWrapper({
if (isEmailQuestion) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
isAnswered = emailRegex.test(String(answer).trim());
} else if (question.type === "birthplace") {
const strVal = String(answer);
const parts = strVal.split(",").map((p) => p.trim());
isAnswered =
parts.length >= 2 && parts[0].length > 0 && parts[1].length > 0;
}
}
@ -387,7 +403,8 @@ export default function QuestionDetailClient({
const router = useRouter();
const { dictionary: t } = useI18n();
const [isTestStarted, setIsTestStarted] = useState(false);
const { data: profile, isLoading: isProfileLoading } = useMarriageProfileQuery();
const { data: profile, isLoading: isProfileLoading } =
useMarriageProfileQuery();
const profileGender = profile?.gender;
const age = getStoredAge();
const item = getQuestionListItemBySlug(itemSlug, locale);
@ -457,7 +474,12 @@ export default function QuestionDetailClient({
return questionsList.map((q) => ({
id: q.question_number,
text: q.text,
info: "factor" in q ? (q.factor as string) : ("factor_code" in q ? (q.factor_code as string) : undefined),
info:
"factor" in q
? (q.factor as string)
: "factor_code" in q
? (q.factor_code as string)
: undefined,
options: defaultGlasserOptions,
}));
}, [glasserQuery.data, locale]);
@ -513,7 +535,10 @@ export default function QuestionDetailClient({
</main>
</>
);
} else if (!item || !isQuestionListItemVisibleForProfile(item, profileContext)) {
} else if (
!item ||
!isQuestionListItemVisibleForProfile(item, profileContext)
) {
return null;
}
@ -637,7 +662,8 @@ export default function QuestionDetailClient({
);
}
const bulletKey = item.slug === "glasser_5_needs_test" ? "glasser" : "personality";
const bulletKey =
item.slug === "glasser_5_needs_test" ? "glasser" : "personality";
const bullets = t.questions.testIntroBullets[bulletKey];
return (
@ -684,7 +710,6 @@ export default function QuestionDetailClient({
);
}
const dobQuestion = visibleQuestions.find(
(question) => question.title === "Date of Birth",
);
@ -743,4 +768,3 @@ export default function QuestionDetailClient({
</>
);
}

2
src/app/questions-list/[slug]/test-intro-client.tsx

@ -1,10 +1,10 @@
"use client";
import { useRouter } from "next/navigation";
import TestIntroPage from "@/components/questions/test-intro-page";
import NavigationButton from "@/components/ui/navigation-button";
import StickyHeader from "@/components/ui/sticky-header";
import { PageBackground } from "@/components/utils/page-background";
import TestIntroPage from "@/components/questions/test-intro-page";
type TestIntroClientProps = {
title: string;

29
src/app/questions-list/page.tsx

@ -4,6 +4,11 @@ import Image from "next/image";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { IoClose } from "react-icons/io5";
import {
getLocalSectionProgress,
getStoredAge,
} from "@/components/questions/progress-helper";
import { hasQuestionAnswerValue } from "@/components/questions/question-answer-storage";
import QuestionCard from "@/components/questions/question-card";
import RequiredStepsCard from "@/components/questions/required-steps-card";
import Button from "@/components/ui/button";
@ -17,20 +22,18 @@ import {
isQuestionVisibleForProfile,
type QuestionListItem,
} from "@/data/question-data";
import { hasQuestionAnswerValue } from "@/components/questions/question-answer-storage";
import { toFrontendSlug } from "@/data/section-slug-map";
import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back";
import {
clearMatchStartGrace,
markMatchStarted,
} from "@/lib/match-start-grace";
import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import { toFrontendSlug } from "@/data/section-slug-map";
import SectionsRequest from "./sections-request";
import { getStoredAge, getLocalSectionProgress } from "@/components/questions/progress-helper";
import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back";
export default function QuestionsListPage() {
useCloseServiceOnBack();
@ -70,7 +73,10 @@ export default function QuestionsListPage() {
sections?.forEach((section) => {
const frontendSlug = toFrontendSlug(section.slug);
const progress = Math.max(0, Math.min(100, Math.round(section.completion_percent)));
const progress = Math.max(
0,
Math.min(100, Math.round(section.completion_percent)),
);
progressBySlug.set(frontendSlug, progress);
progressBySlug.set(section.slug, progress);
});
@ -106,13 +112,12 @@ export default function QuestionsListPage() {
const isProfileBlocked =
profileStatus === "in_case" || profileStatus === "matched";
const canStartMatch =
!isProfileSuspended &&
!isProfileBlocked &&
allRequiredSectionsCompleted;
!isProfileSuspended && !isProfileBlocked && allRequiredSectionsCompleted;
const isStartMatchDisabled = startMatchMutation.isPending || !canStartMatch;
const hasIncompleteOptionalSections = useMemo(() => {
return questionListItems.some(
(item) => !item.required && (sectionProgressBySlug.get(item.slug) ?? 0) < 100,
(item) =>
!item.required && (sectionProgressBySlug.get(item.slug) ?? 0) < 100,
);
}, [questionListItems, sectionProgressBySlug]);

59
src/app/request-accepted/page.tsx

@ -4,15 +4,17 @@ import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { getSubmitPath } from "@/lib/get-submit-path";
import { FiCopy, FiPhone } from "react-icons/fi";
import { DotsLoader } from "@/components/ui/button";
import CallResultSheet from "@/components/ui/call-result-sheet";
import FemaleConsentSheet from "@/components/ui/female-consent-sheet";
import NavigationButton from "@/components/ui/navigation-button";
import SubscriptionRequiredSheet from "@/components/ui/subscription-required-sheet";
import { DotsLoader } from "@/components/ui/button";
import { PageBackground } from "@/components/utils/page-background";
import type { MarriageField, MarriagePhoneFieldValue } from "@/hooks/marriage/types";
import type {
MarriageField,
MarriagePhoneFieldValue,
} from "@/hooks/marriage/types";
import { useMarriageContactInfoQuery } from "@/hooks/marriage/use-contact-info";
import { useSubmitMarriageContactStatusMutation } from "@/hooks/marriage/use-contact-status";
import {
@ -20,6 +22,7 @@ import {
useHabcoinPaymentMutation,
} from "@/hooks/marriage/use-habcoin-payment";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
@ -99,27 +102,37 @@ function ContactInfoPhoneCard({ item }: { item: ContactInfoPhoneItem }) {
<div className="flex items-center justify-between rounded-[15px] border border-[#E4E4E4] bg-[#FBFBFB] px-4 py-3.5 shadow-[0_4px_12px_rgba(0,0,0,0.03)]">
<div className="text-left">
<p className="text-xs font-semibold text-[#8F8F8F]">{item.label}</p>
<p className="text-base font-bold text-[#1F1F1F] dir-ltr">{item.phoneNumber}</p>
<p className="text-base font-bold text-[#1F1F1F] dir-ltr">
{item.phoneNumber}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
navigator.clipboard
.writeText(item.phoneNumber)
.catch(() => {});
navigator.clipboard.writeText(item.phoneNumber).catch(() => {});
}}
className="inline-flex p-3 items-center justify-center rounded-[10px] bg-[#F0445B] text-white"
>
<Image src={"/assets/images/Frame 2095586fdas679.svg"} width={16} height={16} alt="copy"/>
<Image
src={"/assets/images/Frame 2095586fdas679.svg"}
width={16}
height={16}
alt="copy"
/>
</button>
<a
href={`tel:${item.phoneNumber}`}
className="inline-flex p-3 items-center justify-center rounded-[10px] bg-[#F0445B] text-white"
>
<Image src={"/assets/images/Vecfdastor.svg"} width={16} height={16} alt="copy"/>
<Image
src={"/assets/images/Vecfdastor.svg"}
width={16}
height={16}
alt="copy"
/>
</a>
</div>
</div>
@ -152,11 +165,14 @@ export default function RequestAcceptedPage() {
const caseStatus = profile?.active_case?.status;
const recommendedPlanId = profile?.recommended_plan?.id;
const paymentMutation = useHabcoinPaymentMutation();
const contactStatusMutation = useSubmitMarriageContactStatusMutation(caseId ?? "", {
onSuccess: () => {
router.push(localizePath("/finding-match", locale));
const contactStatusMutation = useSubmitMarriageContactStatusMutation(
caseId ?? "",
{
onSuccess: () => {
router.push(localizePath("/finding-match", locale));
},
},
});
);
const contactInfoQuery = useMarriageContactInfoQuery(caseId, {
enabled: false,
});
@ -231,7 +247,8 @@ export default function RequestAcceptedPage() {
if (!caseId || contactStatusMutation.isPending) return;
await contactStatusMutation.mutateAsync({
action: "no_contact",
custom_note: "No contact reported by female candidate after decision window",
custom_note:
"No contact reported by female candidate after decision window",
});
};
@ -282,7 +299,7 @@ export default function RequestAcceptedPage() {
/>
) : null}
<main
<main
style={{ paddingBottom: "calc(40px + var(--safe-bottom))" }}
className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(16px,calc(var(--safe-top)+8px))] text-center"
>
@ -323,7 +340,11 @@ export default function RequestAcceptedPage() {
className="max-w-[212px] cursor-pointer appearance-none border-0 bg-transparent p-0 text-left"
>
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C] hover:bg-[#EBEBEB] transition-colors flex items-center justify-center min-h-[38px]">
{contactStatusMutation.isPending ? <DotsLoader /> : primaryActionText}
{contactStatusMutation.isPending ? (
<DotsLoader />
) : (
primaryActionText
)}
</div>
</button>
) : (
@ -343,7 +364,11 @@ export default function RequestAcceptedPage() {
className="max-w-[212px] appearance-none border-0 bg-transparent p-0 text-left"
>
<div className="bg-linear-180 from-[#FE6F82] to-[#E03950] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#fff] shadow-md shadow-[#F2596E]/60 flex items-center justify-center min-h-[38px]">
{paymentMutation.isPending ? <DotsLoader /> : secondaryActionText}
{paymentMutation.isPending ? (
<DotsLoader />
) : (
secondaryActionText
)}
</div>
</button>
</div>

23
src/app/request-sent/page.tsx

@ -1,17 +1,16 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import AdvisorActionsCard from "@/components/ui/advisor-actions-card";
import NavigationButton from "@/components/ui/navigation-button";
import { PageBackground } from "@/components/utils/page-background";
import { useI18n } from "@/translations/provider";
import Link from "next/link";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { localizePath } from "@/translations/config";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
@ -39,7 +38,8 @@ export default function RequestSentPage() {
const copy = t.findingMatch;
const requestSentCopy = (t as any).requestSent ?? {
title: "درخواست ارسال شد",
description: "درخواست معرفی شما از طرف ما به خانواده طرف مقابل ارسال شد. در صورت تایید ایشان، اطلاعات تماس به اطلاع شما خواهد رسید.",
description:
"درخواست معرفی شما از طرف ما به خانواده طرف مقابل ارسال شد. در صورت تایید ایشان، اطلاعات تماس به اطلاع شما خواهد رسید.",
matchProfile: "مشاهده پروفایل گزینه",
profileLocked: "پروفایل قفل است",
};
@ -48,7 +48,7 @@ export default function RequestSentPage() {
<>
<PageBackground />
<main
<main
style={{ paddingBottom: "calc(40px + var(--safe-bottom))" }}
className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(16px,calc(var(--safe-top)+8px))] text-center"
>
@ -79,11 +79,14 @@ export default function RequestSentPage() {
{requestSentCopy.description}
</p>
<Link href={"/new-match/profile"} className="mt-9 w-full max-w-[212px]">
<Link
href={"/new-match/profile"}
className="mt-9 w-full max-w-[212px]"
>
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C]">
{requestSentCopy.matchProfile}
</div>
</Link >
</Link>
</section>
<div className="space-y-8">

199
src/components/dev/dev-click-to-component.tsx

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useEffect, useRef, useState } from "react";
const IDE_SCHEMES = [
{
@ -48,10 +48,18 @@ function parseLocator(locator: string) {
export function DevClickToComponent() {
const [isInspecting, setIsInspecting] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(
null,
);
const [isDragging, setIsDragging] = useState(false);
const dragStart = useRef({ x: 0, y: 0, buttonX: 0, buttonY: 0, hasMoved: false });
const dragStart = useRef({
x: 0,
y: 0,
buttonX: 0,
buttonY: 0,
hasMoved: false,
});
// Detect mobile size
useEffect(() => {
@ -127,13 +135,12 @@ export function DevClickToComponent() {
body: JSON.stringify({ locator: locatorString, userAgent }),
}).catch((err) => {
console.error("[DevClickToComponent] Error sending API request:", err);
// Fallback to URL scheme
const ideUrl =
IDE_SCHEMES.find(({ matches }) =>
matches.some((match) => userAgent.includes(match)),
)?.createUrl(locatorString) ??
`antigravity://file/${locatorString}`;
)?.createUrl(locatorString) ?? `antigravity://file/${locatorString}`;
try {
window.location.href = ideUrl;
@ -172,7 +179,10 @@ export function DevClickToComponent() {
?.getAttribute("data-locator");
if (locator) {
console.log(`[DevClickToComponent] Inspect Mode matched element. Opening in IDE:`, locator);
console.log(
`[DevClickToComponent] Inspect Mode matched element. Opening in IDE:`,
locator,
);
// Flash target element outline briefly as visual feedback
const element = target.closest<HTMLElement>("[data-locator]");
@ -193,7 +203,10 @@ export function DevClickToComponent() {
body: JSON.stringify({ locator, userAgent }),
});
} catch (err) {
console.error("[DevClickToComponent] Error sending API request:", err);
console.error(
"[DevClickToComponent] Error sending API request:",
err,
);
}
}
@ -202,7 +215,7 @@ export function DevClickToComponent() {
// Use capturing phase to intercept clicks/taps before they trigger other actions
document.addEventListener("click", handleInspectClick, true);
return () => {
document.removeEventListener("click", handleInspectClick, true);
};
@ -229,7 +242,11 @@ export function DevClickToComponent() {
body: JSON.stringify({ type, args }),
});
} catch (err) {
originalError.call(console, "[DevClickToComponent] Failed to forward log to server:", err);
originalError.call(
console,
"[DevClickToComponent] Failed to forward log to server:",
err,
);
} finally {
isSending = false;
}
@ -280,7 +297,8 @@ export function DevClickToComponent() {
return originalFetch(input, init);
}
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
const method =
init?.method ?? (input instanceof Request ? input.method : "GET");
const startTime = Date.now();
let requestBody: string | null = null;
@ -360,7 +378,12 @@ export function DevClickToComponent() {
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (this: any, method: any, url: any, ...args: any[]) {
XMLHttpRequest.prototype.open = function (
this: any,
method: any,
url: any,
...args: any[]
) {
this._method = method;
this._url = typeof url === "string" ? url : url.toString();
return originalOpen.apply(this, [method, url, ...args] as any);
@ -384,7 +407,7 @@ export function DevClickToComponent() {
const duration = Date.now() - startTime;
const status = this.status;
let responseBody = "";
try {
responseBody = this.responseText;
} catch {
@ -408,20 +431,22 @@ export function DevClickToComponent() {
}
}
window.fetch("/api/remote-network", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
method,
url,
status,
requestBody,
responseBody,
duration,
}),
}).catch(() => {});
window
.fetch("/api/remote-network", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
method,
url,
status,
requestBody,
responseBody,
duration,
}),
})
.catch(() => {});
});
return originalSend.apply(this, [body]);
@ -437,8 +462,8 @@ export function DevClickToComponent() {
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
setIsDragging(true);
const startX = position?.x ?? (window.innerWidth - 66);
const startY = position?.y ?? (window.innerHeight - 126);
const startX = position?.x ?? window.innerWidth - 66;
const startY = position?.y ?? window.innerHeight - 126;
dragStart.current = {
x: e.clientX,
y: e.clientY,
@ -452,13 +477,19 @@ export function DevClickToComponent() {
if (!isDragging) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
dragStart.current.hasMoved = true;
}
const newX = Math.max(16, Math.min(window.innerWidth - 66, dragStart.current.buttonX + dx));
const newY = Math.max(16, Math.min(window.innerHeight - 126, dragStart.current.buttonY + dy));
const newX = Math.max(
16,
Math.min(window.innerWidth - 66, dragStart.current.buttonX + dx),
);
const newY = Math.max(
16,
Math.min(window.innerHeight - 126, dragStart.current.buttonY + dy),
);
setPosition({ x: newX, y: newY });
};
@ -481,7 +512,7 @@ export function DevClickToComponent() {
const handleSendStorageClick = async () => {
if (dragStart.current.hasMoved) return;
if (isSendingStorage) return;
setIsSendingStorage(true);
setSendSuccess(false);
@ -512,7 +543,10 @@ export function DevClickToComponent() {
setTimeout(() => setSendSuccess(false), 2000);
}
} catch (err) {
console.error("[DevClickToComponent] Failed to send localStorage values:", err);
console.error(
"[DevClickToComponent] Failed to send localStorage values:",
err,
);
} finally {
setIsSendingStorage(false);
}
@ -553,24 +587,65 @@ export function DevClickToComponent() {
alignItems: "center",
justifyContent: "center",
cursor: isDragging ? "grabbing" : "pointer",
transition: isDragging ? "none" : "background-color 0.25s, transform 0.25s, box-shadow 0.25s",
transition: isDragging
? "none"
: "background-color 0.25s, transform 0.25s, box-shadow 0.25s",
outline: "none",
transform: isSendingStorage ? "scale(0.95)" : "scale(1)",
}}
title="Send LocalStorage to Terminal API"
>
{isSendingStorage ? (
<svg className="animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
<svg
className="animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
style={{ width: "22px", height: "22px", pointerEvents: "none" }}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : sendSuccess ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2.5}
stroke="currentColor"
style={{ width: "22px", height: "22px", pointerEvents: "none" }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m4.5 12.75 6 6 9-13.5"
/>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 16.5V9.75m0 0 3 3m-3-3-3 3M6.75 19.5a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
style={{ width: "22px", height: "22px", pointerEvents: "none" }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 16.5V9.75m0 0 3 3m-3-3-3 3M6.75 19.5a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z"
/>
</svg>
)}
</button>
@ -590,26 +665,50 @@ export function DevClickToComponent() {
backgroundColor: isInspecting ? "#3b82f6" : "#1f2937",
color: "#ffffff",
border: "none",
boxShadow: isInspecting
? "0 0 15px rgba(59, 130, 246, 0.6)"
boxShadow: isInspecting
? "0 0 15px rgba(59, 130, 246, 0.6)"
: "0 4px 12px rgba(0,0,0,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: isDragging ? "grabbing" : "pointer",
transition: isDragging ? "none" : "background-color 0.25s, transform 0.25s, box-shadow 0.25s",
transition: isDragging
? "none"
: "background-color 0.25s, transform 0.25s, box-shadow 0.25s",
outline: "none",
transform: isInspecting ? "scale(1.1)" : "scale(1)",
}}
title="Toggle Dev Inspect Mode (Drag to move)"
>
{isInspecting ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2.5}
stroke="currentColor"
style={{ width: "22px", height: "22px", pointerEvents: "none" }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18 18 6M6 6l12 12"
/>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" style={{ width: "22px", height: "22px", pointerEvents: "none" }}>
<path strokeLinecap="round" strokeLinejoin="round" d="m15.75 15.75-2.489-2.489m0 0a3.375 3.375 0 1 0-4.773-4.773 3.375 3.375 0 0 0 4.774 4.774ZM21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
style={{ width: "22px", height: "22px", pointerEvents: "none" }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m15.75 15.75-2.489-2.489m0 0a3.375 3.375 0 1 0-4.773-4.773 3.375 3.375 0 0 0 4.774 4.774ZM21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
)}
</button>

74
src/components/questions/progress-helper.ts

@ -1,14 +1,16 @@
import {
type QuestionListItem,
isQuestionVisibleForProfile,
isQuestionRequiredForProfile,
isQuestionVisibleForProfile,
type QuestionListItem,
} from "@/data/question-data";
import { hasQuestionAnswerValue } from "./question-answer-storage";
export function getStoredAge(): number | null {
try {
if (typeof window === "undefined") return null;
const rawValue = window.localStorage.getItem("marriage:sections:personal_info:answers");
const rawValue = window.localStorage.getItem(
"marriage:sections:personal_info:answers",
);
if (!rawValue) return null;
const storedAnswers = JSON.parse(rawValue);
const ageField = storedAnswers.fields?.find((f: any) => f.label === "Age");
@ -16,7 +18,9 @@ export function getStoredAge(): number | null {
const num = Number(ageField.value);
if (Number.isFinite(num)) return num;
}
const dobField = storedAnswers.fields?.find((f: any) => f.label === "Date of Birth");
const dobField = storedAnswers.fields?.find(
(f: any) => f.label === "Date of Birth",
);
if (dobField && dobField.value) {
const dob = new Date(dobField.value);
if (!Number.isNaN(dob.getTime())) {
@ -29,11 +33,27 @@ export function getStoredAge(): number | null {
return age >= 0 ? age : null;
}
}
} catch (e) { }
} catch (e) {}
return null;
}
export function getLocalSectionProgress(item: QuestionListItem, profile: any, age: number | null): number | null {
function isFieldAnswered(q: any, field: any): boolean {
if (!field || !hasQuestionAnswerValue(field.value)) {
return false;
}
if (q.type === "birthplace") {
const strVal = String(field.value);
const parts = strVal.split(",").map((p) => p.trim());
return parts.length >= 2 && parts[0].length > 0 && parts[1].length > 0;
}
return true;
}
export function getLocalSectionProgress(
item: QuestionListItem,
profile: any,
age: number | null,
): number | null {
try {
if (typeof window === "undefined") return null;
const storageKey = `marriage:sections:${item.slug}:answers`;
@ -45,7 +65,11 @@ export function getLocalSectionProgress(item: QuestionListItem, profile: any, ag
if (storedValue && storedValue.completed) {
return 100;
}
if (!storedValue || !Array.isArray(storedValue.fields) || storedValue.fields.length === 0) {
if (
!storedValue ||
!Array.isArray(storedValue.fields) ||
storedValue.fields.length === 0
) {
return null;
}
@ -55,7 +79,9 @@ export function getLocalSectionProgress(item: QuestionListItem, profile: any, ag
};
const visibleQuestions = item.questions
.filter((question) => isQuestionVisibleForProfile(question, profileContext))
.filter((question) =>
isQuestionVisibleForProfile(question, profileContext),
)
.map((question) => ({
...question,
required: isQuestionRequiredForProfile(question, profileContext),
@ -67,22 +93,40 @@ export function getLocalSectionProgress(item: QuestionListItem, profile: any, ag
return 100;
}
const answeredCount = visibleQuestions.filter((q, index) => {
const slugifyTitle = (title: string) => title.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
const slugifyTitle = (title: string) =>
title
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
const fieldKey = `q${index + 1}_${slugifyTitle(q.title)}`;
const field = storedValue.fields.find((f: any) => f.key === fieldKey || f.label === q.title);
return field && hasQuestionAnswerValue(field.value);
const field = storedValue.fields.find(
(f: any) => f.key === fieldKey || f.label === q.title,
);
return isFieldAnswered(q, field);
}).length;
return Math.round((answeredCount / visibleQuestions.length) * 100);
}
const answeredRequiredCount = requiredQuestions.filter((q) => {
const originalIndex = item.questions.findIndex((origQ) => origQ.title === q.title);
const originalIndex = item.questions.findIndex(
(origQ) => origQ.title === q.title,
);
if (originalIndex === -1) return false;
const slugifyTitle = (title: string) => title.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
const slugifyTitle = (title: string) =>
title
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
const fieldKey = `q${originalIndex + 1}_${slugifyTitle(q.title)}`;
const field = storedValue.fields.find((f: any) => f.key === fieldKey || f.label === q.title);
return field && hasQuestionAnswerValue(field.value);
const field = storedValue.fields.find(
(f: any) => f.key === fieldKey || f.label === q.title,
);
return isFieldAnswered(q, field);
}).length;
return Math.round((answeredRequiredCount / requiredQuestions.length) * 100);

24
src/components/questions/question-answer-storage.tsx

@ -11,16 +11,19 @@ import {
useState,
} from "react";
import type { QuestionField } from "@/data/question-data";
import { toBackendSlug } from "@/data/section-slug-map";
import { pathParam } from "@/hooks/marriage/path-param";
import { getApiRequestUrl } from "@/lib/http";
import type {
MarriageField,
MarriageFieldValue,
MarriagePhoneFieldValue,
UpdateMarriageSectionDataPayload,
} from "@/hooks/marriage/types";
import { useUpdateMarriageSectionDataMutation, useMarriageSectionDataQuery } from "@/hooks/marriage/use-section-data";
import { toBackendSlug } from "@/data/section-slug-map";
import {
useMarriageSectionDataQuery,
useUpdateMarriageSectionDataMutation,
} from "@/hooks/marriage/use-section-data";
import { getApiRequestUrl } from "@/lib/http";
const STORAGE_VERSION = 1;
@ -123,7 +126,9 @@ function isMarriageField(value: unknown): value is MarriageField {
);
}
function isMarriagePhoneFieldValue(value: unknown): value is MarriagePhoneFieldValue {
function isMarriagePhoneFieldValue(
value: unknown,
): value is MarriagePhoneFieldValue {
if (!value || typeof value !== "object") {
return false;
}
@ -175,7 +180,10 @@ function getOrderedFields(
return orderedFields;
}
function getCurrentStep(fields: MarriageField[], questions: readonly QuestionField[]) {
function getCurrentStep(
fields: MarriageField[],
questions: readonly QuestionField[],
) {
return questions.filter((question, index) => {
if (!question.required || question.logic?.dependsOn) {
return false;
@ -192,7 +200,7 @@ function createPayload(
): UpdateMarriageSectionDataPayload {
const fields = getOrderedFields(answers, questions);
const targetQuestions = questions.filter(
(q) => q.required && !q.logic?.dependsOn
(q) => q.required && !q.logic?.dependsOn,
);
return {
@ -271,7 +279,9 @@ function writeStoredAnswers(
function getKeepalivePatchUrl(slug: string) {
const backendSlug = toBackendSlug(slug);
return getApiRequestUrl(`/api/marriage/sections/${pathParam(backendSlug)}/data/`);
return getApiRequestUrl(
`/api/marriage/sections/${pathParam(backendSlug)}/data/`,
);
}
export function QuestionAnswersProvider({

716
src/components/questions/question-birthplace.tsx

@ -0,0 +1,716 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { getCountryList, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries";
import type { QuestionField } from "@/data/question-data";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
type QuestionBirthplaceProps = {
question: QuestionField;
questionIndex: number;
disabled?: boolean;
};
type BirthplaceValue = {
country?: string;
city?: string;
};
function parseValue(rawValue: unknown): { country: string; city: string } {
if (!rawValue) return { country: "", city: "" };
if (typeof rawValue === "object" && rawValue !== null) {
const obj = rawValue as BirthplaceValue;
return {
country: typeof obj.country === "string" ? obj.country : "",
city: typeof obj.city === "string" ? obj.city : "",
};
}
if (typeof rawValue === "string") {
let str = rawValue.trim();
// Clean legacy country names with parentheses containing commas
str = str.replace(
"United Kingdom (UK, England, Wales, Scotland, Northern Ireland)",
"United Kingdom (UK)",
);
str = str.replace("United States (US, USA, America)", "United States (US)");
str = str.replace(
"United Arab Emirates (UAE, Dubai, Abu Dhabi)",
"United Arab Emirates (UAE)",
);
str = str.replace("Congo (Congo-Brazzaville)", "Congo");
str = str.replace("Czechia (Czech Republic)", "Czechia");
str = str.replace("Myanmar (formerly Burma)", "Myanmar");
str = str.replace(
"امارات متحده عربی (دبی، ابوظبی، UAE)",
"امارات متحده عربی (UAE)",
);
str = str.replace(
"بریتانیا (انگلستان، اسکاتلند، ولز، ایرلند شمالی، UK)",
"بریتانیا (UK)",
);
str = str.replace(
"ایالات متحده آمریکا (آمریکا، US، USA)",
"ایالات متحده آمریکا (US)",
);
if (str.includes(",")) {
const parts = str.split(",").map((s) => s.trim());
return { city: parts[0] || "", country: parts[1] || "" };
}
if (str.includes(" - ")) {
const parts = str.split(" - ").map((s) => s.trim());
return { country: parts[0] || "", city: parts[1] || "" };
}
const isCountry = COUNTRIES_EN.includes(str) || COUNTRIES_FA.includes(str);
if (isCountry) {
return { country: str, city: "" };
}
return { country: "", city: str };
}
return { country: "", city: "" };
}
export function QuestionBirthplace({
question,
questionIndex,
disabled,
}: QuestionBirthplaceProps) {
const { dictionary: t, locale } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const rawValue = getAnswerValue(question, questionIndex);
const initial = parseValue(rawValue);
const [selectedCountry, setSelectedCountry] = useState(initial.country);
const [cityInput, setCityInput] = useState(initial.city);
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const isResidence =
question.title.toLowerCase().includes("residence") ||
question.title.includes("سکونت");
const [mode, setMode] = useState<"auto" | "manual">("auto");
const [isDetecting, setIsDetecting] = useState(false);
const [detectedLocation, setDetectedLocation] = useState("");
const updateAnswers = (country: string, city: string) => {
const formatted =
city && country ? `${city}, ${country}` : city || country || null;
setAnswerValue(question, questionIndex, 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
if (rawValue && !force) {
const parsed = parseValue(rawValue);
if (parsed.country && parsed.city) {
setDetectedLocation(`${parsed.city}, ${parsed.country}`);
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;
setSelectedCountry(country);
setCityInput(city);
setDetectedLocation(`${city}, ${country}`);
updateAnswers(country, city);
setMode("auto");
} else {
setMode("manual");
}
})
.catch(() => {
setMode("manual");
});
})
.finally(() => {
setIsDetecting(false);
});
};
useEffect(() => {
if (isResidence) {
detectLocation();
}
}, [isResidence]);
const handleAutoClick = () => {
setMode("auto");
detectLocation(true);
};
const handleManualClick = () => {
if (typeof window !== "undefined") {
localStorage.setItem("hasCheckedGeoIPResidence", "true");
}
setMode("manual");
setSelectedCountry("");
setCityInput("");
updateAnswers("", "");
};
// Synchronize state if rawValue changes externally
useEffect(() => {
const updated = parseValue(rawValue);
if (updated.country !== selectedCountry) {
setSelectedCountry(updated.country);
}
if (updated.city.trim() !== cityInput.trim()) {
setCityInput(updated.city);
}
if (updated.country && updated.city) {
setDetectedLocation(`${updated.city}, ${updated.country}`);
}
}, [rawValue]);
// Lock page scroll when dropdown is open
useEffect(() => {
if (isOpen) {
document.body.classList.add("dropdown-open");
document.body.style.overflow = "hidden";
document.documentElement.style.overflow = "hidden";
} else {
document.body.classList.remove("dropdown-open");
document.body.style.overflow = "";
document.documentElement.style.overflow = "";
}
return () => {
document.body.classList.remove("dropdown-open");
document.body.style.overflow = "";
document.documentElement.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
if (isOpen) {
const timer = setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [isOpen]);
const handleListWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
e.stopPropagation();
const el = listRef.current;
if (!el) return;
const atTop = el.scrollTop <= 0 && e.deltaY < 0;
const atBottom =
el.scrollTop + el.clientHeight >= el.scrollHeight && e.deltaY > 0;
if (atTop || atBottom) {
e.preventDefault();
}
}, []);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const options = getCountryList(locale);
const filteredOptions = options.filter((option) =>
option.toLowerCase().includes(searchQuery.toLowerCase()),
);
const handleSelectCountry = (country: string) => {
if (typeof window !== "undefined") {
localStorage.setItem("hasCheckedGeoIPResidence", "true");
}
setSelectedCountry(country);
setCityInput("");
setIsOpen(false);
updateAnswers(country, "");
};
const handleCityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (typeof window !== "undefined") {
localStorage.setItem("hasCheckedGeoIPResidence", "true");
}
const newCity = e.target.value;
setCityInput(newCity);
updateAnswers(selectedCountry, newCity);
};
const isRtl = locale === "fa" || locale === "ar" || locale === "ur";
const selectCountryPlaceholder = isRtl ? "انتخاب کشور" : "Select country";
const cityPlaceholder =
question.extras?.placeHolder ||
(isRtl ? "شهر، منطقه یا محله" : "City, region, or neighborhood");
return (
<div
ref={containerRef}
className={[
"relative flex w-full flex-col gap-3 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
{isResidence ? (
<>
{/* GeoIP Auto/Manual selector UI */}
<div className="flex flex-col gap-3.5 w-full py-1">
{/* Left Side: Location Info */}
<div className="flex items-center gap-2.5 min-w-0">
<svg
width="16"
height="20"
viewBox="0 0 16 20"
fill="none"
className="shrink-0 text-[#181818]"
>
<path
d="M8 0C3.58 0 0 3.58 0 8C0 13.54 8 20 8 20C8 20 16 13.54 16 8C16 3.58 12.42 0 8 0ZM8 11C6.34 11 5 9.66 5 8C5 6.34 6.34 5 8 5C9.66 5 11 6.34 11 8C11 9.66 9.66 11 8 11Z"
fill="currentColor"
/>
</svg>
<span className="text-[16px] font-bold text-[#181818]">
{isDetecting
? locale === "fa"
? "در حال شناسایی..."
: "Detecting..."
: detectedLocation ||
(locale === "fa" ? "نامشخص" : "Unknown")}
</span>
</div>
{/* Right Side: Toggle Buttons */}
<div className="flex items-center gap-2 w-full">
{/* Auto Button */}
<button
type="button"
onClick={handleAutoClick}
className={[
"flex flex-1 items-center justify-center gap-1.5 h-[46px] rounded-xl text-[14px] font-bold cursor-pointer transition-all shadow-sm",
mode === "auto"
? "bg-[#F2465F] text-white"
: "bg-white border border-[#D0D5DD] text-[#344054] hover:bg-gray-50",
].join(" ")}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
className="shrink-0"
>
<circle
cx="8"
cy="8"
r="6"
stroke="currentColor"
strokeWidth="2"
/>
<circle cx="8" cy="8" r="2" fill="currentColor" />
<path
d="M8 0V3M8 13V16M0 8H3M13 8H16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
<span>{locale === "fa" ? "خودکار" : "Auto"}</span>
</button>
{/* Manual Button */}
<button
type="button"
onClick={handleManualClick}
className={[
"flex flex-1 items-center justify-center gap-1.5 h-[46px] rounded-xl text-[14px] font-bold cursor-pointer transition-all shadow-sm",
mode === "manual"
? "bg-[#F2465F] text-white"
: "bg-white border border-[#D0D5DD] text-[#344054] hover:bg-gray-50",
].join(" ")}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
className="shrink-0"
>
<path
d="M1 3.5L5 1.5L11 4.5L15 2.5V12.5L11 14.5L5 11.5L1 13.5V3.5Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M5 1.5V11.5M11 4.5V14.5"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
<span>{locale === "fa" ? "دستی" : "Manual"}</span>
</button>
</div>
</div>
{mode === "manual" && (
<div className="flex flex-col gap-3 w-full animate-in fade-in slide-in-from-top-2 duration-200">
{/* Country Selection Dropdown */}
<div className="relative w-full">
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
className={[
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none",
isOpen
? "border-[#6F6F6F] ring-1 ring-[#6F6F6F]"
: "border-[#D0D5DD] hover:border-[#98A2B3]",
].join(" ")}
>
<span
className={[
"text-[15px] font-medium truncate",
selectedCountry ? "text-[#181818]" : "text-[#667085]",
].join(" ")}
>
{selectedCountry || selectCountryPlaceholder}
</span>
<svg
width="16"
height="10"
viewBox="0 0 16 10"
fill="none"
className={[
"shrink-0 transition-transform duration-200",
isOpen ? "rotate-180" : "",
].join(" ")}
>
<path
d="M14.75 1.25L7.75 8.25L0.75 1.25"
stroke="#344054"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{isOpen && (
<div className="absolute top-[calc(100%+8px)] left-0 z-50 flex w-full flex-col gap-3.5 rounded-[20px] bg-white p-4.5 shadow-[0_12px_36px_rgba(0,0,0,0.09)] border border-[#EAECF0] animate-in fade-in zoom-in-95 duration-150">
{options.length > 3 || searchQuery ? (
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[12px] bg-[#EFEFEF] px-3.5 transition-colors focus-within:bg-[#E8E8E8]">
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
className="shrink-0 text-[#667085]"
>
<path
d="M8.25 14.25C11.5637 14.25 14.25 11.5637 14.25 8.25C14.25 4.93629 11.5637 2.25 8.25 2.25C4.93629 2.25 2.25 4.93629 2.25 8.25C2.25 11.5637 4.93629 14.25 8.25 14.25Z"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.75 15.75L12.5 12.5"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[#667085] hover:text-[#181818] text-xs font-semibold"
>
</button>
) : null}
</div>
) : null}
<div
ref={listRef}
onWheel={handleListWheel}
onTouchStart={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
className="flex max-h-[220px] flex-col gap-3.5 overflow-y-auto overscroll-contain pr-1"
>
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => {
const isSelected = selectedCountry === option;
return (
<button
key={option}
type="button"
onClick={() => handleSelectCountry(option)}
className="flex w-full items-center gap-3 text-start cursor-pointer group/opt py-0.5"
>
<div
className={[
"size-[20px] shrink-0 rounded-full transition-all duration-150",
isSelected
? "bg-[#F2465F] shadow-xs"
: "border-[2px] border-[#344054] bg-transparent group-hover/opt:border-[#181818]",
].join(" ")}
/>
<span className="text-[15px] font-bold text-[#181818]">
{option}
</span>
</button>
);
})
) : (
<span className="py-2 text-[13px] text-[#667085]">
No options found
</span>
)}
</div>
</div>
)}
</div>
{/* City Text Input */}
<div className="w-full">
<input
type="text"
disabled={disabled}
value={cityInput}
onChange={handleCityChange}
placeholder={cityPlaceholder}
className="h-[54px] w-full rounded-[16px] border border-[#D0D5DD] bg-white px-4.5 text-[15px] font-medium text-[#181818] placeholder:text-[#98A2B3] hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F] outline-none transition-all"
/>
</div>
</div>
)}
</>
) : (
<>
{/* 1. Country Selection Dropdown */}
<div className="relative w-full">
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
className={[
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none",
isOpen
? "border-[#6F6F6F] ring-1 ring-[#6F6F6F]"
: "border-[#D0D5DD] hover:border-[#98A2B3]",
].join(" ")}
>
<span
className={[
"text-[15px] font-medium truncate",
selectedCountry ? "text-[#181818]" : "text-[#667085]",
].join(" ")}
>
{selectedCountry || selectCountryPlaceholder}
</span>
<svg
width="16"
height="10"
viewBox="0 0 16 10"
fill="none"
className={[
"shrink-0 transition-transform duration-200",
isOpen ? "rotate-180" : "",
].join(" ")}
>
<path
d="M14.75 1.25L7.75 8.25L0.75 1.25"
stroke="#344054"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{isOpen && (
<div className="absolute top-[calc(100%+8px)] left-0 z-50 flex w-full flex-col gap-3.5 rounded-[20px] bg-white p-4.5 shadow-[0_12px_36px_rgba(0,0,0,0.09)] border border-[#EAECF0] animate-in fade-in zoom-in-95 duration-150">
{options.length > 3 || searchQuery ? (
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[12px] bg-[#EFEFEF] px-3.5 transition-colors focus-within:bg-[#E8E8E8]">
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
className="shrink-0 text-[#667085]"
>
<path
d="M8.25 14.25C11.5637 14.25 14.25 11.5637 14.25 8.25C14.25 4.93629 11.5637 2.25 8.25 2.25C4.93629 2.25 2.25 4.93629 2.25 8.25C2.25 11.5637 4.93629 14.25 8.25 14.25Z"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.75 15.75L12.5 12.5"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[#667085] hover:text-[#181818] text-xs font-semibold"
>
</button>
) : null}
</div>
) : null}
<div
ref={listRef}
onWheel={handleListWheel}
onTouchStart={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
className="flex max-h-[220px] flex-col gap-3.5 overflow-y-auto overscroll-contain pr-1"
>
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => {
const isSelected = selectedCountry === option;
return (
<button
key={option}
type="button"
onClick={() => handleSelectCountry(option)}
className="flex w-full items-center gap-3 text-start cursor-pointer group/opt py-0.5"
>
<div
className={[
"size-[20px] shrink-0 rounded-full transition-all duration-150",
isSelected
? "bg-[#F2465F] shadow-xs"
: "border-[2px] border-[#344054] bg-transparent group-hover/opt:border-[#181818]",
].join(" ")}
/>
<span className="text-[15px] font-bold text-[#181818]">
{option}
</span>
</button>
);
})
) : (
<span className="py-2 text-[13px] text-[#667085]">
No options found
</span>
)}
</div>
</div>
)}
</div>
{/* 2. City Text Input */}
<div className="w-full">
<input
type="text"
disabled={disabled}
value={cityInput}
onChange={handleCityChange}
placeholder={cityPlaceholder}
className="h-[54px] w-full rounded-[16px] border border-[#D0D5DD] bg-white px-4.5 text-[15px] font-medium text-[#181818] placeholder:text-[#98A2B3] hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F] outline-none transition-all"
/>
</div>
</>
)}
</div>
);
}
export default QuestionBirthplace;

2
src/components/questions/question-card.tsx

@ -1,3 +1,4 @@
import Image from "next/image";
import Link from "next/link";
import type { IconType } from "react-icons";
import {
@ -11,7 +12,6 @@ import {
import type { QuestionCardIcon, QuestionListItem } from "@/data/question-data";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import Image from "next/image";
type QuestionCardProps = {
item: QuestionListItem;

25
src/components/questions/question-date.tsx

@ -2,9 +2,9 @@
import { useEffect, useMemo, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
import { useI18n } from "@/translations/provider";
type QuestionDateProps = {
question: QuestionField;
@ -36,7 +36,9 @@ const DAYS = Array.from({ length: 31 }, (_, i) => {
const MIN_AGE = 18;
const currentYear = new Date().getFullYear();
const maxBirthYear = currentYear - MIN_AGE;
const YEARS = Array.from({ length: 80 }, (_, i) => (maxBirthYear - i).toString());
const YEARS = Array.from({ length: 80 }, (_, i) =>
(maxBirthYear - i).toString(),
);
export function QuestionDate({
question,
@ -76,7 +78,14 @@ export function QuestionDate({
const m = Number.parseInt(selectedMonth, 10);
const d = Number.parseInt(selectedDay, 10);
if (!y || !m || !d || Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) {
if (
!y ||
!m ||
!d ||
Number.isNaN(y) ||
Number.isNaN(m) ||
Number.isNaN(d)
) {
return null;
}
@ -103,7 +112,11 @@ export function QuestionDate({
if (y && m && d) {
const formattedMonth = m.padStart(2, "0");
const formattedDay = d.padStart(2, "0");
setAnswerValue(question, questionIndex, `${y}-${formattedMonth}-${formattedDay}`);
setAnswerValue(
question,
questionIndex,
`${y}-${formattedMonth}-${formattedDay}`,
);
} else {
setAnswerValue(question, questionIndex, "");
}
@ -193,7 +206,9 @@ export function QuestionDate({
value={calculatedAge !== null ? calculatedAge : ""}
className={[
"h-[54px] w-full rounded-[15px] border px-4 group-12 font-medium outline-none cursor-not-allowed disabled:bg-[#F5F2F1] disabled:text-[#7C7472]",
isUnder18 ? "border-[#F2465F] text-[#F2465F]" : "border-[#E7D8D5] text-[#7C7472]",
isUnder18
? "border-[#F2465F] text-[#F2465F]"
: "border-[#E7D8D5] text-[#7C7472]",
].join(" ")}
/>
{isUnder18 ? (

69
src/components/questions/question-dropdown.tsx

@ -1,7 +1,11 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { getCountryList } from "@/data/countries";
import { getLanguageList } from "@/data/languages";
import { getNationalityList } from "@/data/nationalities";
import type { QuestionField } from "@/data/question-data";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
@ -16,6 +20,7 @@ export function QuestionDropdown({
questionIndex,
disabled,
}: QuestionDropdownProps) {
const { locale } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const rawValue = getAnswerValue(question, questionIndex);
@ -31,6 +36,7 @@ export function QuestionDropdown({
const [searchQuery, setSearchQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// Lock page scroll when dropdown is open
useEffect(() => {
@ -81,7 +87,43 @@ export function QuestionDropdown({
};
}, []);
const options = question.extras.options || [];
useEffect(() => {
if (isOpen) {
const timer = setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [isOpen]);
let options = question.extras.options || [];
const isNationalityDropdown =
question.title === "Current Nationality / Citizenship" ||
question.title === "ملیت / تابعیت فعلی";
const isCountryDropdown =
!isNationalityDropdown &&
(options.includes("United States") ||
options.includes("ایالات متحده آمریکا") ||
options.includes("Iran") ||
options.includes("ایران"));
if (isNationalityDropdown) {
options = getNationalityList(locale);
} else if (isCountryDropdown) {
options = getCountryList(locale);
}
const isLanguageDropdown =
options.includes("Persian") ||
options.includes("فارسی") ||
options.includes("English") ||
options.includes("انگلیسی");
if (isLanguageDropdown) {
options = getLanguageList(locale);
}
const filteredOptions = options.filter((option) =>
option.toLowerCase().includes(searchQuery.toLowerCase()),
);
@ -92,7 +134,9 @@ export function QuestionDropdown({
: question.extras.placeHolder || "Select"
: singleValue || question.extras.placeHolder || "Select";
const hasSelectedValue = isMulti ? selectedList.length > 0 : Boolean(singleValue);
const hasSelectedValue = isMulti
? selectedList.length > 0
: Boolean(singleValue);
const toggleMultiOption = (option: string) => {
let nextValue: string[];
@ -101,7 +145,11 @@ export function QuestionDropdown({
} else {
nextValue = [...selectedList, option];
}
setAnswerValue(question, questionIndex, nextValue.length > 0 ? nextValue : null);
setAnswerValue(
question,
questionIndex,
nextValue.length > 0 ? nextValue : null,
);
};
return (
@ -121,7 +169,9 @@ export function QuestionDropdown({
onClick={() => setIsOpen(!isOpen)}
className={[
"flex h-[54px] w-full items-center justify-between rounded-[16px] border bg-white px-4.5 text-start transition-all cursor-pointer outline-none",
isOpen ? "border-[#6F6F6F] ring-1 ring-[#6F6F6F]" : "border-[#D0D5DD] hover:border-[#98A2B3]",
isOpen
? "border-[#6F6F6F] ring-1 ring-[#6F6F6F]"
: "border-[#D0D5DD] hover:border-[#98A2B3]",
].join(" ")}
>
<span
@ -137,7 +187,10 @@ export function QuestionDropdown({
height="10"
viewBox="0 0 16 10"
fill="none"
className={["shrink-0 transition-transform duration-200", isOpen ? "rotate-180" : ""].join(" ")}
className={[
"shrink-0 transition-transform duration-200",
isOpen ? "rotate-180" : "",
].join(" ")}
>
<path
d="M14.75 1.25L7.75 8.25L0.75 1.25"
@ -178,6 +231,7 @@ export function QuestionDropdown({
/>
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
@ -255,7 +309,9 @@ export function QuestionDropdown({
);
})
) : (
<span className="py-2 text-[13px] text-[#667085]">No options found</span>
<span className="py-2 text-[13px] text-[#667085]">
No options found
</span>
)}
</div>
</div>
@ -265,4 +321,3 @@ export function QuestionDropdown({
}
export default QuestionDropdown;

2
src/components/questions/question-exit-navigation-button.tsx

@ -5,9 +5,9 @@ import { useState } from "react";
import NavigationButton, {
type NavigationButtonProps,
} from "@/components/ui/navigation-button";
import { useQuestionAnswers } from "./question-answer-storage";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage";
export type QuestionExitNavigationButtonProps = NavigationButtonProps & {
exitHref?: string;

18
src/components/questions/question-file.tsx

@ -38,8 +38,14 @@ function resolveMediaType(
return "file";
}
function isImageFile(fileName?: string | null, fileUrl?: string | null): boolean {
if (fileUrl && (fileUrl.startsWith("data:image/") || fileUrl.startsWith("blob:"))) {
function isImageFile(
fileName?: string | null,
fileUrl?: string | null,
): boolean {
if (
fileUrl &&
(fileUrl.startsWith("data:image/") || fileUrl.startsWith("blob:"))
) {
if (fileUrl.startsWith("data:application/pdf")) return false;
if (fileName && /\.(pdf|doc|docx|xls|xlsx|txt|zip|rar)$/i.test(fileName)) {
return false;
@ -60,7 +66,7 @@ export function QuestionFile({
const initialFileName =
typeof storedValue === "string" && storedValue.trim().length > 0
? storedValue.split("/").pop() ?? storedValue
? (storedValue.split("/").pop() ?? storedValue)
: null;
const initialFileUrl =
@ -171,7 +177,8 @@ export function QuestionFile({
fieldName: "file",
allowedExtensions: extensions,
maxBytes: 10_485_760, // 10 MB
title: typeof question.title === "string" ? question.title : "Upload File",
title:
typeof question.title === "string" ? question.title : "Upload File",
});
}, [question]);
@ -354,6 +361,3 @@ export function QuestionFile({
}
export default QuestionFile;

33
src/components/questions/question-number.tsx

@ -1,10 +1,10 @@
"use client";
import { useMemo, useEffect } from "react";
import { useEffect, useMemo } from "react";
import type { QuestionField } from "@/data/question-data";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
import { useI18n } from "@/translations/provider";
type QuestionNumberProps = {
question: QuestionField;
@ -26,7 +26,7 @@ export default function QuestionNumber({
const { dictionary: t } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const derivedValue =
derivedFromQuestion && derivedFromQuestionIndex !== undefined
? getAnswerValue(derivedFromQuestion, derivedFromQuestionIndex)
@ -39,7 +39,14 @@ export default function QuestionNumber({
setAnswerValue(question, questionIndex, age);
}
}
}, [derivedFromQuestion, derivedValue, question, questionIndex, setAnswerValue, value]);
}, [
derivedFromQuestion,
derivedValue,
question,
questionIndex,
setAnswerValue,
value,
]);
useEffect(() => {
if (
@ -52,8 +59,13 @@ export default function QuestionNumber({
}, [question, questionIndex, setAnswerValue, value]);
const [min, max] = question.extras.range;
const numValue = typeof value === "number" ? value : (typeof value === "string" ? parseFloat(value) : NaN);
const numValue =
typeof value === "number"
? value
: typeof value === "string"
? parseFloat(value)
: NaN;
const isOutOfRange = useMemo(() => {
if (Number.isNaN(numValue)) return false;
if (min !== 0 && numValue < min) return true;
@ -93,7 +105,11 @@ export default function QuestionNumber({
setAnswerValue(question, questionIndex, null);
} else {
const parsed = parseFloat(nextValue);
setAnswerValue(question, questionIndex, Number.isNaN(parsed) ? nextValue : parsed);
setAnswerValue(
question,
questionIndex,
Number.isNaN(parsed) ? nextValue : parsed,
);
}
}}
className={[
@ -103,7 +119,8 @@ export default function QuestionNumber({
/>
{isOutOfRange ? (
<span className="block text-[10px] font-semibold text-[#F2465F]">
{t.common.rangeError || `Please enter a value between ${min} and ${max}`}
{t.common.rangeError ||
`Please enter a value between ${min} and ${max}`}
</span>
) : null}
</div>

352
src/components/questions/question-phone.tsx

@ -1,11 +1,12 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber";
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import type { QuestionField } from "@/data/question-data";
import type { MarriagePhoneFieldValue } from "@/hooks/marriage/types";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
import { useI18n } from "@/translations/provider";
type QuestionPhoneProps = {
question: QuestionField;
@ -39,7 +40,7 @@ function isMarriagePhoneFieldValue(
function readPhoneValue(value: unknown, fallbackCode: string): PhoneValueParts {
if (value === null) {
return {
codeValue: "",
codeValue: fallbackCode,
phoneValue: "",
};
}
@ -62,7 +63,7 @@ function readPhoneValue(value: unknown, fallbackCode: string): PhoneValueParts {
if (value.length === 0) {
return {
codeValue: "",
codeValue: fallbackCode,
phoneValue: "",
};
}
@ -100,6 +101,34 @@ function readPhoneValue(value: unknown, fallbackCode: string): PhoneValueParts {
};
}
function getMaxLengthForCountry(codeValue: string): number {
try {
const cleanCode = codeValue.replace(/[^\d]/g, "");
if (!cleanCode) return 15;
const countryCode = Number(cleanCode);
const regionCode = phoneUtil.getRegionCodeForCountryCode(countryCode);
if (!regionCode || regionCode === "ZZ") return 15;
const exampleMobile = phoneUtil.getExampleNumberForType(
regionCode,
1, // MOBILE
);
const exampleGeneral = phoneUtil.getExampleNumber(regionCode);
const lenMobile = exampleMobile
? String(exampleMobile.getNationalNumber()).length
: 0;
const lenGeneral = exampleGeneral
? String(exampleGeneral.getNationalNumber()).length
: 0;
const baseLen = Math.max(lenMobile, lenGeneral, 8);
return baseLen + 1;
} catch {
return 15;
}
}
function writePhoneValue(codeValue: string, phoneValue: string) {
if (!codeValue && !phoneValue) {
return null;
@ -120,7 +149,10 @@ function toStoredPhoneValue(
codeValue: string,
phoneValue: string,
): MarriagePhoneFieldValue | null {
const normalizedCountryCode = sanitizeCountryCode(codeValue).replace(/^\+/, "");
const normalizedCountryCode = sanitizeCountryCode(codeValue).replace(
/^\+/,
"",
);
const normalizedPhoneNumber = phoneValue.trim().replace(/\s+/g, "");
if (!normalizedCountryCode && !normalizedPhoneNumber) {
@ -195,6 +227,7 @@ export function QuestionPhone({
countryCode = "+98",
disabled,
}: QuestionPhoneProps) {
const { locale } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const defaultCodeValue = countryCode.trim() || "+98";
@ -202,6 +235,14 @@ export function QuestionPhone({
const [codeValue, setCodeValue] = useState(initialValue.codeValue);
const [phoneValue, setPhoneValue] = useState(initialValue.phoneValue);
const lastCommittedValueRef = useRef(value);
const hasFetchedIpRef = useRef(false);
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const normalizedPhoneState = getNormalizedPhoneValue(codeValue, phoneValue);
const showInvalidState =
codeValue.trim().length > 0 &&
@ -212,18 +253,164 @@ export function QuestionPhone({
? normalizedPhoneState.isValid || phoneValue.trim().length === 0
: normalizedPhoneState.isValid;
const countryList = useMemo(() => {
const regions = phoneUtil.getSupportedRegions();
const displayNames = new Intl.DisplayNames([locale || "en"], {
type: "region",
});
const list: { name: string; code: string; flag: string }[] = [];
const seen = new Set<string>();
for (const region of regions) {
try {
const callingCode = phoneUtil.getCountryCodeForRegion(region);
if (!callingCode) continue;
let name = displayNames.of(region);
if (!name) continue;
if (region === "US")
name = locale === "fa" ? "ایالات متحده آمریکا" : "United States";
if (region === "GB")
name = locale === "fa" ? "بریتانیا" : "United Kingdom";
const key = `${name}_+${callingCode}`;
if (seen.has(key)) continue;
seen.add(key);
const codePoints = region
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt(0));
const flag = String.fromCodePoint(...codePoints);
list.push({
name,
code: `+${callingCode}`,
flag,
});
} catch (e) {}
}
return list.sort((a, b) => a.name.localeCompare(b.name));
}, [locale]);
const activeFlag = useMemo(() => {
if (!codeValue) return "🏳️";
const cleanActiveCode = codeValue.replace(/[^\d]/g, "");
const match = countryList.find(
(c) => c.code.replace(/[^\d]/g, "") === cleanActiveCode,
);
return match ? match.flag : "🏳️";
}, [codeValue, countryList]);
const filteredCountries = useMemo(() => {
const q = searchQuery.toLowerCase().trim();
if (!q) return countryList;
return countryList.filter(
(c) =>
c.name.toLowerCase().includes(q) || c.code.toLowerCase().includes(q),
);
}, [countryList, searchQuery]);
// Lock page scroll when dropdown is open
useEffect(() => {
if (isOpen) {
document.body.classList.add("dropdown-open");
document.body.style.overflow = "hidden";
document.documentElement.style.overflow = "hidden";
} else {
document.body.classList.remove("dropdown-open");
document.body.style.overflow = "";
document.documentElement.style.overflow = "";
}
return () => {
document.body.classList.remove("dropdown-open");
document.body.style.overflow = "";
document.documentElement.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
if (isOpen) {
const timer = setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [isOpen]);
// Close dropdown on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
useEffect(() => {
if (value === lastCommittedValueRef.current) {
return;
}
const nextValue = readPhoneValue(value, defaultCodeValue);
const maxLen = getMaxLengthForCountry(nextValue.codeValue);
const truncatedPhone = nextValue.phoneValue.slice(0, maxLen);
setCodeValue(nextValue.codeValue);
setPhoneValue(nextValue.phoneValue);
setPhoneValue(truncatedPhone);
lastCommittedValueRef.current = value;
}, [defaultCodeValue, value]);
useEffect(() => {
if (hasFetchedIpRef.current) return;
const alreadyChecked =
typeof window !== "undefined"
? localStorage.getItem("hasCheckedGeoIPPhone")
: "true";
if (alreadyChecked) return;
if (!value) {
hasFetchedIpRef.current = true;
if (typeof window !== "undefined") {
localStorage.setItem("hasCheckedGeoIPPhone", "true");
}
fetch("https://ipapi.co/json/")
.then((res) => res.json())
.then((data) => {
if (data && data.country_calling_code) {
const ipCode = data.country_calling_code.startsWith("+")
? data.country_calling_code
: `+${data.country_calling_code}`;
setCodeValue(ipCode);
updateStoredValue(ipCode, phoneValue);
}
})
.catch(() => {
fetch("https://ipwho.is/")
.then((res) => res.json())
.then((data) => {
if (data && data.calling_code) {
const ipCode = data.calling_code.startsWith("+")
? data.calling_code
: `+${data.calling_code}`;
setCodeValue(ipCode);
updateStoredValue(ipCode, phoneValue);
}
})
.catch(() => {});
});
}
}, []);
const updateStoredValue = (nextCodeValue: string, nextPhoneValue: string) => {
const draftValue = writePhoneValue(nextCodeValue, nextPhoneValue);
const nextPhoneState = getNormalizedPhoneValue(
@ -241,12 +428,27 @@ export function QuestionPhone({
setAnswerValue(question, questionIndex, nextValue);
};
const handleSelectCountryCode = (selectedCode: string) => {
if (typeof window !== "undefined") {
localStorage.setItem("hasCheckedGeoIPPhone", "true");
}
const maxLen = getMaxLengthForCountry(selectedCode);
const truncatedPhone = phoneValue.slice(0, maxLen);
setCodeValue(selectedCode);
setPhoneValue(truncatedPhone);
updateStoredValue(selectedCode, truncatedPhone);
setIsOpen(false);
setSearchQuery("");
};
return (
<div
ref={containerRef}
data-question-answered={isAnswered ? "true" : "false"}
data-question-type={question.type}
className={[
"flex w-full flex-col gap-2 transition-opacity duration-200",
"relative flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
@ -259,23 +461,34 @@ export function QuestionPhone({
].join(" ")}
>
<div className="flex shrink-0 items-center pl-2.5 pr-2">
<input
type="tel"
inputMode="tel"
aria-label="Country code"
maxLength={4}
<button
type="button"
disabled={disabled}
placeholder={defaultCodeValue}
value={codeValue}
onChange={(event) => {
const nextCodeValue = sanitizeCountryCode(event.target.value);
setCodeValue(nextCodeValue);
updateStoredValue(nextCodeValue, phoneValue);
}}
className="w-[33px] border-0 bg-transparent p-0 text-left group-12 leading-none text-[#181818] tabular-nums outline-none placeholder:text-[#9D8F8C]"
/>
<span aria-hidden="true" className="h-5 w-px bg-[#181818]/35" />
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1.5 h-full px-2 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors cursor-pointer outline-none border-0 group-12 leading-none text-[#181818] tabular-nums"
>
<span>{activeFlag}</span>
<span>{codeValue || defaultCodeValue}</span>
<svg
width="10"
height="6"
viewBox="0 0 10 6"
fill="none"
className={[
"shrink-0 transition-transform duration-200 text-[#344054]",
isOpen ? "rotate-180" : "",
].join(" ")}
>
<path
d="M1 1L5 5L9 1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<span aria-hidden="true" className="h-5 w-px bg-[#181818]/35 ml-1" />
</div>
<span className="flex min-w-0 flex-1 items-center pr-4">
<input
@ -284,11 +497,17 @@ export function QuestionPhone({
disabled={disabled}
placeholder={question.extras.placeHolder?.replace(/^\+\d+\s*/, "")}
value={phoneValue}
maxLength={getMaxLengthForCountry(codeValue)}
onChange={(event) => {
if (typeof window !== "undefined") {
localStorage.setItem("hasCheckedGeoIPPhone", "true");
}
const nextPhoneValue = sanitizePhoneNumber(event.target.value);
const maxLen = getMaxLengthForCountry(codeValue);
const truncatedPhone = nextPhoneValue.slice(0, maxLen);
setPhoneValue(nextPhoneValue);
updateStoredValue(codeValue, nextPhoneValue);
setPhoneValue(truncatedPhone);
updateStoredValue(codeValue, truncatedPhone);
}}
dir="ltr"
className="h-full w-full border-0 bg-transparent p-0 text-left group-12 leading-none text-[#181818] tabular-nums outline-none placeholder:text-[#9D8F8C]"
@ -300,6 +519,91 @@ export function QuestionPhone({
Enter a valid phone number with country code.
</span>
) : null}
{/* Dropdown Options Panel */}
{isOpen && (
<div className="absolute top-[calc(100%+8px)] left-0 z-50 flex w-full flex-col gap-3.5 rounded-[20px] bg-white p-4.5 shadow-[0_12px_36px_rgba(0,0,0,0.09)] border border-[#EAECF0] animate-in fade-in zoom-in-95 duration-150">
{/* Search Input Bar */}
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[12px] bg-[#EFEFEF] px-3.5 transition-colors focus-within:bg-[#E8E8E8]">
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
className="shrink-0 text-[#667085]"
>
<path
d="M8.25 14.25C11.5637 14.25 14.25 11.5637 14.25 8.25C14.25 4.93629 11.5637 2.25 8.25 2.25C4.93629 2.25 2.25 4.93629 2.25 8.25C2.25 11.5637 4.93629 14.25 8.25 14.25Z"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.75 15.75L12.5 12.5"
stroke="#667085"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={
locale === "fa"
? "جستجوی کشور یا پیش‌شماره..."
: "Search country or dial code..."
}
className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[#667085] hover:text-[#181818] text-xs font-semibold"
>
</button>
) : null}
</div>
{/* Country List */}
<div
ref={listRef}
className="flex max-h-[220px] flex-col gap-3.5 overflow-y-auto overscroll-contain pr-1"
>
{filteredCountries.length > 0 ? (
filteredCountries.map((c) => {
return (
<button
key={`${c.name}-${c.code}`}
type="button"
onClick={() => handleSelectCountryCode(c.code)}
className="flex w-full items-center justify-between text-start cursor-pointer group/opt py-0.5"
>
<div className="flex items-center gap-3">
<span className="text-[20px]">{c.flag}</span>
<span className="text-[15px] font-bold text-[#181818]">
{c.name}
</span>
</div>
<span className="text-[15px] font-semibold text-[#667085] tabular-nums">
{c.code}
</span>
</button>
);
})
) : (
<span className="py-2 text-[13px] text-[#667085]">
{locale === "fa" ? "موردی یافت نشد" : "No options found"}
</span>
)}
</div>
</div>
)}
</div>
);
}

22
src/components/questions/question-photo.tsx

@ -1,7 +1,7 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useId, useState, type ReactNode } from "react";
import { type ReactNode, useCallback, useEffect, useId, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media";
import { getApiRequestUrl } from "@/lib/http";
@ -110,9 +110,11 @@ export function QuestionPhoto({
returnAs: "upload",
uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`,
fieldName: "file",
allowedExtensions: extensions.length > 0 ? extensions : ["jpg", "jpeg", "png", "webp"],
allowedExtensions:
extensions.length > 0 ? extensions : ["jpg", "jpeg", "png", "webp"],
maxBytes: 10_485_760, // 10 MB
title: typeof question.title === "string" ? question.title : "Upload Photo",
title:
typeof question.title === "string" ? question.title : "Upload Photo",
});
}, [question]);
@ -139,7 +141,11 @@ export function QuestionPhoto({
// Determine photo URL to display
let displayUrl: string | null = localPreviewUrl;
if (!displayUrl && typeof storedValue === "string" && storedValue.trim().length > 0) {
if (
!displayUrl &&
typeof storedValue === "string" &&
storedValue.trim().length > 0
) {
const val = storedValue.trim();
if (
val.startsWith("http://") ||
@ -163,7 +169,10 @@ export function QuestionPhoto({
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} className="justify-center text-center" />
<QuestionTitle
question={question}
className="justify-center text-center"
/>
<input
id={inputId}
@ -237,6 +246,3 @@ export function QuestionPhoto({
}
export default QuestionPhoto;

2
src/components/questions/question-progress-tracker.tsx

@ -171,7 +171,7 @@ export function QuestionProgressTracker({
onChange={updateProgress}
onInput={updateProgress}
>
<div className="w-full shrink-0 px-[17px] pt-2 pb-2">
<div className="w-full shrink-0 px-[17px] pt-2 pb-[24px]">
<div
aria-label={`Answered questions: ${answered} of ${safeTotal}`}
aria-valuemax={safeTotal}

11
src/components/questions/question-section-flow.tsx

@ -5,15 +5,14 @@ import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { useCallback, useState } from "react";
import Button from "@/components/ui/button";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionProgressTracker, {
useQuestionProgress,
} from "./question-progress-tracker";
import QuestionSnapList from "./question-snap-list";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
type QuestionSectionFlowProps = {
children: ReactNode;
continueLabel: string;
@ -115,7 +114,11 @@ export function QuestionSectionFlow({
}: QuestionSectionFlowProps) {
return (
<QuestionProgressTracker total={total}>
<SectionFlowContent continueLabel={continueLabel} exitHref={exitHref} optionalQuestionIndexes={optionalQuestionIndexes}>
<SectionFlowContent
continueLabel={continueLabel}
exitHref={exitHref}
optionalQuestionIndexes={optionalQuestionIndexes}
>
{children}
</SectionFlowContent>
</QuestionProgressTracker>

8
src/components/questions/question-slider.tsx

@ -89,7 +89,13 @@ export function QuestionSlider({
max={max}
step={1}
value={value}
onChange={(event) => setAnswerValue(question, questionIndex, Number(event.target.value))}
onChange={(event) =>
setAnswerValue(
question,
questionIndex,
Number(event.target.value),
)
}
disabled={disabled}
className="question-slider-range w-full"
style={{

39
src/components/questions/question-snap-list.tsx

@ -117,7 +117,8 @@ export function QuestionSnapList({
const isDisabled =
questionElement.getAttribute("data-question-disabled") === "true" ||
questionElement.querySelector("[data-question-disabled='true']") !== null;
questionElement.querySelector("[data-question-disabled='true']") !==
null;
if (isDisabled) {
continue;
@ -190,9 +191,10 @@ export function QuestionSnapList({
return;
}
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
);
const isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);
firstFocusableInput.focus({ preventScroll: !isMobile });
@ -331,16 +333,24 @@ export function QuestionSnapList({
let wrapperStyles = "w-full";
if (isActive) {
containerStyles = "top-[35%] left-0 -translate-y-1/2 z-10 opacity-100 scale-100 pointer-events-auto";
containerStyles =
"top-0 left-0 bottom-0 z-10 opacity-100 scale-100 pointer-events-auto !justify-start";
wrapperStyles = "w-full my-auto shrink-0";
} else if (isPrev) {
containerStyles = "top-4 left-0 z-0 opacity-0 pointer-events-none scale-[0.98]";
wrapperStyles = "w-full max-h-[110px] overflow-hidden pointer-events-none";
containerStyles =
"top-4 left-0 z-0 opacity-0 pointer-events-none scale-[0.98]";
wrapperStyles =
"w-full max-h-[110px] overflow-hidden pointer-events-none";
} else if (isNext) {
containerStyles = "bottom-4 left-0 z-0 opacity-0 pointer-events-none scale-[0.98]";
wrapperStyles = "w-full max-h-[110px] overflow-hidden pointer-events-none";
containerStyles =
"bottom-4 left-0 z-0 opacity-0 pointer-events-none scale-[0.98]";
wrapperStyles =
"w-full max-h-[110px] overflow-hidden pointer-events-none";
} else {
containerStyles = "top-1/2 left-0 -translate-y-1/2 z-0 opacity-0 pointer-events-none scale-[0.95]";
wrapperStyles = "w-full max-h-[110px] overflow-hidden pointer-events-none";
containerStyles =
"top-1/2 left-0 -translate-y-1/2 z-0 opacity-0 pointer-events-none scale-[0.95]";
wrapperStyles =
"w-full max-h-[110px] overflow-hidden pointer-events-none";
}
return (
@ -357,9 +367,10 @@ export function QuestionSnapList({
containerStyles,
].join(" ")}
>
<div className={wrapperStyles}>
{question}
</div>
<div className={wrapperStyles}>{question}</div>
{isActive && (
<div className="h-[30%] shrink min-h-0" aria-hidden="true" />
)}
</div>
);
})}

11
src/components/questions/question-text.tsx

@ -29,7 +29,8 @@ export default function QuestionText({
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isValidEmail = !isEmailQuestion || emailRegex.test(stringValue);
const showInvalidState = isEmailQuestion && stringValue.length > 0 && !isValidEmail;
const showInvalidState =
isEmailQuestion && stringValue.length > 0 && !isValidEmail;
const isAnswered =
question.required === false
? isValidEmail || stringValue.length === 0
@ -56,7 +57,9 @@ export default function QuestionText({
<input
type="text"
value={String(value ?? "")}
onChange={(e) => setAnswerValue(question, questionIndex, e.target.value)}
onChange={(e) =>
setAnswerValue(question, questionIndex, e.target.value)
}
placeholder={question.extras.placeHolder}
disabled={disabled}
className="h-full w-full border-0 bg-transparent px-[18px] group-12 leading-none text-[#181818] outline-none placeholder:text-[#9D8F8C]"
@ -91,7 +94,9 @@ export default function QuestionText({
<input
type="text"
value={String(value ?? "")}
onChange={(e) => setAnswerValue(question, questionIndex, e.target.value)}
onChange={(e) =>
setAnswerValue(question, questionIndex, e.target.value)
}
placeholder={question.extras.placeHolder}
disabled={disabled}
className="h-[54px] w-full rounded-[15px] border border-[#E7D8D5] bg-white px-4 group-12 font-medium text-[#181818] outline-none transition-all placeholder:text-[#9D8F8C] focus:border-[#6F6F6F] disabled:bg-[#F5F2F1] disabled:text-[#7C7472]"

15
src/components/questions/question-title.tsx

@ -2,9 +2,9 @@
import { useState } from "react";
import { IoEyeOff } from "react-icons/io5";
import HelpModal from "@/components/ui/help-modal";
import type { QuestionField } from "@/data/question-data";
import { useI18n } from "@/translations/provider";
import HelpModal from "@/components/ui/help-modal";
type QuestionTitleProps = {
question: QuestionField;
@ -24,8 +24,7 @@ export function QuestionTitle({ question, className }: QuestionTitleProps) {
className="shrink-0 text-[18px] text-[#D9383A]"
/>
<span>
{t.questions.privateFieldNotice ??
"Private Field (Visible to Habib Marriage advisors only)"}
{t.questions.privateFieldNotice ?? "Private (Advisors Only)"}
</span>
</span>
) : null}
@ -38,10 +37,12 @@ export function QuestionTitle({ question, className }: QuestionTitleProps) {
.filter(Boolean)
.join(" ")}
>
<span>{question.title}</span>
{question.required ? (
<span className="text-[#F2465F] font-bold">*</span>
) : null}
<span className="inline">
{question.title}
{question.required ? (
<span className="text-[#F2465F] font-bold ml-1 inline">*</span>
) : null}
</span>
{question.description ? (
<span className="text-[13.5px] font-normal text-[#667085]">
({question.description})

19
src/components/questions/required-steps-card.tsx

@ -1,17 +1,20 @@
"use client";
import { useMemo } from "react";
import { IoAlert, IoCheckmark } from "react-icons/io5";
import {
getLocalSectionProgress,
getStoredAge,
} from "@/components/questions/progress-helper";
import {
getQuestionListItems,
isQuestionListItemVisibleForProfile,
type QuestionListItem,
} from "@/data/question-data";
import { toFrontendSlug } from "@/data/section-slug-map";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
import { useI18n } from "@/translations/provider";
import { useMemo } from "react";
import { toFrontendSlug } from "@/data/section-slug-map";
import { getStoredAge, getLocalSectionProgress } from "@/components/questions/progress-helper";
type RequiredStepsCardProps = {
items?: QuestionListItem[];
@ -69,10 +72,16 @@ export default function RequiredStepsCard({
let progress = 0;
if (localProgress !== null) {
progress = localProgress;
} else if (progressBySlug && typeof progressBySlug.get(item.slug) === "number") {
} else if (
progressBySlug &&
typeof progressBySlug.get(item.slug) === "number"
) {
progress = progressBySlug.get(item.slug)!;
} else if (section) {
progress = Math.max(0, Math.min(100, Math.round(section.completion_percent)));
progress = Math.max(
0,
Math.min(100, Math.round(section.completion_percent)),
);
} else {
progress = item.progress;
}

36
src/components/questions/test-loading-screen.tsx

@ -3,7 +3,11 @@
import { DotsLoader } from "@/components/ui/button";
import { PageBackground } from "@/components/utils/page-background";
export function AnalyzingIllustration({ className = "w-44 h-44" }: { className?: string }) {
export function AnalyzingIllustration({
className = "w-44 h-44",
}: {
className?: string;
}) {
return (
<svg
viewBox="0 0 160 160"
@ -24,11 +28,35 @@ export function AnalyzingIllustration({ className = "w-44 h-44" }: { className?:
<rect x="50" y="38" width="60" height="26" rx="6" fill="#FFF1F2" />
{/* Small text lines in header */}
<line x1="56" y1="46" x2="76" y2="46" stroke="#FDA4AF" strokeWidth="2.5" strokeLinecap="round" />
<line x1="56" y1="53" x2="70" y2="53" stroke="#FECDD3" strokeWidth="2" strokeLinecap="round" />
<line
x1="56"
y1="46"
x2="76"
y2="46"
stroke="#FDA4AF"
strokeWidth="2.5"
strokeLinecap="round"
/>
<line
x1="56"
y1="53"
x2="70"
y2="53"
stroke="#FECDD3"
strokeWidth="2"
strokeLinecap="round"
/>
{/* Donut chart in header */}
<circle cx="97" cy="51" r="7.5" stroke="#F2465F" strokeWidth="3" fill="none" strokeDasharray="32 10" />
<circle
cx="97"
cy="51"
r="7.5"
stroke="#F2465F"
strokeWidth="3"
fill="none"
strokeDasharray="32 10"
/>
<circle cx="97" cy="51" r="3.5" fill="#E03950" />
{/* Bar chart bars */}

16
src/components/questions/test-questions-flow.tsx

@ -1,13 +1,13 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { GoArrowLeft, GoCheck } from "react-icons/go";
import { IoInformation } from "react-icons/io5";
import Button from "@/components/ui/button";
import NavigationButton from "@/components/ui/navigation-button";
import StickyHeader from "@/components/ui/sticky-header";
import { PageBackground } from "@/components/utils/page-background";
import Button from "@/components/ui/button";
import { useRouter } from "next/navigation";
import TestLoadingScreen from "./test-loading-screen";
export type QuestionOption = {
@ -53,7 +53,9 @@ export default function TestQuestionsFlow({
const currentQuestion = questions[currentIndex] ?? questions[0];
const totalQuestions = questions.length;
const isLastQuestion = currentIndex === totalQuestions - 1;
const selectedValue = currentQuestion ? answers[currentQuestion.id] : undefined;
const selectedValue = currentQuestion
? answers[currentQuestion.id]
: undefined;
const handleOptionSelect = (value: string | number) => {
if (!currentQuestion) return;
@ -185,11 +187,15 @@ export default function TestQuestionsFlow({
: "border-2 border-[#C5C5C8] bg-transparent"
}`}
>
{isSelected ? <GoCheck className="size-4 stroke-[1.5]" /> : null}
{isSelected ? (
<GoCheck className="size-4 stroke-[1.5]" />
) : null}
</div>
{/* Option Text */}
<span className={`group-14 font-semibold leading-snug flex-1 ${isSelected ? "text-white" : "text-[#1B1B1B]"}`}>
<span
className={`group-14 font-semibold leading-snug flex-1 ${isSelected ? "text-white" : "text-[#1B1B1B]"}`}
>
{option.label}
</span>
</button>

2
src/components/sliders/slider-page.tsx

@ -182,7 +182,7 @@ export default function SliderPage() {
</main>
<div className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]">
<div
<div
style={{ paddingBottom: `calc(1rem + var(--safe-bottom))` }}
className="mx-auto w-full sm:max-w-[375px] px-[17px] pt-4"
>

8
src/components/sliders/slider-slide-four.tsx

@ -27,7 +27,9 @@ export function SliderSlideFour({
>
<div className="min-h-0 flex-1 overflow-y-auto pb-28">
<div className="text-center">
<p className="text-[#747474] group-10 font-semibold">Submit Process</p>
<p className="text-[#747474] group-10 font-semibold">
Submit Process
</p>
<p className="text-[#111111] group-16 font-bold">
{index + 1}/4 Registration Type
</p>
@ -82,9 +84,7 @@ export function SliderSlideFour({
/>
</svg>
</span>
<span className="group-14 font-semibold">
{option.label}
</span>
<span className="group-14 font-semibold">{option.label}</span>
</button>
);
})}

24
src/components/sliders/slider-slide-one.tsx

@ -19,7 +19,9 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
}}
>
<div className="text-center">
<p className="text-[#747474] group-10 font-semibold">Submit Process</p>
<p className="text-[#747474] group-10 font-semibold">
Submit Process
</p>
<p className="text-[#111111] group-16 font-bold">
{index + 1}/4 terms & conditions
</p>
@ -48,7 +50,9 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
</p>
<p className="group-12 leading-[1.55]">
<span className="font-bold text-[#262626]">Identity Verification:</span>{" "}
<span className="font-bold text-[#262626]">
Identity Verification:
</span>{" "}
<span className="font-normal text-[#525252]">
Mandatory submission of valid government-issued ID upon
registration.
@ -56,7 +60,9 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
</p>
<p className="group-12 leading-[1.55]">
<span className="font-bold text-[#262626]">Single Status Commitment:</span>{" "}
<span className="font-bold text-[#262626]">
Single Status Commitment:
</span>{" "}
<span className="font-normal text-[#525252]">
Users must provide proof of being single, or documents
confirming divorce or spouse&apos;s death upon request.
@ -72,7 +78,9 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
</p>
<p className="group-12 leading-[1.55]">
<span className="font-bold text-[#262626]">General Health:</span>{" "}
<span className="font-bold text-[#262626]">
General Health:
</span>{" "}
<span className="font-normal text-[#525252]">
Self-declaration regarding mental health, absence of
addiction, and no criminal record.
@ -86,7 +94,9 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
</h3>
<p className="group-12 leading-[1.55]">
<span className="font-bold text-[#262626]">Content Security:</span>{" "}
<span className="font-bold text-[#262626]">
Content Security:
</span>{" "}
<span className="font-normal text-[#525252]">
Technical prevention of screenshots from profiles and chat
environments.
@ -94,7 +104,9 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
</p>
<p className="group-12 leading-[1.55]">
<span className="font-bold text-[#262626]">Progressive Disclosure:</span>{" "}
<span className="font-bold text-[#262626]">
Progressive Disclosure:
</span>{" "}
<span className="font-normal text-[#525252]">
Sensitive information (face, contact details) revealed
step-by-step only with mutual consent.

4
src/components/sliders/slider-slide-two.tsx

@ -9,7 +9,9 @@ export function SliderSlideTwo({ index }: SliderSlideProps) {
>
<div className="min-h-0 flex-1 overflow-y-auto pb-[86px]">
<div className="text-center">
<p className="text-[#747474] group-10 font-semibold">Submit Process</p>
<p className="text-[#747474] group-10 font-semibold">
Submit Process
</p>
<p className="text-[#111111] group-16 font-bold">
{index + 1}/4 Watch Video
</p>

4
src/components/ui/button.tsx

@ -30,7 +30,9 @@ export type ButtonProps = Omit<
export function DotsLoader({ className = "" }: { className?: string }) {
return (
<span className={`inline-flex items-center justify-center gap-1.5 py-0.5 ${className}`}>
<span
className={`inline-flex items-center justify-center gap-1.5 py-0.5 ${className}`}
>
<span className="size-2 rounded-full bg-current animate-dots-slide-1" />
<span className="size-2 rounded-full bg-current animate-dots-slide-2" />
<span className="size-2 rounded-full bg-current animate-dots-slide-3" />

9
src/components/ui/female-consent-sheet.tsx

@ -1,9 +1,9 @@
"use client";
import Image from "next/image";
import type { HTMLAttributes, ReactNode } from "react";
import { useEffect, useState } from "react";
import { useI18n } from "@/translations/provider";
import Image from "next/image";
const EXIT_ANIMATION_MS = 220;
@ -140,7 +140,12 @@ export function FemaleConsentSheet({
aria-label="Close"
className="items-center justify-center rounded-full text-[#A0A0A0]"
>
<Image src={"/assets/images/Vecfadsftor.svg"} alt="close" width={18} height={18}/>
<Image
src={"/assets/images/Vecfadsftor.svg"}
alt="close"
width={18}
height={18}
/>
</button>
</div>

15
src/components/ui/help-modal.tsx

@ -38,8 +38,7 @@ export function HelpModal({
description ??
(t.common as any)?.helpDescription ??
"Psychologically, this practice fosters a sense of empathy and contentment, which can reduce financial stress. Socially, lending strengthens neighborhood bonds and creates support networks that can lead to economic opportunities. This hadith encourages believers to lend dough, bread, and fire to increase their sustenance.";
const resolvedButtonText =
buttonText ?? (t.common as any)?.gotIt ?? "Got it";
const resolvedButtonText = buttonText ?? (t.common as any)?.gotIt ?? "Got it";
const closeSheet = useCallback(() => {
if (isClosing) return;
@ -128,10 +127,11 @@ export function HelpModal({
{(() => {
const items = Array.isArray(resolvedDescription)
? resolvedDescription
: typeof resolvedDescription === "string"
: typeof resolvedDescription === "string" &&
resolvedDescription.includes("\n")
? resolvedDescription
.split(/\.\s+/)
.map((s) => s.replace(/\.$/, "").trim())
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean)
: null;
@ -156,10 +156,7 @@ export function HelpModal({
})()}
<div className="mt-5 w-full">
<Button
onClick={closeSheet}
className="py-[14px]"
>
<Button onClick={closeSheet} className="py-[14px]">
{resolvedButtonText}
</Button>
</div>

8
src/components/ui/navigation-button.tsx

@ -5,8 +5,8 @@ import { useRouter } from "next/navigation";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { useState } from "react";
import { GoArrowLeft } from "react-icons/go";
import { useI18n } from "@/translations/provider";
import HelpModal from "@/components/ui/help-modal";
import { useI18n } from "@/translations/provider";
type NavigationButtonIcon =
| "back"
@ -113,7 +113,9 @@ export function NavigationButton({
const resolvedHelpDescription =
helpDescription ??
(icon === "document" ? (t.questions as any)?.testIntroDisclaimer : undefined);
(icon === "document"
? (t.questions as any)?.testIntroDisclaimer
: undefined);
return (
<>
@ -131,7 +133,7 @@ export function NavigationButton({
if (icon === "back") {
if (typeof window !== "undefined" && (window as any).HabibApp) {
(window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" })
JSON.stringify({ action: "close_service" }),
);
} else {
router.back();

45
src/components/ui/report-actions-sheet.tsx

@ -4,10 +4,10 @@ import { useEffect, useState } from "react";
import Button from "@/components/ui/button";
import { useFlutterBridge } from "@/hooks/useFlutterBridge";
import {
downloadFile,
copyToClipboard,
openExternalUrl,
downloadFile,
isInFlutterWebView,
openExternalUrl,
} from "@/lib/webview-actions";
const EXIT_ANIMATION_MS = 220;
@ -65,7 +65,9 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
mimeType: "application/pdf",
fileType: "document",
});
console.log(sent ? "📥 download_file ارسال شد" : "⚠️ Flutter WebView یافت نشد");
console.log(
sent ? "📥 download_file ارسال شد" : "⚠️ Flutter WebView یافت نشد",
);
};
// ✅ تست copy_to_clipboard
@ -84,7 +86,9 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
mode: "externalApplication",
title: "test_open_url",
});
console.log(sent ? "🔗 open_external_url ارسال شد" : "⚠️ Flutter WebView یافت نشد");
console.log(
sent ? "🔗 open_external_url ارسال شد" : "⚠️ Flutter WebView یافت نشد",
);
};
useEffect(() => {
@ -116,7 +120,9 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
}, [isClosing, onClose]);
useEffect(() => {
const handleFlutterResponse: NonNullable<Window["onFlutterResponse"]> = (event) => {
const handleFlutterResponse: NonNullable<Window["onFlutterResponse"]> = (
event,
) => {
if (event.action === "get_location" && event.success && event.data) {
const message = `Location: ${event.data.latitude}, ${event.data.longitude}`;
alert(message);
@ -134,7 +140,9 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
}
};
const unsubscribe = window.addFlutterResponseListener?.(handleFlutterResponse);
const unsubscribe = window.addFlutterResponseListener?.(
handleFlutterResponse,
);
return () => {
unsubscribe?.();
@ -171,7 +179,9 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
<div className="sticky top-0 bg-[#F9F8F8] p-3.5 border-b border-gray-200/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-800">Settings & Support</h3>
<h3 className="font-semibold text-gray-800">
Settings & Support
</h3>
<div
className={`w-2 h-2 rounded-full ${isReady ? "bg-green-500" : "bg-gray-400"}`}
title={isReady ? "Connected to Flutter" : "Inactive"}
@ -191,7 +201,9 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
{/* Main buttons */}
<div className="flex flex-col gap-2">
<Button onClick={handleGetLocation}>📍 Get Location</Button>
<Button onClick={handleOpenConsultant}>👨 Habib Consultation</Button>
<Button onClick={handleOpenConsultant}>
👨 Habib Consultation
</Button>
</div>
{/* Test WebView Actions */}
@ -206,7 +218,8 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
</div>
{!isInFlutterWebView() && (
<p className="text-[10px] text-purple-600 mt-2">
Outside WebView copy_to_clipboard uses browser fallback, others require Flutter.
Outside WebView copy_to_clipboard uses browser fallback,
others require Flutter.
</p>
)}
</div>
@ -216,9 +229,7 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
<p className="text-xs text-blue-800 mb-2 font-semibold">
🧪 Test Flutter Connection:
</p>
<Button onClick={handleSendWebReady}>
🚀 Send WEB_READY
</Button>
<Button onClick={handleSendWebReady}>🚀 Send WEB_READY</Button>
</div>
{/* Last Received Event */}
@ -228,9 +239,7 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
📥 Last Event from Flutter:
</p>
<div className="bg-white rounded p-2 text-xs font-mono break-all">
<div className="text-green-600 font-bold">
{lastEvent.type}
</div>
<div className="text-green-600 font-bold">{lastEvent.type}</div>
<div className="text-gray-600 mt-1">
{JSON.stringify(lastEvent.payload, null, 2)}
</div>
@ -275,11 +284,9 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
<div className="bg-yellow-50 rounded-lg p-3 border border-yellow-200">
<p className="text-xs text-yellow-900">
<strong>💡 راهنما:</strong>
<br />
WEB_READY هنگام باز شدن این شیت بهصورت خودکار از کانال
<br /> WEB_READY هنگام باز شدن این شیت بهصورت خودکار از کانال
HabibApp ارسال میشود
<br />
نقطهٔ سبز یعنی کانال HabibApp در دسترس است
<br /> نقطهٔ سبز یعنی کانال HabibApp در دسترس است
<br /> برای ارسال دستی، دکمه «ارسال WEB_READY» را بزنید
</p>
</div>

4
src/components/ui/token-switcher.tsx

@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { MdOutlineSwitchAccount } from "react-icons/md";
import { setClientCookie, getClientCookie } from "@/lib/cookies";
import { getClientCookie, setClientCookie } from "@/lib/cookies";
export const FEMALE_TOKEN = "545f61bb3e061ccb9b19f84715eb1b1ed4740331";
export const MALE_TOKEN = "f3a7543b44ef0a713d1ee0d4f7866b3825cf1308";
@ -56,7 +56,7 @@ export function TokenSwitcher({
(window as any).HABIB_TOKEN = newToken;
setCurrentToken(newToken);
setIsOpen(false);
// Navigate to the beginning of the flow (intro page)
window.location.href = "/";
}

5
src/components/utils/dev-click-to-component.tsx

@ -1 +1,4 @@
export { DevClickToComponent, default } from "@/components/dev/dev-click-to-component";
export {
DevClickToComponent,
default,
} from "@/components/dev/dev-click-to-component";

5
src/components/utils/fixed-bottom.tsx

@ -12,10 +12,7 @@ export function FixedBottom({ children, className }: FixedBottomProps) {
const { bottom } = useViewPaddings();
return (
<div
style={{ paddingBottom: `${bottom}px` }}
className={className}
>
<div style={{ paddingBottom: `${bottom}px` }} className={className}>
{children}
</div>
);

2024
src/data/cattell-fallback.ts
File diff suppressed because it is too large
View File

401
src/data/countries.ts

@ -0,0 +1,401 @@
export const COUNTRIES_EN = [
"Afghanistan",
"Albania",
"Algeria",
"Andorra",
"Angola",
"Antigua and Barbuda",
"Argentina",
"Armenia",
"Australia",
"Austria",
"Azerbaijan",
"Bahamas",
"Bahrain",
"Bangladesh",
"Barbados",
"Belarus",
"Belgium",
"Belize",
"Benin",
"Bhutan",
"Bolivia",
"Bosnia and Herzegovina",
"Botswana",
"Brazil",
"Brunei",
"Bulgaria",
"Burkina Faso",
"Burundi",
"Cabo Verde",
"Cambodia",
"Cameroon",
"Canada",
"Central African Republic",
"Chad",
"Chile",
"China",
"Colombia",
"Comoros",
"Congo",
"Costa Rica",
"Croatia",
"Cuba",
"Cyprus",
"Czechia",
"Democratic Republic of the Congo",
"Denmark",
"Djibouti",
"Dominica",
"Dominican Republic",
"Ecuador",
"Egypt",
"El Salvador",
"Equatorial Guinea",
"Eritrea",
"Estonia",
"Eswatini",
"Ethiopia",
"Fiji",
"Finland",
"France",
"Gabon",
"Gambia",
"Georgia",
"Germany",
"Ghana",
"Greece",
"Grenada",
"Guatemala",
"Guinea",
"Guinea-Bissau",
"Guyana",
"Haiti",
"Holy See",
"Honduras",
"Hungary",
"Iceland",
"India",
"Indonesia",
"Iran",
"Iraq",
"Ireland",
"Italy",
"Ivory Coast",
"Jamaica",
"Japan",
"Jordan",
"Kazakhstan",
"Kenya",
"Kiribati",
"Kuwait",
"Kyrgyzstan",
"Laos",
"Latvia",
"Lebanon",
"Lesotho",
"Liberia",
"Libya",
"Liechtenstein",
"Lithuania",
"Luxembourg",
"Madagascar",
"Malawi",
"Malaysia",
"Maldives",
"Mali",
"Malta",
"Marshall Islands",
"Mauritania",
"Mauritius",
"Mexico",
"Micronesia",
"Moldova",
"Monaco",
"Mongolia",
"Montenegro",
"Morocco",
"Mozambique",
"Myanmar",
"Namibia",
"Nauru",
"Nepal",
"Netherlands",
"New Zealand",
"Nicaragua",
"Niger",
"Nigeria",
"North Korea",
"North Macedonia",
"Norway",
"Oman",
"Pakistan",
"Palau",
"Palestine State",
"Panama",
"Papua New Guinea",
"Paraguay",
"Peru",
"Philippines",
"Poland",
"Portugal",
"Qatar",
"Romania",
"Russia",
"Rwanda",
"Saint Kitts and Nevis",
"Saint Lucia",
"Saint Vincent and the Grenadines",
"Samoa",
"San Marino",
"Sao Tome and Principe",
"Saudi Arabia",
"Senegal",
"Serbia",
"Seychelles",
"Sierra Leone",
"Singapore",
"Slovakia",
"Slovenia",
"Solomon Islands",
"Somalia",
"South Africa",
"South Korea",
"South Sudan",
"Spain",
"Sri Lanka",
"Sudan",
"Suriname",
"Sweden",
"Switzerland",
"Syria",
"Tajikistan",
"Tanzania",
"Thailand",
"Timor-Leste",
"Togo",
"Tonga",
"Trinidad and Tobago",
"Tunisia",
"Turkey",
"Turkmenistan",
"Tuvalu",
"Uganda",
"Ukraine",
"United Arab Emirates (UAE)",
"United Kingdom (UK)",
"United States (US)",
"Uruguay",
"Uzbekistan",
"Vanuatu",
"Venezuela",
"Vietnam",
"Yemen",
"Zambia",
"Zimbabwe",
];
export const COUNTRIES_FA = [
"افغانستان",
"آلبانی",
"الجزایر",
"آندورا",
"آنگولا",
"آنتیگوا و باربودا",
"آرژانتین",
"ارمنستان",
"استرالیا",
"اتریش",
"آذربایجان",
"باهاما",
"بحرین",
"بنگلادش",
"باربادوس",
"بلاروس",
"بلژیک",
"بلیز",
"بنین",
"بوتان",
"بولیوی",
"بوسنی و هرزگوین",
"بوتسوانا",
"برزیل",
"برونئی",
"بلغارستان",
"بورکینافاسو",
"بوروندی",
"کیپ ورد",
"کامبوج",
"کامرون",
"کانادا",
"جمهوری آفریقای مرکزی",
"چاد",
"شیلی",
"چین",
"کلمبیا",
"کومور",
"کنگو",
"کاستاریکا",
"کرواسی",
"کوبا",
"قبرس",
"جمهوری چک",
"جمهوری دموکراتیک کنگو",
"دانمارک",
"جیبوتی",
"دومینیکا",
"جمهوری دومینیکن",
"اکوادور",
"مصر",
"السالوادور",
"گینه استوایی",
"اریتره",
"استونی",
"اسواتینی",
"اتیوپی",
"فیجی",
"فنلاند",
"فرانسه",
"گابن",
"Gambia",
"گرجستان",
"آلمان",
"غنا",
"یونان",
"گرنادا",
"گواتمالا",
"گینه",
"گینه بیسائو",
"گویان",
"هایتی",
"واتیکان",
"هندوراس",
"مجارستان",
"ایسلند",
"هند",
"اندونزی",
"ایران",
"عراق",
"ایرلند",
"ایتالیا",
"ساحل عاج",
"جامائیکا",
"ژاپن",
"اردن",
"قزاقستان",
"کنیا",
"کیریباتی",
"کویت",
"قرقیزستان",
"لائوس",
"لتونی",
"لبنان",
"لسوتو",
"لیبریا",
"لیبی",
"لیختن‌اشتاین",
"لیتوانی",
"لوکزامبورگ",
"ماداگاسکار",
"مالاوی",
"مالزی",
"مالدیو",
"مالی",
"مالت",
"جزایر مارشال",
"موریتانی",
"موریس",
"مکزیک",
"میکرونزی",
"مولداوی",
"موناکو",
"مغولستان",
"مونته‌نگرو",
"مراکش",
"موزامبیک",
"میانمار",
"نامیبیا",
"نائورو",
"نپال",
"هلند",
"نیوزیلند",
"نیکاراگوئه",
"نیجر",
"نیجریه",
"کره شمالی",
"مقدونیه شمالی",
"نروژ",
"عمان",
"پاکستان",
"پالائو",
"فلسطین",
"پاناما",
"پاپوآ گینه نو",
"پاراگوئه",
"پرو",
"فیلیپین",
"لهستان",
"پرتغال",
"قطر",
"رومانی",
"روسیه",
"رواندا",
"سنت کیتس و نویس",
"سنت لوسیا",
"سنت وینسنت و گرنادین‌ها",
"ساموآ",
"سان مارینو",
"سائوتومه و پرنسیپ",
"عربستان سعودی",
"سنگال",
"صربستان",
"سیشل",
"سیرالئون",
"سنگاپور",
"اسلواکی",
"اسلوونی",
"جزایر سلیمان",
"سومالی",
"آفریقای جنوبی",
"کره جنوبی",
"سودان جنوبی",
"اسپانیا",
"سری‌لانکا",
"سودان",
"سورینام",
"سوئد",
"سوئیس",
"سوریه",
"تاجیکستان",
"تانزانیا",
"تایلند",
"تیمور شرقی",
"توگو",
"تونگا",
"ترینیداد و توباگو",
"تونس",
"ترکیه",
"ترکمنستان",
"تووالو",
"اوگاندا",
"اوکراین",
"امارات متحده عربی (UAE)",
"بریتانیا (UK)",
"ایالات متحده آمریکا (US)",
"اروگوئه",
"ازبکستان",
"وانواتو",
"ونزوئلا",
"ویتنام",
"یمن",
"زامبیا",
"زیمبابوه",
];
export function getCountryList(locale: string): string[] {
const normalized = String(locale || "en").toLowerCase();
if (normalized === "fa" || normalized === "fa-ir") {
return COUNTRIES_FA;
}
return COUNTRIES_EN;
}

72
src/data/glasser-fallback.ts

@ -8,176 +8,176 @@ export const glasserFallbackQuestions: GlasserFallbackQuestion[] = [
{
question_number: 1,
factor_code: "S",
text: "مسایلی مثل پس انداز، مخارج زندگی، مسکن، آینده شغلی و.. تا چه اندازه ذهن شما را به خود مشغول می دارد؟"
text: "مسایلی مثل پس انداز، مخارج زندگی، مسکن، آینده شغلی و.. تا چه اندازه ذهن شما را به خود مشغول می دارد؟",
},
{
question_number: 2,
factor_code: "S",
text: "تا چه اندازه به سلامت جسمانی، بهداشت و احتمال ابتلا به بیماری فکر میکنید؟"
text: "تا چه اندازه به سلامت جسمانی، بهداشت و احتمال ابتلا به بیماری فکر میکنید؟",
},
{
question_number: 3,
factor_code: "S",
text: "شدت میل جنسی خود را چگونه ارزیابی میکنید؟"
text: "شدت میل جنسی خود را چگونه ارزیابی میکنید؟",
},
{
question_number: 4,
factor_code: "S",
text: "در انجام کارها و اقدامات مخاطره انگیز تا چه اندازه محتاطانه عمل میکنید؟"
text: "در انجام کارها و اقدامات مخاطره انگیز تا چه اندازه محتاطانه عمل میکنید؟",
},
{
question_number: 5,
factor_code: "S",
text: "از روبه رویی با تجارب جدید و شروع راه های ناشناخته تا چه اندازه اجتناب میکنید؟"
text: "از روبه رویی با تجارب جدید و شروع راه های ناشناخته تا چه اندازه اجتناب میکنید؟",
},
{
question_number: 6,
factor_code: "S",
text: "از نظر دوستان و همکاران خود چقدر وقت شناس با نظم و ترتیب و دقیق هستید؟"
text: "از نظر دوستان و همکاران خود چقدر وقت شناس با نظم و ترتیب و دقیق هستید؟",
},
{
question_number: 7,
factor_code: "S",
text: "تا چه اندازه امنیت شغلی و درآمد ثابت برایتان اهمیت دارد؟"
text: "تا چه اندازه امنیت شغلی و درآمد ثابت برایتان اهمیت دارد؟",
},
{
question_number: 8,
factor_code: "L",
text: "احساس می کنید به چه میزان عشق، صمیمیت و مهرورزی نیاز دارید؟"
text: "احساس می کنید به چه میزان عشق، صمیمیت و مهرورزی نیاز دارید؟",
},
{
question_number: 9,
factor_code: "L",
text: "تا چه اندازه رفاه و سعادت انسان های دیگر برایتان مهم است؟"
text: "تا چه اندازه رفاه و سعادت انسان های دیگر برایتان مهم است؟",
},
{
question_number: 10,
factor_code: "L",
text: "تا چه اندازه تمایل دارید تجارب شخصی، احساسات عمیق و اسرار خود را با دیگران در میان بگذارید؟"
text: "تا چه اندازه تمایل دارید تجارب شخصی، احساسات عمیق و اسرار خود را با دیگران در میان بگذارید؟",
},
{
question_number: 11,
factor_code: "L",
text: "در ایجاد صمیمیت و برقراری ارتباط عاطفی با دیگران چقدر پیش قدم می شوید؟"
text: "در ایجاد صمیمیت و برقراری ارتباط عاطفی با دیگران چقدر پیش قدم می شوید؟",
},
{
question_number: 12,
factor_code: "L",
text: "تا چه اندازه از دیدن، وقت گذراندن و گفتگو با دوستان و آشنایان خود لذت می برید؟"
text: "تا چه اندازه از دیدن، وقت گذراندن و گفتگو با دوستان و آشنایان خود لذت می برید؟",
},
{
question_number: 13,
factor_code: "L",
text: "میزان بخشندگی، گذشت و فراموش کردن خطای دیگران را در خود چگونه ارزیابی می کنید؟"
text: "میزان بخشندگی، گذشت و فراموش کردن خطای دیگران را در خود چگونه ارزیابی می کنید؟",
},
{
question_number: 14,
factor_code: "L",
text: "تا چه اندازه به ایجاد و حفظ روابط دوستانه، صمیمی و پایدار با دیگران علاقه مند هستید؟"
text: "تا چه اندازه به ایجاد و حفظ روابط دوستانه، صمیمی و پایدار با دیگران علاقه مند هستید؟",
},
{
question_number: 15,
factor_code: "P",
text: "تا چه اندازه تمایل دارید رهبری گروه‌ها، هدایت پروژه‌ها یا مدیریت امور را بر عهده بگیرید؟"
text: "تا چه اندازه تمایل دارید رهبری گروه‌ها، هدایت پروژه‌ها یا مدیریت امور را بر عهده بگیرید؟",
},
{
question_number: 16,
factor_code: "P",
text: "رسیدن به موفقیت، موقعیت اجتماعی ممتاز و جایگاه برتر تا چه حد برایتان اهمیت دارد؟"
text: "رسیدن به موفقیت، موقعیت اجتماعی ممتاز و جایگاه برتر تا چه حد برایتان اهمیت دارد؟",
},
{
question_number: 17,
factor_code: "P",
text: "در صورت بروز نقد، مخالفت یا اشتباه از سوی دیگران، تا چه حد رفتار تند، قاطع یا اصلاحی نشان می‌دهید؟"
text: "در صورت بروز نقد، مخالفت یا اشتباه از سوی دیگران، تا چه حد رفتار تند، قاطع یا اصلاحی نشان می‌دهید؟",
},
{
question_number: 18,
factor_code: "P",
text: "تا چه اندازه تمایل دارید کارها دقیقاً طبق نظرات، استانداردها و روش‌های شما انجام شوند؟"
text: "تا چه اندازه تمایل دارید کارها دقیقاً طبق نظرات، استانداردها و روش‌های شما انجام شوند؟",
},
{
question_number: 19,
factor_code: "P",
text: "در بحث‌ها و گفتگوها تا چه حد اصرار دارید حرف و نظر خود را به عنوان نظر درست به اثبات برسانید؟"
text: "در بحث‌ها و گفتگوها تا چه حد اصرار دارید حرف و نظر خود را به عنوان نظر درست به اثبات برسانید؟",
},
{
question_number: 20,
factor_code: "P",
text: "وقتی احساس می‌کنید حق با شماست، تا چه اندازه حاضر به رقابت یا پافشاری بر روی خواسته‌هایتان هستید؟"
text: "وقتی احساس می‌کنید حق با شماست، تا چه اندازه حاضر به رقابت یا پافشاری بر روی خواسته‌هایتان هستید؟",
},
{
question_number: 21,
factor_code: "P",
text: "دیدگاه شما نسبت به یادگیری، کسب دانش و بالا بردن اطلاعات تخصصی چیست؟"
text: "دیدگاه شما نسبت به یادگیری، کسب دانش و بالا بردن اطلاعات تخصصی چیست؟",
},
{
question_number: 22,
factor_code: "Fr",
text: "تا چه اندازه تمایل دارید بدون دخالت، نظارت یا دستور دیگران اهداف شخصی خود را دنبال کنید؟"
text: "تا چه اندازه تمایل دارید بدون دخالت، نظارت یا دستور دیگران اهداف شخصی خود را دنبال کنید؟",
},
{
question_number: 23,
factor_code: "Fr",
text: "در برابر احساس محدودیت، اجبار یا رعایت قوانین دست و پا گیر تا چه حد واکنش نشان می‌دهید؟"
text: "در برابر احساس محدودیت، اجبار یا رعایت قوانین دست و پا گیر تا چه حد واکنش نشان می‌دهید؟",
},
{
question_number: 24,
factor_code: "Fr",
text: "میزان تمایل شما به داشتن حریم خصوصی، زمان تنهایی و استقلال در تصمیم‌گیری‌ها چقدر است؟"
text: "میزان تمایل شما به داشتن حریم خصوصی، زمان تنهایی و استقلال در تصمیم‌گیری‌ها چقدر است؟",
},
{
question_number: 25,
factor_code: "Fr",
text: "تا چه اندازه رهایی از قید و بندها و برنامه‌های فشرده را ترجیح می‌دهید؟"
text: "تا چه اندازه رهایی از قید و بندها و برنامه‌های فشرده را ترجیح می‌دهید؟",
},
{
question_number: 26,
factor_code: "Fr",
text: "در مواجهه با فشار روانی یا استرس، تا چه حد نیاز دارید فضا و وقت مستقل داشته باشید؟"
text: "در مواجهه با فشار روانی یا استرس، تا چه حد نیاز دارید فضا و وقت مستقل داشته باشید؟",
},
{
question_number: 27,
factor_code: "Fr",
text: "تا چه اندازه تمایل دارید مسیر زندگی، سبک پوشش یا رفتارهای خود را بر اساس معیارهای فردی انتخاب کنید؟"
text: "تا چه اندازه تمایل دارید مسیر زندگی، سبک پوشش یا رفتارهای خود را بر اساس معیارهای فردی انتخاب کنید؟",
},
{
question_number: 28,
factor_code: "Fr",
text: "چقدر برای آزادی اندیشه و ابراز دیدگاه‌های غیرمعمول اهمیت قائل هستید؟"
text: "چقدر برای آزادی اندیشه و ابراز دیدگاه‌های غیرمعمول اهمیت قائل هستید؟",
},
{
question_number: 29,
factor_code: "Fu",
text: "تا چه اندازه اهل شوخ‌طبعی، خنده، لطیفه‌گویی و ایجاد فضای شاد هستید؟"
text: "تا چه اندازه اهل شوخ‌طبعی، خنده، لطیفه‌گویی و ایجاد فضای شاد هستید؟",
},
{
question_number: 30,
factor_code: "Fu",
text: "چقدر زمان و هزینه برای سرگرمی، تفریحات بی‌دغدغه و فعالیت‌های مفرح اختصاص می‌دهید؟"
text: "چقدر زمان و هزینه برای سرگرمی، تفریحات بی‌دغدغه و فعالیت‌های مفرح اختصاص می‌دهید؟",
},
{
question_number: 31,
factor_code: "Fu",
text: "تا چه اندازه از کارهای خلاقانه، هنری، بازی و سرگرمی لذت می‌برید؟"
text: "تا چه اندازه از کارهای خلاقانه، هنری، بازی و سرگرمی لذت می‌برید؟",
},
{
question_number: 32,
factor_code: "Fu",
text: "چقدر قادر هستید لحظات سخت و جدیت زندگی را با نگاهی سبک، طنزآمیز و مثبت سپری کنید؟"
text: "چقدر قادر هستید لحظات سخت و جدیت زندگی را با نگاهی سبک، طنزآمیز و مثبت سپری کنید؟",
},
{
question_number: 33,
factor_code: "Fu",
text: "تمایل شما به شرکت در دورهمی‌ها، برنامه‌های تفریحی و سفر با دوستان چقدر است؟"
text: "تمایل شما به شرکت در دورهمی‌ها، برنامه‌های تفریحی و سفر با دوستان چقدر است؟",
},
{
question_number: 34,
factor_code: "Fu",
text: "چقدر برای داشتن سرگرمی‌ها (هابی) و فعالیت‌های غیرکاری وقت می‌گذارید؟"
text: "چقدر برای داشتن سرگرمی‌ها (هابی) و فعالیت‌های غیرکاری وقت می‌گذارید؟",
},
{
question_number: 35,
factor_code: "Fu",
text: "از یادگیری چیزهای جدید همراه با بازی، هیجان و نشاط چقدر استقبال می‌کنید؟"
}
text: "از یادگیری چیزهای جدید همراه با بازی، هیجان و نشاط چقدر استقبال می‌کنید؟",
},
];

157
src/data/languages.ts

@ -0,0 +1,157 @@
export const LANGUAGES_EN = [
"Afrikaans",
"Albanian",
"Amharic",
"Arabic",
"Armenian",
"Azerbaijani",
"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",
"Kyrgyz",
"Latvian",
"Lithuanian",
"Macedonian",
"Malay",
"Maltese",
"Mongolian",
"Nepali",
"Norwegian",
"Pashto",
"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 const LANGUAGES_FA = [
"آذربایجانی",
"آفریکانس",
"آلمانی",
"آلبانیایی",
"امهری",
"اردو",
"اردو (لاتین)",
"ارمنی",
"ازبکی",
"اسپانیایی",
"استونیایی",
"اسلواکی",
"اسلوونیایی",
"انگلیسی",
"اندونزیایی",
"اوکراینی",
"ایتالیایی",
"ایرلندی",
"ایسلندی",
"بنگالی",
"بوسنیایی",
"بلغاری",
"پشتو",
"پنجابی",
"پرتغالی",
"تاجیکی",
"تامیلی",
"تایلندی",
"تلوگو",
"ترکی",
"ترکمنی",
"چکی",
"چینی",
"دانمارکی",
"روسی",
"رومانیایی",
"ژاپنی",
"سواحیلی",
"سوئدی",
"سینهالی",
"صربی",
"سومالیایی",
"عربی",
"عبری",
"فارسی",
"فرانسوی",
"فنلاندی",
"قزاقی",
"قرقیزی",
"کاتالان",
"کره‌ای",
"کرواتی",
"کشمیری",
"گجراتی",
"گرجی",
"لتونیایی",
"لیتوانیایی",
"لهستانی",
"مالایی",
"مالتی",
"مجاری",
"مقدونیه‌ای",
"مغولی",
"میانماری (برمه‌ای)",
"نپالی",
"نروژی",
"هلندی",
"ویتنامی",
"هندی",
"هوسا",
"سایر",
];
export function getLanguageList(locale: string): string[] {
const normalized = String(locale || "en").toLowerCase();
if (normalized === "fa" || normalized === "fa-ir") {
return LANGUAGES_FA;
}
return LANGUAGES_EN;
}

283
src/data/nationalities.ts

@ -0,0 +1,283 @@
export const NATIONALITIES_EN = [
"Afghan",
"Albanian",
"Algerian",
"American (US)",
"Andorran",
"Angolan",
"Antiguan",
"Argentinian",
"Armenian",
"Australian",
"Austrian",
"Azerbaijani",
"Bahamian",
"Bahraini",
"Bangladeshi",
"Barbadian",
"Belarusian",
"Belgian",
"Belizean",
"Beninese",
"Bhutanese",
"Bolivian",
"Bosnian",
"Botswanan",
"Brazilian",
"British (UK)",
"Bruneian",
"Bulgarian",
"Burkinabè",
"Burmese",
"Burundian",
"Cabo Verdean",
"Cambodian",
"Cameroonian",
"Canadian",
"Central African",
"Chadian",
"Chilean",
"Chinese",
"Colombian",
"Comorian",
"Congolese",
"Costa Rican",
"Croatian",
"Cuban",
"Cypriot",
"Czech",
"Danish",
"Djiboutian",
"Dominican",
"Dutch",
"Ecuadorian",
"Egyptian",
"Emirati (UAE)",
"Equatorial Guinean",
"Eritrean",
"Estonian",
"Ethiopian",
"Fijian",
"Filipino",
"Finnish",
"French",
"Gabonese",
"Gambian",
"Georgian",
"German",
"Ghanaian",
"Greek",
"Grenadian",
"Guatemalan",
"Guinean",
"Guyanese",
"Haitian",
"Honduran",
"Hungarian",
"Icelandic",
"Indian",
"Indonesian",
"Iranian",
"Iraqi",
"Irish",
"Italian",
"Ivorian",
"Jamaican",
"Japanese",
"Jordanian",
"Kazakhstani",
"Kenyan",
"Kuwaiti",
"Kyrgyzstani",
"Laotian",
"Latvian",
"Lebanese",
"Liberian",
"Libyan",
"Liechtensteiner",
"Lithuanian",
"Luxembourger",
"Macedonian",
"Malagasy",
"Malawian",
"Malaysian",
"Maldivian",
"Malian",
"Maltese",
"Mauritanian",
"Mauritian",
"Mexican",
"Micronesian",
"Moldovan",
"Monégasque",
"Mongolian",
"Montenegrin",
"Moroccan",
"Mozambican",
"Namibian",
"Nauruan",
"Nepalese",
"New Zealander",
"Nicaraguan",
"Nigerien",
"Nigerian",
"North Korean",
"Norwegian",
"Omani",
"Pakistani",
"Palestinian",
"Panamanian",
"Papua New Guinean",
"Paraguayan",
"Peruvian",
"Polish",
"Portuguese",
"Qatari",
"Romanian",
"Russian",
"Rwandan",
"Saudi",
"Senegalese",
"Serbian",
"Seychellois",
"Sierra Leonean",
"Singaporean",
"Slovak",
"Slovenian",
"Somali",
"South African",
"South Korean",
"Spanish",
"Sri Lankan",
"Sudanese",
"Surinamese",
"Swazi",
"Swedish",
"Swiss",
"Syrian",
"Tajikistani",
"Tanzanian",
"Thai",
"Togolese",
"Tongan",
"Tunisian",
"Turkish",
"Turkmen",
"Ugandan",
"Ukrainian",
"Uruguayan",
"Uzbekistani",
"Venezuelan",
"Vietnamese",
"Yemeni",
"Zambian",
"Zimbabwean",
"Other",
];
export const NATIONALITIES_FA = [
"آذربایجانی",
"آرژانتینی",
"آلمانی",
"آلبانیایی",
"آمریکایی (US)",
"آندورایی",
"آنگولایی",
"اتریشی",
"اتیوپیایی",
"ارمنی",
"اردنی",
"اروگوئه‌ای",
"ازبکستانی",
"اسپانیایی",
"استرالیایی",
"استونیایی",
"اسلواکی",
"اسلوونیایی",
"افغانستانی",
"الجزایری",
"اماراتی (UAE)",
"اندونزیایی",
"انگلیسی",
"ایتالیایی",
"ایرانی",
"ایرلندی",
"بحرینی",
"برزیلی",
"بریتانیایی (UK)",
"بلغارستانی",
"بنگلادشی",
"بلژیکی",
"بلاروسی",
"بوسنیایی",
"پاکستانی",
"پرتغالی",
"پروایی",
"تاجیکستانی",
"تایلندی",
"ترکیه‌ای",
"تونسی",
"چکی",
"چینی",
"دانمارکی",
"روسی",
"رومانیایی",
"ژاپنی",
"سری‌لانکایی",
"سعودی",
"سنگاپوری",
"سوری",
"سومالیایی",
"سوئدی",
"سوئیسی",
"شیلیایی",
"عراقی",
"عربستانی",
"عمانی",
"فرانسوی",
"فلسطینی",
"فیلیپینی",
"فنلاندی",
"قزاقستانی",
"قطری",
"قرقیزستانی",
"کره جنوبی",
"کره شمالی",
"کلمبیایی",
"کامرونی",
"کانادایی",
"کنگویی",
"کوبایی",
"کویتی",
"کنیایی",
"گرجستانی",
"لبنانی",
"لهستانی",
"لیبیایی",
"لیتوانیایی",
"مالزیایی",
"مالدیوی",
"مراکشی",
"مصر",
"مکزیکی",
"مولداویایی",
"مغولی",
"نامیبیاایی",
"نپالی",
"نروژی",
"نیوزیلندی",
"نیجریه‌ای",
"هلندی",
"ونزوئلایی",
"ویتنامی",
"یمنی",
"سایر",
];
export function getNationalityList(locale: string): string[] {
const normalized = String(locale || "en").toLowerCase();
if (normalized === "fa" || normalized === "fa-ir") {
return NATIONALITIES_FA;
}
return NATIONALITIES_EN;
}

10
src/data/question-data.ts

@ -1,7 +1,7 @@
import type { MarriageGender } from "@/hooks/marriage/types";
import { defaultLocale, type Locale } from "@/translations/config";
import enQuestions from "@/data/questions/en.json";
import faQuestions from "@/data/questions/fa.json";
import type { MarriageGender } from "@/hooks/marriage/types";
import { defaultLocale, type Locale } from "@/translations/config";
export const bookingTerms = [
"You will be contacted by your consultant.",
@ -108,7 +108,11 @@ function mapQuestionListItem(item: RawQuestionListItem): QuestionListItem {
}
export function getQuestionListItems(locale: Locale = defaultLocale) {
const items = questionsByLocale[locale] ?? questionsByLocale[defaultLocale] ?? questionsByLocale.en ?? [];
const items =
questionsByLocale[locale] ??
questionsByLocale[defaultLocale] ??
questionsByLocale.en ??
[];
return items.map(mapQuestionListItem);
}

660
src/data/questions/en.json
File diff suppressed because it is too large
View File

660
src/data/questions/fa.json
File diff suppressed because it is too large
View File

14
src/hooks/marriage/query-keys.ts

@ -14,11 +14,21 @@ export const marriageQueryKeys = {
[...marriageQueryKeys.sections(), slug, "data"] as const,
sections: () => [...marriageQueryKeys.all, "sections"] as const,
cattellQuestions: (lang?: string) =>
[...marriageQueryKeys.all, "cattell", "questions", lang ?? "default"] as const,
[
...marriageQueryKeys.all,
"cattell",
"questions",
lang ?? "default",
] as const,
cattellResult: (lang?: string) =>
[...marriageQueryKeys.all, "cattell", "result", lang ?? "default"] as const,
glasserQuestions: (lang?: string) =>
[...marriageQueryKeys.all, "glasser", "questions", lang ?? "default"] as const,
[
...marriageQueryKeys.all,
"glasser",
"questions",
lang ?? "default",
] as const,
glasserResult: (lang?: string) =>
[...marriageQueryKeys.all, "glasser", "result", lang ?? "default"] as const,
};

10
src/hooks/marriage/types.ts

@ -74,7 +74,14 @@ export type MarriageActiveCase = {
contact_shared_at?: string | null;
minutes_since_contact_shared?: number | null;
can_report_no_contact?: boolean;
my_action?: "pending" | "waiting" | "rejected" | "other_rejected" | "payment_required" | "completed" | string;
my_action?:
| "pending"
| "waiting"
| "rejected"
| "other_rejected"
| "payment_required"
| "completed"
| string;
other_action?: string;
};
@ -242,4 +249,3 @@ export type GlasserSubmitResponse = {
export type GlasserResultResponse = {
results: Record<string, unknown>;
};

3
src/hooks/marriage/use-section-data.ts

@ -1,6 +1,7 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toBackendSlug } from "@/data/section-slug-map";
import { http } from "@/lib/http";
import type { MutationOptions, QueryOptions } from "./options";
import { pathParam } from "./path-param";
@ -10,8 +11,6 @@ import type {
UpdateMarriageSectionDataPayload,
} from "./types";
import { toBackendSlug } from "@/data/section-slug-map";
export async function getMarriageSectionData(slug: string) {
const backendSlug = toBackendSlug(slug);
const { data } = await http.get<MarriageSectionData>(

6
src/hooks/use-close-service-on-back.ts

@ -23,7 +23,11 @@ export function useCloseServiceOnBack(enabled: boolean = true) {
const handlePopState = () => {
if ((window as any).HabibApp?.postMessage) {
// Keep the state pinned in webview and notify Flutter to close the webview
window.history.pushState({ closeOnBack: true }, "", window.location.href);
window.history.pushState(
{ closeOnBack: true },
"",
window.location.href,
);
(window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" }),
);

114
src/hooks/useFlutterBridge.ts

@ -3,16 +3,16 @@
* دریافت و ارسال ایونتها
*/
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from "react";
// تایپ‌های ایونت‌های دریافتی از Flutter
export type FlutterEventType =
| 'LAYOUT_CHANGED'
| 'KEYBOARD_CHANGED'
| 'SAFE_AREA_CHANGED'
| 'ORIENTATION_CHANGED'
| 'SCREEN_SIZE_CHANGED'
| 'INITIAL_CONFIG';
| "LAYOUT_CHANGED"
| "KEYBOARD_CHANGED"
| "SAFE_AREA_CHANGED"
| "ORIENTATION_CHANGED"
| "SCREEN_SIZE_CHANGED"
| "INITIAL_CONFIG";
export interface FlutterEvent {
type: FlutterEventType;
@ -22,11 +22,11 @@ export interface FlutterEvent {
// تایپ‌های ایونت‌های ارسالی به Flutter
export type WebEventType =
| 'WEB_READY'
| 'REQUEST_LOCATION'
| 'REQUEST_CONSULTANT'
| 'PAGE_LOADED'
| 'ERROR_OCCURRED';
| "WEB_READY"
| "REQUEST_LOCATION"
| "REQUEST_CONSULTANT"
| "PAGE_LOADED"
| "ERROR_OCCURRED";
export interface WebEvent {
type: WebEventType;
@ -69,34 +69,37 @@ const WEB_EVENT_TO_ACTION: Record<WebEventType, string> = {
};
export function useFlutterBridge(
options: UseFlutterBridgeOptions = {}
options: UseFlutterBridgeOptions = {},
): UseFlutterBridgeReturn {
const { onEvent, enableLogging = true } = options;
const [events, setEvents] = useState<FlutterEvent[]>([]);
const [lastEvent, setLastEvent] = useState<FlutterEvent | null>(null);
const [isReady, setIsReady] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const logsRef = useRef<string[]>([]);
const hasSentReadyRef = useRef(false);
// تابع کمکی برای اضافه کردن لاگ
const addLog = (message: string, type: 'info' | 'success' | 'error' = 'info') => {
const timestamp = new Date().toLocaleTimeString('fa-IR');
const addLog = (
message: string,
type: "info" | "success" | "error" = "info",
) => {
const timestamp = new Date().toLocaleTimeString("fa-IR");
const logMessage = `[${timestamp}] [${type.toUpperCase()}] ${message}`;
if (enableLogging) {
console.log(logMessage);
}
logsRef.current = [...logsRef.current, logMessage].slice(-50); // نگه داشتن 50 لاگ آخر
setLogs(logsRef.current);
};
// تابع ارسال ایونت به Flutter
const sendToFlutter = (type: WebEventType, payload?: any) => {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
const action = WEB_EVENT_TO_ACTION[type];
let delivered = false;
@ -109,14 +112,17 @@ export function useFlutterBridge(
payload !== undefined ? { action, data: payload } : { action };
window.HabibApp.postMessage(JSON.stringify(message));
delivered = true;
addLog(`📤 ارسال به Flutter (HabibApp): ${type}${action}`, 'success');
addLog(
`📤 ارسال به Flutter (HabibApp): ${type}${action}`,
"success",
);
}
// مسیرهای fallback فقط برای محیط توسعه/iframe (در WebView واقعی Flutter وجود ندارند)
const event: WebEvent = { type, payload, timestamp: Date.now() };
if (window.parent && window.parent !== window) {
window.parent.postMessage(event, '*');
window.parent.postMessage(event, "*");
delivered = true;
}
@ -126,28 +132,29 @@ export function useFlutterBridge(
}
if ((window as any).webkit?.messageHandlers?.FlutterChannel) {
(window as any).webkit.messageHandlers.FlutterChannel.postMessage(event);
(window as any).webkit.messageHandlers.FlutterChannel.postMessage(
event,
);
delivered = true;
}
if (!delivered) {
addLog(`⚠️ محیط Flutter یافت نشد - در حالت توسعه`, 'info');
addLog(`⚠️ محیط Flutter یافت نشد - در حالت توسعه`, "info");
}
} catch (error) {
addLog(`❌ خطا در ارسال به Flutter: ${error}`, 'error');
console.error('Error sending to Flutter:', error);
addLog(`❌ خطا در ارسال به Flutter: ${error}`, "error");
console.error("Error sending to Flutter:", error);
}
};
// Listen کردن به ایونت‌های Flutter
useEffect(() => {
addLog('🚀 شروع listening به ایونت‌های Flutter', 'info');
addLog("🚀 شروع listening به ایونت‌های Flutter", "info");
const handleFlutterEvent = (event: MessageEvent) => {
try {
const data = typeof event.data === 'string'
? JSON.parse(event.data)
: event.data;
const data =
typeof event.data === "string" ? JSON.parse(event.data) : event.data;
// چک کردن اینکه ایونت از Flutter است
if (data && data.type) {
@ -157,15 +164,15 @@ export function useFlutterBridge(
timestamp: data.timestamp || Date.now(),
};
addLog(`📥 دریافت از Flutter: ${data.type}`, 'success');
addLog(`📥 دریافت از Flutter: ${data.type}`, "success");
setEvents((prev) => [...prev, flutterEvent].slice(-20)); // نگه داشتن 20 ایونت آخر
setLastEvent(flutterEvent);
// Handle INITIAL_CONFIG
if (data.type === 'INITIAL_CONFIG') {
if (data.type === "INITIAL_CONFIG") {
setIsReady(true);
addLog('✅ WebView آماده شد', 'success');
addLog("✅ WebView آماده شد", "success");
}
// Callback
@ -174,13 +181,13 @@ export function useFlutterBridge(
}
}
} catch (error) {
addLog(`❌ خطا در parse ایونت Flutter: ${error}`, 'error');
console.error('Error parsing Flutter event:', error);
addLog(`❌ خطا در parse ایونت Flutter: ${error}`, "error");
console.error("Error parsing Flutter event:", error);
}
};
// Listen به message event
window.addEventListener('message', handleFlutterEvent);
window.addEventListener("message", handleFlutterEvent);
// Listen به custom events
const handleCustomEvent = (event: Event) => {
@ -192,15 +199,24 @@ export function useFlutterBridge(
}
};
window.addEventListener('flutterConfig', handleCustomEvent as EventListener);
window.addEventListener('flutterEvent', handleCustomEvent as EventListener);
window.addEventListener(
"flutterConfig",
handleCustomEvent as EventListener,
);
window.addEventListener("flutterEvent", handleCustomEvent as EventListener);
// Cleanup
return () => {
window.removeEventListener('message', handleFlutterEvent);
window.removeEventListener('flutterConfig', handleCustomEvent as EventListener);
window.removeEventListener('flutterEvent', handleCustomEvent as EventListener);
addLog('🛑 متوقف کردن listening', 'info');
window.removeEventListener("message", handleFlutterEvent);
window.removeEventListener(
"flutterConfig",
handleCustomEvent as EventListener,
);
window.removeEventListener(
"flutterEvent",
handleCustomEvent as EventListener,
);
addLog("🛑 متوقف کردن listening", "info");
};
}, [onEvent, enableLogging]);
@ -209,7 +225,7 @@ export function useFlutterBridge(
// تزریق می‌شود؛ وجودش یعنی پل برقرار است. منتظر INITIAL_CONFIG نمی‌مانیم،
// چون Flutter چنین ایونتی نمی‌فرستد.
useEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
let interval: ReturnType<typeof setInterval> | undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;
@ -221,12 +237,12 @@ export function useFlutterBridge(
if (!hasSentReadyRef.current) {
hasSentReadyRef.current = true;
sendToFlutter('WEB_READY', {
sendToFlutter("WEB_READY", {
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: Date.now(),
});
addLog('✅ WEB_READY به‌صورت خودکار ارسال شد', 'success');
addLog("✅ WEB_READY به‌صورت خودکار ارسال شد", "success");
}
return true;
@ -252,18 +268,18 @@ export function useFlutterBridge(
// گوش‌دادن به پاسخ‌های واقعی Flutter از مسیر window.onFlutterResponse.
// رسیدن هر پاسخ یعنی ارتباط دوطرفه برقرار است.
useEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
const unsubscribe = window.addFlutterResponseListener?.((response) => {
setIsReady(true);
const flutterEvent: FlutterEvent = {
type: 'INITIAL_CONFIG',
type: "INITIAL_CONFIG",
payload: response,
timestamp: response?.data?.timestamp || Date.now(),
};
addLog(`📥 پاسخ از Flutter: ${response?.action}`, 'success');
addLog(`📥 پاسخ از Flutter: ${response?.action}`, "success");
setEvents((prev) => [...prev, flutterEvent].slice(-20));
setLastEvent(flutterEvent);
onEvent?.(flutterEvent);

17
src/lib/auth-bridge.ts

@ -74,11 +74,13 @@ class AuthBridge {
return false;
}
this.flutterResponseUnsubscribe = win.addFlutterResponseListener((event) => {
if (event.action === "login") {
this.handleLoginResponse(event.success);
}
});
this.flutterResponseUnsubscribe = win.addFlutterResponseListener(
(event) => {
if (event.action === "login") {
this.handleLoginResponse(event.success);
}
},
);
return true;
};
@ -147,7 +149,10 @@ class AuthBridge {
}
private resolvePending(token: string | null) {
const resolvers = this.pendingResolvers.splice(0, this.pendingResolvers.length);
const resolvers = this.pendingResolvers.splice(
0,
this.pendingResolvers.length,
);
for (const resolve of resolvers) {
resolve(token);
}

6
src/lib/http.ts

@ -56,7 +56,9 @@ export function getApiRequestUrl(path: string) {
}
export const http = axios.create({
baseURL: shouldUseProxy() ? "/api/proxy" : process.env.NEXT_PUBLIC_API_BASE_URL,
baseURL: shouldUseProxy()
? "/api/proxy"
: process.env.NEXT_PUBLIC_API_BASE_URL,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
@ -98,5 +100,5 @@ http.interceptors.request.use((config) => {
http.interceptors.response.use(
(response) => response,
(error) => Promise.reject(error)
(error) => Promise.reject(error),
);

24
src/lib/performance.ts

@ -1,9 +1,11 @@
export function measurePerformance() {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
window.addEventListener("load", () => {
const perfData = performance.getEntriesByType(
"navigation",
)[0] as PerformanceNavigationTiming;
window.addEventListener('load', () => {
const perfData = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
const metrics = {
dns: perfData.domainLookupEnd - perfData.domainLookupStart,
tcp: perfData.connectEnd - perfData.connectStart,
@ -14,14 +16,16 @@ export function measurePerformance() {
loadComplete: perfData.loadEventEnd - perfData.fetchStart,
};
console.log('⏱️ Performance Metrics:', metrics);
console.log("⏱️ Performance Metrics:", metrics);
// ارسال به analytics (اختیاری)
if ((window as any).HabibApp) {
(window as any).HabibApp.postMessage(JSON.stringify({
action: 'performance_metrics',
data: metrics,
}));
(window as any).HabibApp.postMessage(
JSON.stringify({
action: "performance_metrics",
data: metrics,
}),
);
}
});
}

26
src/lib/view-paddings.ts

@ -34,12 +34,14 @@ interface InitialConfig {
locale: LocaleInfo | null;
}
type EdgeSource = {
top?: number;
bottom?: number;
left?: number;
right?: number;
} | null
type EdgeSource =
| {
top?: number;
bottom?: number;
left?: number;
right?: number;
}
| null
| undefined;
function num(value: unknown): number {
@ -130,9 +132,7 @@ class ViewPaddingsBridge {
window.addEventListener("message", (event) => {
const data =
typeof event.data === "string"
? safeParse(event.data)
: event.data;
typeof event.data === "string" ? safeParse(event.data) : event.data;
handle(data);
});
}
@ -313,5 +313,11 @@ class ViewPaddingsBridge {
}
}
export type { ViewPaddings, InitialConfig, LayoutInfo, PlatformInfo, LocaleInfo };
export type {
ViewPaddings,
InitialConfig,
LayoutInfo,
PlatformInfo,
LocaleInfo,
};
export const viewPaddingsBridge = new ViewPaddingsBridge();

4
src/plugins/jsx-locator-loader.cjs

@ -35,7 +35,9 @@ module.exports = function (source, inputSourceMap) {
return;
}
} catch (err) {
console.error(`[jsx-locator-loader] Skipped ${filename} due to parse notice: ${err.message}`);
console.error(
`[jsx-locator-loader] Skipped ${filename} due to parse notice: ${err.message}`,
);
}
return source;

5
src/translations/dictionaries.ts

@ -55,5 +55,8 @@ export const dictionaries = {
export type Dictionary = typeof dictionaries.en;
export function getDictionary(locale: Locale): Dictionary {
return (dictionaries as unknown as Record<string, Dictionary>)[locale] ?? dictionaries.en;
return (
(dictionaries as unknown as Record<string, Dictionary>)[locale] ??
dictionaries.en
);
}

4
src/translations/locales/ar.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "معلومات سرية (مرئية لمستشاري حبيب مريج فقط)",
"privateFieldNotice": "معلومات سرية (للمستشارين فقط)",
"startMatchFailed": "فشل إرسال طلب التوافق. يرجى التحقق من الاتصال والمحاولة مرة أخرى.",
"moveToEnd": "الانتقال إلى النهاية"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/az.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/bn.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/da.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/de.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/en.json

@ -34,7 +34,7 @@
"playAlt": "play"
},
"questions": {
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"profileRegistration": "Profile registration",
"closeQuestionsList": "Close questions list",
"requiredSteps": "Required Steps",
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/es.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/fa.json

@ -34,7 +34,7 @@
"playAlt": "پخش"
},
"questions": {
"privateFieldNotice": "اطلاعات محرمانه (فقط برای کارشناسان حبیب مریج و سیستم مچینگ)",
"privateFieldNotice": "اطلاعات محرمانه (فقط کارشناسان)",
"profileRegistration": "ثبت اطلاعات پروفایل",
"closeQuestionsList": "بستن فهرست سوالات",
"requiredSteps": "مراحل ضروری",
@ -151,4 +151,4 @@
"matchProfile": "مشاهده پروفایل گزینه",
"profileLocked": "پروفایل قفل است"
}
}
}

4
src/translations/locales/fr.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/gu.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/ha.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/he.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/hi.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/id.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/ks.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/pt.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/ru.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/sw.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/tg.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/tr.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Gizli Alan (Yalnızca Habib Marriage danışmanları tarafından görülebilir)",
"privateFieldNotice": "Gizli (Yalnızca Danışmanlar)",
"startMatchFailed": "Eşleşme isteği gönderilemedi. Lütfen bağlantınızı kontrol edip tekrar deneyin.",
"moveToEnd": "Sona Taşı"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/ul.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/ur.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "نجی فیلڈ (صرف حبیب میرج کے مشیروں کو نظر آتی ہے)",
"privateFieldNotice": "نجی (صرف مشیران)",
"startMatchFailed": "میچ کی درخواست بھیجنے میں ناکامی۔ برائے مہربانی اپنا کنکشن چیک کریں اور دوبارہ کوشش کریں۔",
"moveToEnd": "آخر میں منتقل کریں"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/uz.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

4
src/translations/locales/zh.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
]
},
"privateFieldNotice": "Private Field (Visible to Habib Marriage advisors only)",
"privateFieldNotice": "Private (Advisors Only)",
"startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End"
},
@ -151,4 +151,4 @@
"matchProfile": "Match Profile",
"profileLocked": "Profile is locked"
}
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save