Browse Source

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

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

8
next.config.ts

@ -28,7 +28,10 @@ const nextConfig: NextConfig = {
exclude: /node_modules/, exclude: /node_modules/,
use: [ 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: [ headers: [
{ {
key: "Cache-Control", 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", 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 ( return (
<I18nProvider locale={lang}> <I18nProvider locale={lang}>
<div dir={localeDirections[lang]}>
{children}
</div>
<div dir={localeDirections[lang]}>{children}</div>
</I18nProvider> </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 { getQuestionListItems } from "@/data/question-data";
import { locales } from "@/translations/config"; import { locales } from "@/translations/config";
import QuestionDetailPage from "@/app/questions-list/[slug]/page";
export function generateStaticParams() { export function generateStaticParams() {
return locales.flatMap((lang) => return locales.flatMap((lang) =>

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

@ -1,8 +1,8 @@
"use client"; "use client";
import { useState, useEffect } from "react";
import { useFlutterBridge } from "@/hooks/useFlutterBridge";
import { useEffect, useState } from "react";
import Button from "@/components/ui/button"; import Button from "@/components/ui/button";
import { useFlutterBridge } from "@/hooks/useFlutterBridge";
export default function TestWebViewPage() { export default function TestWebViewPage() {
const { sendToFlutter, logs, lastEvent, events, isReady } = useFlutterBridge({ const { sendToFlutter, logs, lastEvent, events, isReady } = useFlutterBridge({
@ -24,7 +24,7 @@ export default function TestWebViewPage() {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("flutterEvent", { new CustomEvent("flutterEvent", {
detail: { type, payload, timestamp: Date.now() }, detail: { type, payload, timestamp: Date.now() },
})
}),
); );
}; };
@ -183,7 +183,7 @@ export default function TestWebViewPage() {
</div> </div>
<div className="text-gray-400 mt-2 text-[10px]"> <div className="text-gray-400 mt-2 text-[10px]">
{new Date(lastEvent.timestamp || Date.now()).toLocaleString( {new Date(lastEvent.timestamp || Date.now()).toLocaleString(
"fa-IR"
"fa-IR",
)} )}
</div> </div>
</div> </div>
@ -289,9 +289,9 @@ export default function TestWebViewPage() {
{event.type} {event.type}
</span> </span>
<span className="text-xs text-gray-500"> <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> </span>
</div> </div>
<pre className="font-mono text-xs text-gray-600 whitespace-pre-wrap break-all"> <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 dynamic = "force-dynamic";
export const runtime = "nodejs"; export const runtime = "nodejs";
@ -10,7 +10,10 @@ export async function POST(request: NextRequest) {
try { try {
const data = await request.json(); 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 }); return Response.json({ success: true });
} catch (error) { } catch (error) {
console.error("Failed to process dev storage request:", 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 { exec } from "child_process";
import type { NextRequest } from "next/server";
import os from "os"; import os from "os";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -23,11 +23,15 @@ export async function POST(request: NextRequest) {
exec(command, (error) => { exec(command, (error) => {
if (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}"`; const fallbackCmd = `start "" "antigravity://file/${locator}"`;
exec(fallbackCmd, (fallbackErr) => { exec(fallbackCmd, (fallbackErr) => {
if (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); logProxyResponse(upstreamResponse, responseBody);
const responseHeaders = getResponseHeaders(upstreamResponse.headers); const responseHeaders = getResponseHeaders(upstreamResponse.headers);
// Force no caching on API proxy responses – always fetch fresh data // Force no caching on API proxy responses – always fetch fresh data

2
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 dynamic = "force-dynamic";
export const runtime = "nodejs"; export const runtime = "nodejs";

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 dynamic = "force-dynamic";
export const runtime = "nodejs"; export const runtime = "nodejs";
@ -9,7 +9,8 @@ export async function POST(request: NextRequest) {
} }
try { try {
const { method, url, status, requestBody, responseBody, duration } = await request.json();
const { method, url, status, requestBody, responseBody, duration } =
await request.json();
const statusColor = const statusColor =
status >= 500 status >= 500
@ -23,12 +24,20 @@ export async function POST(request: NextRequest) {
const resetColor = "\x1b[0m"; const resetColor = "\x1b[0m";
const headerPrefix = "\x1b[35m📱 🌐 [HTTP]\x1b[0m"; // Magenta 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) { if (requestBody) {
try { 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 { } catch {
console.log(` \x1b[90mRequest Body:\x1b[0m`, requestBody); console.log(` \x1b[90mRequest Body:\x1b[0m`, requestBody);
} }
@ -36,8 +45,14 @@ export async function POST(request: NextRequest) {
if (responseBody) { if (responseBody) {
try { 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 { } catch {
console.log(` \x1b[90mResponse Body:\x1b[0m`, responseBody); console.log(` \x1b[90mResponse Body:\x1b[0m`, responseBody);
} }

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

@ -2,16 +2,16 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { FaPen } from "react-icons/fa6"; import { FaPen } from "react-icons/fa6";
import AdvisorActionsCard from "@/components/ui/advisor-actions-card"; import AdvisorActionsCard from "@/components/ui/advisor-actions-card";
import NavigationButton from "@/components/ui/navigation-button"; import NavigationButton from "@/components/ui/navigation-button";
import { PageBackground } from "@/components/utils/page-background"; 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 { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { getSubmitPath } from "@/lib/get-submit-path";
const advisorAvatars = [ const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" }, { id: "advisor-primary", src: "/assets/images/Avatar Image.png" },

21
src/app/globals.css

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

19
src/app/intro/page.tsx

@ -1,17 +1,17 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import Button from "@/components/ui/button"; import Button from "@/components/ui/button";
import NavigationButton from "@/components/ui/navigation-button"; import NavigationButton from "@/components/ui/navigation-button";
import ReportActionsSheet from "@/components/ui/report-actions-sheet"; 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 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 { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { getSubmitPath } from "@/lib/get-submit-path";
const REDIRECT_SESSION_KEY = "redirect"; const REDIRECT_SESSION_KEY = "redirect";
@ -104,7 +104,10 @@ export default function Intro() {
const { data: freshProfile } = await refetch(); const { data: freshProfile } = await refetch();
profileResponse = freshProfile ?? profile; profileResponse = freshProfile ?? profile;
} catch (refetchError) { } 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); const nextPath = localizePath(getSubmitPath(profileResponse), locale);
@ -214,7 +217,11 @@ export default function Intro() {
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }} style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }}
className="pointer-events-auto px-[17px] pt-3" 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} {t.common.submit}
</Button> </Button>
</div> </div>

5
src/app/layout.tsx

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

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

@ -2,14 +2,12 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo } from "react";
import { useRouter } from "next/navigation"; 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 { FaLock } from "react-icons/fa6";
import AdvisorActionsCard from "@/components/ui/advisor-actions-card"; import AdvisorActionsCard from "@/components/ui/advisor-actions-card";
import NavigationButton from "@/components/ui/navigation-button";
import { DotsLoader } from "@/components/ui/button"; import { DotsLoader } from "@/components/ui/button";
import NavigationButton from "@/components/ui/navigation-button";
import { PageBackground } from "@/components/utils/page-background"; import { PageBackground } from "@/components/utils/page-background";
import type { import type {
MarriageField, MarriageField,
@ -18,8 +16,10 @@ import type {
MarriagePhoneFieldValue, MarriagePhoneFieldValue,
} from "@/hooks/marriage/types"; } from "@/hooks/marriage/types";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useI18n } from "@/translations/provider";
import { useViewPaddings } from "@/hooks/use-view-paddings"; 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 = [ const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" }, { id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
@ -29,10 +29,30 @@ const advisorAvatars = [
const fieldCandidates = { const fieldCandidates = {
name: ["name", "full_name", "fullname", "first_name", "display_name"], 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"], 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: [ cityPreference: [
"city_preference", "city_preference",
"citypreference", "citypreference",
@ -222,7 +242,10 @@ export default function NewMatchPage() {
<PageBackground /> <PageBackground />
<main <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" className="-mx-[17px] min-h-screen h-full text-center px-4"
> >
<header className="flex items-center justify-between pb-3"> <header className="flex items-center justify-between pb-3">
@ -244,7 +267,9 @@ export default function NewMatchPage() {
alt="notification" alt="notification"
/> />
</div> </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]"> <p className="mt-[11px] max-w-[322px] text-[12px] leading-[1.35] font-semibold text-[#7C7C7C]">
{matchHeadingDescription} {matchHeadingDescription}
</p> </p>

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

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

12
src/app/providers.tsx

@ -1,7 +1,11 @@
"use client"; "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"; import { ViewPaddingsProvider } from "@/components/utils/view-paddings-provider";
function AppFocusReloader({ children }: { children: ReactNode }) { function AppFocusReloader({ children }: { children: ReactNode }) {
@ -33,7 +37,8 @@ function AppFocusReloader({ children }: { children: ReactNode }) {
// Listen for Flutter response events if available // Listen for Flutter response events if available
const win = window as any; const win = window as any;
const unbindFlutter = typeof win.addFlutterResponseListener === "function"
const unbindFlutter =
typeof win.addFlutterResponseListener === "function"
? win.addFlutterResponseListener(() => handleReload()) ? win.addFlutterResponseListener(() => handleReload())
: null; : null;
@ -80,4 +85,3 @@ export default function Providers({ children }: ProvidersProps) {
</QueryClientProvider> </QueryClientProvider>
); );
} }

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

@ -6,7 +6,10 @@ import {
hasQuestionAnswerValue, hasQuestionAnswerValue,
} from "@/components/questions/question-answer-storage"; } from "@/components/questions/question-answer-storage";
import InformationSheet from "@/components/ui/information-sheet"; 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"; import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
type AnswerPaceSheetProps = { type AnswerPaceSheetProps = {

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

@ -7,9 +7,9 @@ import {
QuestionAnswersProvider, QuestionAnswersProvider,
useQuestionAnswers, useQuestionAnswers,
} from "@/components/questions/question-answer-storage"; } from "@/components/questions/question-answer-storage";
import QuestionBirthplace from "@/components/questions/question-birthplace";
import QuestionButton from "@/components/questions/question-button"; 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 QuestionDate from "@/components/questions/question-date";
import QuestionDropdown from "@/components/questions/question-dropdown"; import QuestionDropdown from "@/components/questions/question-dropdown";
import QuestionExitNavigationButton from "@/components/questions/question-exit-navigation-button"; 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 QuestionSectionFlow from "@/components/questions/question-section-flow";
import QuestionSlider from "@/components/questions/question-slider"; import QuestionSlider from "@/components/questions/question-slider";
import QuestionText from "@/components/questions/question-text"; 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 NavigationButton from "@/components/ui/navigation-button";
import StickyHeader from "@/components/ui/sticky-header"; import StickyHeader from "@/components/ui/sticky-header";
import { PageBackground } from "@/components/utils/page-background"; import { PageBackground } from "@/components/utils/page-background";
import { cattellFallbackQuestions } from "@/data/cattell-fallback";
import { glasserFallbackQuestions } from "@/data/glasser-fallback";
import { import {
getQuestionListItemBySlug, getQuestionListItemBySlug,
isQuestionListItemVisibleForProfile, isQuestionListItemVisibleForProfile,
@ -32,9 +39,6 @@ import {
type QuestionField, type QuestionField,
} from "@/data/question-data"; } from "@/data/question-data";
import type { MarriageGender } from "@/hooks/marriage/types"; 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 { import {
useCattellQuestionsQuery, useCattellQuestionsQuery,
useSubmitCattellAssessmentMutation, useSubmitCattellAssessmentMutation,
@ -43,10 +47,9 @@ import {
useGlasserQuestionsQuery, useGlasserQuestionsQuery,
useSubmitGlasserAssessmentMutation, useSubmitGlasserAssessmentMutation,
} from "@/hooks/marriage/use-glasser"; } 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"; import AnswerPaceSheet from "./answer-pace-sheet";
type QuestionDetailClientProps = { type QuestionDetailClientProps = {
@ -180,6 +183,14 @@ function renderQuestion(
: undefined; : undefined;
switch (question.type) { switch (question.type) {
case "birthplace":
return (
<QuestionBirthplace
question={question}
questionIndex={questionIndex}
disabled={disabled}
/>
);
case "button": case "button":
return ( return (
<QuestionButton <QuestionButton
@ -348,6 +359,11 @@ function QuestionFlowWrapper({
if (isEmailQuestion) { if (isEmailQuestion) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
isAnswered = emailRegex.test(String(answer).trim()); 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 router = useRouter();
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const [isTestStarted, setIsTestStarted] = useState(false); const [isTestStarted, setIsTestStarted] = useState(false);
const { data: profile, isLoading: isProfileLoading } = useMarriageProfileQuery();
const { data: profile, isLoading: isProfileLoading } =
useMarriageProfileQuery();
const profileGender = profile?.gender; const profileGender = profile?.gender;
const age = getStoredAge(); const age = getStoredAge();
const item = getQuestionListItemBySlug(itemSlug, locale); const item = getQuestionListItemBySlug(itemSlug, locale);
@ -457,7 +474,12 @@ export default function QuestionDetailClient({
return questionsList.map((q) => ({ return questionsList.map((q) => ({
id: q.question_number, id: q.question_number,
text: q.text, 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, options: defaultGlasserOptions,
})); }));
}, [glasserQuery.data, locale]); }, [glasserQuery.data, locale]);
@ -513,7 +535,10 @@ export default function QuestionDetailClient({
</main> </main>
</> </>
); );
} else if (!item || !isQuestionListItemVisibleForProfile(item, profileContext)) {
} else if (
!item ||
!isQuestionListItemVisibleForProfile(item, profileContext)
) {
return null; 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]; const bullets = t.questions.testIntroBullets[bulletKey];
return ( return (
@ -684,7 +710,6 @@ export default function QuestionDetailClient({
); );
} }
const dobQuestion = visibleQuestions.find( const dobQuestion = visibleQuestions.find(
(question) => question.title === "Date of Birth", (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"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import TestIntroPage from "@/components/questions/test-intro-page";
import NavigationButton from "@/components/ui/navigation-button"; import NavigationButton from "@/components/ui/navigation-button";
import StickyHeader from "@/components/ui/sticky-header"; import StickyHeader from "@/components/ui/sticky-header";
import { PageBackground } from "@/components/utils/page-background"; import { PageBackground } from "@/components/utils/page-background";
import TestIntroPage from "@/components/questions/test-intro-page";
type TestIntroClientProps = { type TestIntroClientProps = {
title: string; title: string;

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

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

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

@ -4,15 +4,17 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getSubmitPath } from "@/lib/get-submit-path";
import { FiCopy, FiPhone } from "react-icons/fi"; import { FiCopy, FiPhone } from "react-icons/fi";
import { DotsLoader } from "@/components/ui/button";
import CallResultSheet from "@/components/ui/call-result-sheet"; import CallResultSheet from "@/components/ui/call-result-sheet";
import FemaleConsentSheet from "@/components/ui/female-consent-sheet"; import FemaleConsentSheet from "@/components/ui/female-consent-sheet";
import NavigationButton from "@/components/ui/navigation-button"; import NavigationButton from "@/components/ui/navigation-button";
import SubscriptionRequiredSheet from "@/components/ui/subscription-required-sheet"; import SubscriptionRequiredSheet from "@/components/ui/subscription-required-sheet";
import { DotsLoader } from "@/components/ui/button";
import { PageBackground } from "@/components/utils/page-background"; 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 { useMarriageContactInfoQuery } from "@/hooks/marriage/use-contact-info";
import { useSubmitMarriageContactStatusMutation } from "@/hooks/marriage/use-contact-status"; import { useSubmitMarriageContactStatusMutation } from "@/hooks/marriage/use-contact-status";
import { import {
@ -20,6 +22,7 @@ import {
useHabcoinPaymentMutation, useHabcoinPaymentMutation,
} from "@/hooks/marriage/use-habcoin-payment"; } from "@/hooks/marriage/use-habcoin-payment";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; 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="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"> <div className="text-left">
<p className="text-xs font-semibold text-[#8F8F8F]">{item.label}</p> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
onClick={() => { 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" 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> </button>
<a <a
href={`tel:${item.phoneNumber}`} href={`tel:${item.phoneNumber}`}
className="inline-flex p-3 items-center justify-center rounded-[10px] bg-[#F0445B] text-white" 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> </a>
</div> </div>
</div> </div>
@ -152,11 +165,14 @@ export default function RequestAcceptedPage() {
const caseStatus = profile?.active_case?.status; const caseStatus = profile?.active_case?.status;
const recommendedPlanId = profile?.recommended_plan?.id; const recommendedPlanId = profile?.recommended_plan?.id;
const paymentMutation = useHabcoinPaymentMutation(); const paymentMutation = useHabcoinPaymentMutation();
const contactStatusMutation = useSubmitMarriageContactStatusMutation(caseId ?? "", {
const contactStatusMutation = useSubmitMarriageContactStatusMutation(
caseId ?? "",
{
onSuccess: () => { onSuccess: () => {
router.push(localizePath("/finding-match", locale)); router.push(localizePath("/finding-match", locale));
}, },
});
},
);
const contactInfoQuery = useMarriageContactInfoQuery(caseId, { const contactInfoQuery = useMarriageContactInfoQuery(caseId, {
enabled: false, enabled: false,
}); });
@ -231,7 +247,8 @@ export default function RequestAcceptedPage() {
if (!caseId || contactStatusMutation.isPending) return; if (!caseId || contactStatusMutation.isPending) return;
await contactStatusMutation.mutateAsync({ await contactStatusMutation.mutateAsync({
action: "no_contact", 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",
}); });
}; };
@ -323,7 +340,11 @@ export default function RequestAcceptedPage() {
className="max-w-[212px] cursor-pointer appearance-none border-0 bg-transparent p-0 text-left" 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]"> <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> </div>
</button> </button>
) : ( ) : (
@ -343,7 +364,11 @@ export default function RequestAcceptedPage() {
className="max-w-[212px] appearance-none border-0 bg-transparent p-0 text-left" 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]"> <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> </div>
</button> </button>
</div> </div>

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

@ -1,17 +1,16 @@
"use client"; "use client";
import Image from "next/image"; 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 AdvisorActionsCard from "@/components/ui/advisor-actions-card";
import NavigationButton from "@/components/ui/navigation-button"; import NavigationButton from "@/components/ui/navigation-button";
import { PageBackground } from "@/components/utils/page-background"; 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 { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { localizePath } from "@/translations/config";
import { getSubmitPath } from "@/lib/get-submit-path"; import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
const advisorAvatars = [ const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" }, { id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
@ -39,7 +38,8 @@ export default function RequestSentPage() {
const copy = t.findingMatch; const copy = t.findingMatch;
const requestSentCopy = (t as any).requestSent ?? { const requestSentCopy = (t as any).requestSent ?? {
title: "درخواست ارسال شد", title: "درخواست ارسال شد",
description: "درخواست معرفی شما از طرف ما به خانواده طرف مقابل ارسال شد. در صورت تایید ایشان، اطلاعات تماس به اطلاع شما خواهد رسید.",
description:
"درخواست معرفی شما از طرف ما به خانواده طرف مقابل ارسال شد. در صورت تایید ایشان، اطلاعات تماس به اطلاع شما خواهد رسید.",
matchProfile: "مشاهده پروفایل گزینه", matchProfile: "مشاهده پروفایل گزینه",
profileLocked: "پروفایل قفل است", profileLocked: "پروفایل قفل است",
}; };
@ -79,11 +79,14 @@ export default function RequestSentPage() {
{requestSentCopy.description} {requestSentCopy.description}
</p> </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]"> <div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C]">
{requestSentCopy.matchProfile} {requestSentCopy.matchProfile}
</div> </div>
</Link >
</Link>
</section> </section>
<div className="space-y-8"> <div className="space-y-8">

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

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState, useRef } from "react";
import { useEffect, useRef, useState } from "react";
const IDE_SCHEMES = [ const IDE_SCHEMES = [
{ {
@ -48,10 +48,18 @@ function parseLocator(locator: string) {
export function DevClickToComponent() { export function DevClickToComponent() {
const [isInspecting, setIsInspecting] = useState(false); const [isInspecting, setIsInspecting] = useState(false);
const [isMobile, setIsMobile] = 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 [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 // Detect mobile size
useEffect(() => { useEffect(() => {
@ -132,8 +140,7 @@ export function DevClickToComponent() {
const ideUrl = const ideUrl =
IDE_SCHEMES.find(({ matches }) => IDE_SCHEMES.find(({ matches }) =>
matches.some((match) => userAgent.includes(match)), matches.some((match) => userAgent.includes(match)),
)?.createUrl(locatorString) ??
`antigravity://file/${locatorString}`;
)?.createUrl(locatorString) ?? `antigravity://file/${locatorString}`;
try { try {
window.location.href = ideUrl; window.location.href = ideUrl;
@ -172,7 +179,10 @@ export function DevClickToComponent() {
?.getAttribute("data-locator"); ?.getAttribute("data-locator");
if (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 // Flash target element outline briefly as visual feedback
const element = target.closest<HTMLElement>("[data-locator]"); const element = target.closest<HTMLElement>("[data-locator]");
@ -193,7 +203,10 @@ export function DevClickToComponent() {
body: JSON.stringify({ locator, userAgent }), body: JSON.stringify({ locator, userAgent }),
}); });
} catch (err) { } catch (err) {
console.error("[DevClickToComponent] Error sending API request:", err);
console.error(
"[DevClickToComponent] Error sending API request:",
err,
);
} }
} }
@ -229,7 +242,11 @@ export function DevClickToComponent() {
body: JSON.stringify({ type, args }), body: JSON.stringify({ type, args }),
}); });
} catch (err) { } 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 { } finally {
isSending = false; isSending = false;
} }
@ -280,7 +297,8 @@ export function DevClickToComponent() {
return originalFetch(input, init); 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(); const startTime = Date.now();
let requestBody: string | null = null; let requestBody: string | null = null;
@ -360,7 +378,12 @@ export function DevClickToComponent() {
const originalOpen = XMLHttpRequest.prototype.open; const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send; 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._method = method;
this._url = typeof url === "string" ? url : url.toString(); this._url = typeof url === "string" ? url : url.toString();
return originalOpen.apply(this, [method, url, ...args] as any); return originalOpen.apply(this, [method, url, ...args] as any);
@ -408,7 +431,8 @@ export function DevClickToComponent() {
} }
} }
window.fetch("/api/remote-network", {
window
.fetch("/api/remote-network", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -421,7 +445,8 @@ export function DevClickToComponent() {
responseBody, responseBody,
duration, duration,
}), }),
}).catch(() => {});
})
.catch(() => {});
}); });
return originalSend.apply(this, [body]); return originalSend.apply(this, [body]);
@ -437,8 +462,8 @@ export function DevClickToComponent() {
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => { const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
setIsDragging(true); 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 = { dragStart.current = {
x: e.clientX, x: e.clientX,
y: e.clientY, y: e.clientY,
@ -457,8 +482,14 @@ export function DevClickToComponent() {
dragStart.current.hasMoved = true; 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 }); setPosition({ x: newX, y: newY });
}; };
@ -512,7 +543,10 @@ export function DevClickToComponent() {
setTimeout(() => setSendSuccess(false), 2000); setTimeout(() => setSendSuccess(false), 2000);
} }
} catch (err) { } catch (err) {
console.error("[DevClickToComponent] Failed to send localStorage values:", err);
console.error(
"[DevClickToComponent] Failed to send localStorage values:",
err,
);
} finally { } finally {
setIsSendingStorage(false); setIsSendingStorage(false);
} }
@ -553,24 +587,65 @@ export function DevClickToComponent() {
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
cursor: isDragging ? "grabbing" : "pointer", 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", outline: "none",
transform: isSendingStorage ? "scale(0.95)" : "scale(1)", transform: isSendingStorage ? "scale(0.95)" : "scale(1)",
}} }}
title="Send LocalStorage to Terminal API" title="Send LocalStorage to Terminal API"
> >
{isSendingStorage ? ( {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> </svg>
) : sendSuccess ? ( ) : 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>
) : ( ) : (
<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> </svg>
)} )}
</button> </button>
@ -597,19 +672,43 @@ export function DevClickToComponent() {
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
cursor: isDragging ? "grabbing" : "pointer", 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", outline: "none",
transform: isInspecting ? "scale(1.1)" : "scale(1)", transform: isInspecting ? "scale(1.1)" : "scale(1)",
}} }}
title="Toggle Dev Inspect Mode (Drag to move)" title="Toggle Dev Inspect Mode (Drag to move)"
> >
{isInspecting ? ( {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>
) : ( ) : (
<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> </svg>
)} )}
</button> </button>

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

@ -1,14 +1,16 @@
import { import {
type QuestionListItem,
isQuestionVisibleForProfile,
isQuestionRequiredForProfile, isQuestionRequiredForProfile,
isQuestionVisibleForProfile,
type QuestionListItem,
} from "@/data/question-data"; } from "@/data/question-data";
import { hasQuestionAnswerValue } from "./question-answer-storage"; import { hasQuestionAnswerValue } from "./question-answer-storage";
export function getStoredAge(): number | null { export function getStoredAge(): number | null {
try { try {
if (typeof window === "undefined") return null; 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; if (!rawValue) return null;
const storedAnswers = JSON.parse(rawValue); const storedAnswers = JSON.parse(rawValue);
const ageField = storedAnswers.fields?.find((f: any) => f.label === "Age"); const ageField = storedAnswers.fields?.find((f: any) => f.label === "Age");
@ -16,7 +18,9 @@ export function getStoredAge(): number | null {
const num = Number(ageField.value); const num = Number(ageField.value);
if (Number.isFinite(num)) return num; 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) { if (dobField && dobField.value) {
const dob = new Date(dobField.value); const dob = new Date(dobField.value);
if (!Number.isNaN(dob.getTime())) { if (!Number.isNaN(dob.getTime())) {
@ -29,11 +33,27 @@ export function getStoredAge(): number | null {
return age >= 0 ? age : null; return age >= 0 ? age : null;
} }
} }
} catch (e) { }
} catch (e) {}
return null; 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 { try {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
const storageKey = `marriage:sections:${item.slug}:answers`; const storageKey = `marriage:sections:${item.slug}:answers`;
@ -45,7 +65,11 @@ export function getLocalSectionProgress(item: QuestionListItem, profile: any, ag
if (storedValue && storedValue.completed) { if (storedValue && storedValue.completed) {
return 100; return 100;
} }
if (!storedValue || !Array.isArray(storedValue.fields) || storedValue.fields.length === 0) {
if (
!storedValue ||
!Array.isArray(storedValue.fields) ||
storedValue.fields.length === 0
) {
return null; return null;
} }
@ -55,7 +79,9 @@ export function getLocalSectionProgress(item: QuestionListItem, profile: any, ag
}; };
const visibleQuestions = item.questions const visibleQuestions = item.questions
.filter((question) => isQuestionVisibleForProfile(question, profileContext))
.filter((question) =>
isQuestionVisibleForProfile(question, profileContext),
)
.map((question) => ({ .map((question) => ({
...question, ...question,
required: isQuestionRequiredForProfile(question, profileContext), required: isQuestionRequiredForProfile(question, profileContext),
@ -67,22 +93,40 @@ export function getLocalSectionProgress(item: QuestionListItem, profile: any, ag
return 100; return 100;
} }
const answeredCount = visibleQuestions.filter((q, index) => { 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 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; }).length;
return Math.round((answeredCount / visibleQuestions.length) * 100); return Math.round((answeredCount / visibleQuestions.length) * 100);
} }
const answeredRequiredCount = requiredQuestions.filter((q) => { 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; 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 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; }).length;
return Math.round((answeredRequiredCount / requiredQuestions.length) * 100); return Math.round((answeredRequiredCount / requiredQuestions.length) * 100);

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

@ -11,16 +11,19 @@ import {
useState, useState,
} from "react"; } from "react";
import type { QuestionField } from "@/data/question-data"; import type { QuestionField } from "@/data/question-data";
import { toBackendSlug } from "@/data/section-slug-map";
import { pathParam } from "@/hooks/marriage/path-param"; import { pathParam } from "@/hooks/marriage/path-param";
import { getApiRequestUrl } from "@/lib/http";
import type { import type {
MarriageField, MarriageField,
MarriageFieldValue, MarriageFieldValue,
MarriagePhoneFieldValue, MarriagePhoneFieldValue,
UpdateMarriageSectionDataPayload, UpdateMarriageSectionDataPayload,
} from "@/hooks/marriage/types"; } 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; 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") { if (!value || typeof value !== "object") {
return false; return false;
} }
@ -175,7 +180,10 @@ function getOrderedFields(
return orderedFields; return orderedFields;
} }
function getCurrentStep(fields: MarriageField[], questions: readonly QuestionField[]) {
function getCurrentStep(
fields: MarriageField[],
questions: readonly QuestionField[],
) {
return questions.filter((question, index) => { return questions.filter((question, index) => {
if (!question.required || question.logic?.dependsOn) { if (!question.required || question.logic?.dependsOn) {
return false; return false;
@ -192,7 +200,7 @@ function createPayload(
): UpdateMarriageSectionDataPayload { ): UpdateMarriageSectionDataPayload {
const fields = getOrderedFields(answers, questions); const fields = getOrderedFields(answers, questions);
const targetQuestions = questions.filter( const targetQuestions = questions.filter(
(q) => q.required && !q.logic?.dependsOn
(q) => q.required && !q.logic?.dependsOn,
); );
return { return {
@ -271,7 +279,9 @@ function writeStoredAnswers(
function getKeepalivePatchUrl(slug: string) { function getKeepalivePatchUrl(slug: string) {
const backendSlug = toBackendSlug(slug); const backendSlug = toBackendSlug(slug);
return getApiRequestUrl(`/api/marriage/sections/${pathParam(backendSlug)}/data/`);
return getApiRequestUrl(
`/api/marriage/sections/${pathParam(backendSlug)}/data/`,
);
} }
export function QuestionAnswersProvider({ 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 Link from "next/link";
import type { IconType } from "react-icons"; import type { IconType } from "react-icons";
import { import {
@ -11,7 +12,6 @@ import {
import type { QuestionCardIcon, QuestionListItem } from "@/data/question-data"; import type { QuestionCardIcon, QuestionListItem } from "@/data/question-data";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import Image from "next/image";
type QuestionCardProps = { type QuestionCardProps = {
item: QuestionListItem; item: QuestionListItem;

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

@ -2,9 +2,9 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { QuestionField } from "@/data/question-data"; import type { QuestionField } from "@/data/question-data";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
import { useI18n } from "@/translations/provider";
type QuestionDateProps = { type QuestionDateProps = {
question: QuestionField; question: QuestionField;
@ -36,7 +36,9 @@ const DAYS = Array.from({ length: 31 }, (_, i) => {
const MIN_AGE = 18; const MIN_AGE = 18;
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const maxBirthYear = currentYear - MIN_AGE; 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({ export function QuestionDate({
question, question,
@ -76,7 +78,14 @@ export function QuestionDate({
const m = Number.parseInt(selectedMonth, 10); const m = Number.parseInt(selectedMonth, 10);
const d = Number.parseInt(selectedDay, 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; return null;
} }
@ -103,7 +112,11 @@ export function QuestionDate({
if (y && m && d) { if (y && m && d) {
const formattedMonth = m.padStart(2, "0"); const formattedMonth = m.padStart(2, "0");
const formattedDay = d.padStart(2, "0"); const formattedDay = d.padStart(2, "0");
setAnswerValue(question, questionIndex, `${y}-${formattedMonth}-${formattedDay}`);
setAnswerValue(
question,
questionIndex,
`${y}-${formattedMonth}-${formattedDay}`,
);
} else { } else {
setAnswerValue(question, questionIndex, ""); setAnswerValue(question, questionIndex, "");
} }
@ -193,7 +206,9 @@ export function QuestionDate({
value={calculatedAge !== null ? calculatedAge : ""} value={calculatedAge !== null ? calculatedAge : ""}
className={[ 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]", "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(" ")} ].join(" ")}
/> />
{isUnder18 ? ( {isUnder18 ? (

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

@ -1,7 +1,11 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; 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 type { QuestionField } from "@/data/question-data";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
@ -16,6 +20,7 @@ export function QuestionDropdown({
questionIndex, questionIndex,
disabled, disabled,
}: QuestionDropdownProps) { }: QuestionDropdownProps) {
const { locale } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const rawValue = getAnswerValue(question, questionIndex); const rawValue = getAnswerValue(question, questionIndex);
@ -31,6 +36,7 @@ export function QuestionDropdown({
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// Lock page scroll when dropdown is open // Lock page scroll when dropdown is open
useEffect(() => { 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) => const filteredOptions = options.filter((option) =>
option.toLowerCase().includes(searchQuery.toLowerCase()), option.toLowerCase().includes(searchQuery.toLowerCase()),
); );
@ -92,7 +134,9 @@ export function QuestionDropdown({
: question.extras.placeHolder || "Select" : question.extras.placeHolder || "Select"
: singleValue || 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) => { const toggleMultiOption = (option: string) => {
let nextValue: string[]; let nextValue: string[];
@ -101,7 +145,11 @@ export function QuestionDropdown({
} else { } else {
nextValue = [...selectedList, option]; nextValue = [...selectedList, option];
} }
setAnswerValue(question, questionIndex, nextValue.length > 0 ? nextValue : null);
setAnswerValue(
question,
questionIndex,
nextValue.length > 0 ? nextValue : null,
);
}; };
return ( return (
@ -121,7 +169,9 @@ export function QuestionDropdown({
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className={[ 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", "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(" ")} ].join(" ")}
> >
<span <span
@ -137,7 +187,10 @@ export function QuestionDropdown({
height="10" height="10"
viewBox="0 0 16 10" viewBox="0 0 16 10"
fill="none" 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 <path
d="M14.75 1.25L7.75 8.25L0.75 1.25" d="M14.75 1.25L7.75 8.25L0.75 1.25"
@ -178,6 +231,7 @@ export function QuestionDropdown({
/> />
</svg> </svg>
<input <input
ref={searchInputRef}
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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>
</div> </div>
@ -265,4 +321,3 @@ export function QuestionDropdown({
} }
export default QuestionDropdown; export default QuestionDropdown;

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

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

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

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

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

@ -1,10 +1,10 @@
"use client"; "use client";
import { useMemo, useEffect } from "react";
import { useEffect, useMemo } from "react";
import type { QuestionField } from "@/data/question-data"; import type { QuestionField } from "@/data/question-data";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
import { useI18n } from "@/translations/provider";
type QuestionNumberProps = { type QuestionNumberProps = {
question: QuestionField; question: QuestionField;
@ -39,7 +39,14 @@ export default function QuestionNumber({
setAnswerValue(question, questionIndex, age); setAnswerValue(question, questionIndex, age);
} }
} }
}, [derivedFromQuestion, derivedValue, question, questionIndex, setAnswerValue, value]);
}, [
derivedFromQuestion,
derivedValue,
question,
questionIndex,
setAnswerValue,
value,
]);
useEffect(() => { useEffect(() => {
if ( if (
@ -53,7 +60,12 @@ export default function QuestionNumber({
const [min, max] = question.extras.range; 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(() => { const isOutOfRange = useMemo(() => {
if (Number.isNaN(numValue)) return false; if (Number.isNaN(numValue)) return false;
if (min !== 0 && numValue < min) return true; if (min !== 0 && numValue < min) return true;
@ -93,7 +105,11 @@ export default function QuestionNumber({
setAnswerValue(question, questionIndex, null); setAnswerValue(question, questionIndex, null);
} else { } else {
const parsed = parseFloat(nextValue); const parsed = parseFloat(nextValue);
setAnswerValue(question, questionIndex, Number.isNaN(parsed) ? nextValue : parsed);
setAnswerValue(
question,
questionIndex,
Number.isNaN(parsed) ? nextValue : parsed,
);
} }
}} }}
className={[ className={[
@ -103,7 +119,8 @@ export default function QuestionNumber({
/> />
{isOutOfRange ? ( {isOutOfRange ? (
<span className="block text-[10px] font-semibold text-[#F2465F]"> <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> </span>
) : null} ) : null}
</div> </div>

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

@ -1,11 +1,12 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react";
import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber"; import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber";
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import type { QuestionField } from "@/data/question-data"; import type { QuestionField } from "@/data/question-data";
import type { MarriagePhoneFieldValue } from "@/hooks/marriage/types"; import type { MarriagePhoneFieldValue } from "@/hooks/marriage/types";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
import { useI18n } from "@/translations/provider";
type QuestionPhoneProps = { type QuestionPhoneProps = {
question: QuestionField; question: QuestionField;
@ -39,7 +40,7 @@ function isMarriagePhoneFieldValue(
function readPhoneValue(value: unknown, fallbackCode: string): PhoneValueParts { function readPhoneValue(value: unknown, fallbackCode: string): PhoneValueParts {
if (value === null) { if (value === null) {
return { return {
codeValue: "",
codeValue: fallbackCode,
phoneValue: "", phoneValue: "",
}; };
} }
@ -62,7 +63,7 @@ function readPhoneValue(value: unknown, fallbackCode: string): PhoneValueParts {
if (value.length === 0) { if (value.length === 0) {
return { return {
codeValue: "",
codeValue: fallbackCode,
phoneValue: "", 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) { function writePhoneValue(codeValue: string, phoneValue: string) {
if (!codeValue && !phoneValue) { if (!codeValue && !phoneValue) {
return null; return null;
@ -120,7 +149,10 @@ function toStoredPhoneValue(
codeValue: string, codeValue: string,
phoneValue: string, phoneValue: string,
): MarriagePhoneFieldValue | null { ): MarriagePhoneFieldValue | null {
const normalizedCountryCode = sanitizeCountryCode(codeValue).replace(/^\+/, "");
const normalizedCountryCode = sanitizeCountryCode(codeValue).replace(
/^\+/,
"",
);
const normalizedPhoneNumber = phoneValue.trim().replace(/\s+/g, ""); const normalizedPhoneNumber = phoneValue.trim().replace(/\s+/g, "");
if (!normalizedCountryCode && !normalizedPhoneNumber) { if (!normalizedCountryCode && !normalizedPhoneNumber) {
@ -195,6 +227,7 @@ export function QuestionPhone({
countryCode = "+98", countryCode = "+98",
disabled, disabled,
}: QuestionPhoneProps) { }: QuestionPhoneProps) {
const { locale } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex); const value = getAnswerValue(question, questionIndex);
const defaultCodeValue = countryCode.trim() || "+98"; const defaultCodeValue = countryCode.trim() || "+98";
@ -202,6 +235,14 @@ export function QuestionPhone({
const [codeValue, setCodeValue] = useState(initialValue.codeValue); const [codeValue, setCodeValue] = useState(initialValue.codeValue);
const [phoneValue, setPhoneValue] = useState(initialValue.phoneValue); const [phoneValue, setPhoneValue] = useState(initialValue.phoneValue);
const lastCommittedValueRef = useRef(value); 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 normalizedPhoneState = getNormalizedPhoneValue(codeValue, phoneValue);
const showInvalidState = const showInvalidState =
codeValue.trim().length > 0 && codeValue.trim().length > 0 &&
@ -212,18 +253,164 @@ export function QuestionPhone({
? normalizedPhoneState.isValid || phoneValue.trim().length === 0 ? normalizedPhoneState.isValid || phoneValue.trim().length === 0
: normalizedPhoneState.isValid; : 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(() => { useEffect(() => {
if (value === lastCommittedValueRef.current) { if (value === lastCommittedValueRef.current) {
return; return;
} }
const nextValue = readPhoneValue(value, defaultCodeValue); const nextValue = readPhoneValue(value, defaultCodeValue);
const maxLen = getMaxLengthForCountry(nextValue.codeValue);
const truncatedPhone = nextValue.phoneValue.slice(0, maxLen);
setCodeValue(nextValue.codeValue); setCodeValue(nextValue.codeValue);
setPhoneValue(nextValue.phoneValue);
setPhoneValue(truncatedPhone);
lastCommittedValueRef.current = value; lastCommittedValueRef.current = value;
}, [defaultCodeValue, 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 updateStoredValue = (nextCodeValue: string, nextPhoneValue: string) => {
const draftValue = writePhoneValue(nextCodeValue, nextPhoneValue); const draftValue = writePhoneValue(nextCodeValue, nextPhoneValue);
const nextPhoneState = getNormalizedPhoneValue( const nextPhoneState = getNormalizedPhoneValue(
@ -241,12 +428,27 @@ export function QuestionPhone({
setAnswerValue(question, questionIndex, nextValue); 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 ( return (
<div <div
ref={containerRef}
data-question-answered={isAnswered ? "true" : "false"} data-question-answered={isAnswered ? "true" : "false"}
data-question-type={question.type} data-question-type={question.type}
className={[ 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" : "", disabled ? "pointer-events-none opacity-30" : "",
].join(" ")} ].join(" ")}
> >
@ -259,23 +461,34 @@ export function QuestionPhone({
].join(" ")} ].join(" ")}
> >
<div className="flex shrink-0 items-center pl-2.5 pr-2"> <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} 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]"
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"
/> />
<span aria-hidden="true" className="h-5 w-px bg-[#181818]/35" />
</svg>
</button>
<span aria-hidden="true" className="h-5 w-px bg-[#181818]/35 ml-1" />
</div> </div>
<span className="flex min-w-0 flex-1 items-center pr-4"> <span className="flex min-w-0 flex-1 items-center pr-4">
<input <input
@ -284,11 +497,17 @@ export function QuestionPhone({
disabled={disabled} disabled={disabled}
placeholder={question.extras.placeHolder?.replace(/^\+\d+\s*/, "")} placeholder={question.extras.placeHolder?.replace(/^\+\d+\s*/, "")}
value={phoneValue} value={phoneValue}
maxLength={getMaxLengthForCountry(codeValue)}
onChange={(event) => { onChange={(event) => {
if (typeof window !== "undefined") {
localStorage.setItem("hasCheckedGeoIPPhone", "true");
}
const nextPhoneValue = sanitizePhoneNumber(event.target.value); 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" 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]" 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. Enter a valid phone number with country code.
</span> </span>
) : null} ) : 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> </div>
); );
} }

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

@ -1,7 +1,7 @@
"use client"; "use client";
import Image from "next/image"; 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 type { QuestionField } from "@/data/question-data";
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media"; import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media";
import { getApiRequestUrl } from "@/lib/http"; import { getApiRequestUrl } from "@/lib/http";
@ -110,9 +110,11 @@ export function QuestionPhoto({
returnAs: "upload", returnAs: "upload",
uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`, uploadUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""}/upload-tmp-media/`,
fieldName: "file", 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 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]); }, [question]);
@ -139,7 +141,11 @@ export function QuestionPhoto({
// Determine photo URL to display // Determine photo URL to display
let displayUrl: string | null = localPreviewUrl; 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(); const val = storedValue.trim();
if ( if (
val.startsWith("http://") || val.startsWith("http://") ||
@ -163,7 +169,10 @@ export function QuestionPhoto({
disabled ? "pointer-events-none opacity-30" : "", disabled ? "pointer-events-none opacity-30" : "",
].join(" ")} ].join(" ")}
> >
<QuestionTitle question={question} className="justify-center text-center" />
<QuestionTitle
question={question}
className="justify-center text-center"
/>
<input <input
id={inputId} id={inputId}
@ -237,6 +246,3 @@ export function QuestionPhoto({
} }
export default QuestionPhoto; export default QuestionPhoto;

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

@ -171,7 +171,7 @@ export function QuestionProgressTracker({
onChange={updateProgress} onChange={updateProgress}
onInput={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 <div
aria-label={`Answered questions: ${answered} of ${safeTotal}`} aria-label={`Answered questions: ${answered} of ${safeTotal}`}
aria-valuemax={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 type { ReactNode } from "react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import Button from "@/components/ui/button"; import Button from "@/components/ui/button";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionProgressTracker, { import QuestionProgressTracker, {
useQuestionProgress, useQuestionProgress,
} from "./question-progress-tracker"; } from "./question-progress-tracker";
import QuestionSnapList from "./question-snap-list"; import QuestionSnapList from "./question-snap-list";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
type QuestionSectionFlowProps = { type QuestionSectionFlowProps = {
children: ReactNode; children: ReactNode;
continueLabel: string; continueLabel: string;
@ -115,7 +114,11 @@ export function QuestionSectionFlow({
}: QuestionSectionFlowProps) { }: QuestionSectionFlowProps) {
return ( return (
<QuestionProgressTracker total={total}> <QuestionProgressTracker total={total}>
<SectionFlowContent continueLabel={continueLabel} exitHref={exitHref} optionalQuestionIndexes={optionalQuestionIndexes}>
<SectionFlowContent
continueLabel={continueLabel}
exitHref={exitHref}
optionalQuestionIndexes={optionalQuestionIndexes}
>
{children} {children}
</SectionFlowContent> </SectionFlowContent>
</QuestionProgressTracker> </QuestionProgressTracker>

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

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

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

@ -117,7 +117,8 @@ export function QuestionSnapList({
const isDisabled = const isDisabled =
questionElement.getAttribute("data-question-disabled") === "true" || questionElement.getAttribute("data-question-disabled") === "true" ||
questionElement.querySelector("[data-question-disabled='true']") !== null;
questionElement.querySelector("[data-question-disabled='true']") !==
null;
if (isDisabled) { if (isDisabled) {
continue; continue;
@ -190,8 +191,9 @@ export function QuestionSnapList({
return; 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 }); firstFocusableInput.focus({ preventScroll: !isMobile });
@ -331,16 +333,24 @@ export function QuestionSnapList({
let wrapperStyles = "w-full"; let wrapperStyles = "w-full";
if (isActive) { 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) { } 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) { } 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 { } 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 ( return (
@ -357,9 +367,10 @@ export function QuestionSnapList({
containerStyles, containerStyles,
].join(" ")} ].join(" ")}
> >
<div className={wrapperStyles}>
{question}
</div>
<div className={wrapperStyles}>{question}</div>
{isActive && (
<div className="h-[30%] shrink min-h-0" aria-hidden="true" />
)}
</div> </div>
); );
})} })}

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

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

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

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

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

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

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

@ -3,7 +3,11 @@
import { DotsLoader } from "@/components/ui/button"; import { DotsLoader } from "@/components/ui/button";
import { PageBackground } from "@/components/utils/page-background"; 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 ( return (
<svg <svg
viewBox="0 0 160 160" 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" /> <rect x="50" y="38" width="60" height="26" rx="6" fill="#FFF1F2" />
{/* Small text lines in header */} {/* 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 */} {/* 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" /> <circle cx="97" cy="51" r="3.5" fill="#E03950" />
{/* Bar chart bars */} {/* Bar chart bars */}

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

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

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

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

@ -19,7 +19,9 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
}} }}
> >
<div className="text-center"> <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"> <p className="text-[#111111] group-16 font-bold">
{index + 1}/4 terms & conditions {index + 1}/4 terms & conditions
</p> </p>
@ -48,7 +50,9 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
</p> </p>
<p className="group-12 leading-[1.55]"> <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]"> <span className="font-normal text-[#525252]">
Mandatory submission of valid government-issued ID upon Mandatory submission of valid government-issued ID upon
registration. registration.
@ -56,7 +60,9 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
</p> </p>
<p className="group-12 leading-[1.55]"> <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]"> <span className="font-normal text-[#525252]">
Users must provide proof of being single, or documents Users must provide proof of being single, or documents
confirming divorce or spouse&apos;s death upon request. confirming divorce or spouse&apos;s death upon request.
@ -72,7 +78,9 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
</p> </p>
<p className="group-12 leading-[1.55]"> <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]"> <span className="font-normal text-[#525252]">
Self-declaration regarding mental health, absence of Self-declaration regarding mental health, absence of
addiction, and no criminal record. addiction, and no criminal record.
@ -86,7 +94,9 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
</h3> </h3>
<p className="group-12 leading-[1.55]"> <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]"> <span className="font-normal text-[#525252]">
Technical prevention of screenshots from profiles and chat Technical prevention of screenshots from profiles and chat
environments. environments.
@ -94,7 +104,9 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
</p> </p>
<p className="group-12 leading-[1.55]"> <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]"> <span className="font-normal text-[#525252]">
Sensitive information (face, contact details) revealed Sensitive information (face, contact details) revealed
step-by-step only with mutual consent. 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="min-h-0 flex-1 overflow-y-auto pb-[86px]">
<div className="text-center"> <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"> <p className="text-[#111111] group-16 font-bold">
{index + 1}/4 Watch Video {index + 1}/4 Watch Video
</p> </p>

4
src/components/ui/button.tsx

@ -30,7 +30,9 @@ export type ButtonProps = Omit<
export function DotsLoader({ className = "" }: { className?: string }) { export function DotsLoader({ className = "" }: { className?: string }) {
return ( 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-1" />
<span className="size-2 rounded-full bg-current animate-dots-slide-2" /> <span className="size-2 rounded-full bg-current animate-dots-slide-2" />
<span className="size-2 rounded-full bg-current animate-dots-slide-3" /> <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"; "use client";
import Image from "next/image";
import type { HTMLAttributes, ReactNode } from "react"; import type { HTMLAttributes, ReactNode } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import Image from "next/image";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -140,7 +140,12 @@ export function FemaleConsentSheet({
aria-label="Close" aria-label="Close"
className="items-center justify-center rounded-full text-[#A0A0A0]" 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> </button>
</div> </div>

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

@ -38,8 +38,7 @@ export function HelpModal({
description ?? description ??
(t.common as any)?.helpDescription ?? (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."; "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(() => { const closeSheet = useCallback(() => {
if (isClosing) return; if (isClosing) return;
@ -128,10 +127,11 @@ export function HelpModal({
{(() => { {(() => {
const items = Array.isArray(resolvedDescription) const items = Array.isArray(resolvedDescription)
? resolvedDescription ? resolvedDescription
: typeof resolvedDescription === "string"
: typeof resolvedDescription === "string" &&
resolvedDescription.includes("\n")
? resolvedDescription ? resolvedDescription
.split(/\.\s+/)
.map((s) => s.replace(/\.$/, "").trim())
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean) .filter(Boolean)
: null; : null;
@ -156,10 +156,7 @@ export function HelpModal({
})()} })()}
<div className="mt-5 w-full"> <div className="mt-5 w-full">
<Button
onClick={closeSheet}
className="py-[14px]"
>
<Button onClick={closeSheet} className="py-[14px]">
{resolvedButtonText} {resolvedButtonText}
</Button> </Button>
</div> </div>

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

@ -5,8 +5,8 @@ import { useRouter } from "next/navigation";
import type { ButtonHTMLAttributes, ReactNode } from "react"; import type { ButtonHTMLAttributes, ReactNode } from "react";
import { useState } from "react"; import { useState } from "react";
import { GoArrowLeft } from "react-icons/go"; import { GoArrowLeft } from "react-icons/go";
import { useI18n } from "@/translations/provider";
import HelpModal from "@/components/ui/help-modal"; import HelpModal from "@/components/ui/help-modal";
import { useI18n } from "@/translations/provider";
type NavigationButtonIcon = type NavigationButtonIcon =
| "back" | "back"
@ -113,7 +113,9 @@ export function NavigationButton({
const resolvedHelpDescription = const resolvedHelpDescription =
helpDescription ?? helpDescription ??
(icon === "document" ? (t.questions as any)?.testIntroDisclaimer : undefined);
(icon === "document"
? (t.questions as any)?.testIntroDisclaimer
: undefined);
return ( return (
<> <>
@ -131,7 +133,7 @@ export function NavigationButton({
if (icon === "back") { if (icon === "back") {
if (typeof window !== "undefined" && (window as any).HabibApp) { if (typeof window !== "undefined" && (window as any).HabibApp) {
(window as any).HabibApp.postMessage( (window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" })
JSON.stringify({ action: "close_service" }),
); );
} else { } else {
router.back(); 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 Button from "@/components/ui/button";
import { useFlutterBridge } from "@/hooks/useFlutterBridge"; import { useFlutterBridge } from "@/hooks/useFlutterBridge";
import { import {
downloadFile,
copyToClipboard, copyToClipboard,
openExternalUrl,
downloadFile,
isInFlutterWebView, isInFlutterWebView,
openExternalUrl,
} from "@/lib/webview-actions"; } from "@/lib/webview-actions";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -65,7 +65,9 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
mimeType: "application/pdf", mimeType: "application/pdf",
fileType: "document", fileType: "document",
}); });
console.log(sent ? "📥 download_file ارسال شد" : "⚠️ Flutter WebView یافت نشد");
console.log(
sent ? "📥 download_file ارسال شد" : "⚠️ Flutter WebView یافت نشد",
);
}; };
// ✅ تست copy_to_clipboard // ✅ تست copy_to_clipboard
@ -84,7 +86,9 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
mode: "externalApplication", mode: "externalApplication",
title: "test_open_url", title: "test_open_url",
}); });
console.log(sent ? "🔗 open_external_url ارسال شد" : "⚠️ Flutter WebView یافت نشد");
console.log(
sent ? "🔗 open_external_url ارسال شد" : "⚠️ Flutter WebView یافت نشد",
);
}; };
useEffect(() => { useEffect(() => {
@ -116,7 +120,9 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
}, [isClosing, onClose]); }, [isClosing, onClose]);
useEffect(() => { useEffect(() => {
const handleFlutterResponse: NonNullable<Window["onFlutterResponse"]> = (event) => {
const handleFlutterResponse: NonNullable<Window["onFlutterResponse"]> = (
event,
) => {
if (event.action === "get_location" && event.success && event.data) { if (event.action === "get_location" && event.success && event.data) {
const message = `Location: ${event.data.latitude}, ${event.data.longitude}`; const message = `Location: ${event.data.latitude}, ${event.data.longitude}`;
alert(message); alert(message);
@ -134,7 +140,9 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
} }
}; };
const unsubscribe = window.addFlutterResponseListener?.(handleFlutterResponse);
const unsubscribe = window.addFlutterResponseListener?.(
handleFlutterResponse,
);
return () => { return () => {
unsubscribe?.(); 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="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 justify-between">
<div className="flex items-center gap-2"> <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 <div
className={`w-2 h-2 rounded-full ${isReady ? "bg-green-500" : "bg-gray-400"}`} className={`w-2 h-2 rounded-full ${isReady ? "bg-green-500" : "bg-gray-400"}`}
title={isReady ? "Connected to Flutter" : "Inactive"} title={isReady ? "Connected to Flutter" : "Inactive"}
@ -191,7 +201,9 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
{/* Main buttons */} {/* Main buttons */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Button onClick={handleGetLocation}>📍 Get Location</Button> <Button onClick={handleGetLocation}>📍 Get Location</Button>
<Button onClick={handleOpenConsultant}>👨 Habib Consultation</Button>
<Button onClick={handleOpenConsultant}>
👨 Habib Consultation
</Button>
</div> </div>
{/* Test WebView Actions */} {/* Test WebView Actions */}
@ -206,7 +218,8 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
</div> </div>
{!isInFlutterWebView() && ( {!isInFlutterWebView() && (
<p className="text-[10px] text-purple-600 mt-2"> <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> </p>
)} )}
</div> </div>
@ -216,9 +229,7 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
<p className="text-xs text-blue-800 mb-2 font-semibold"> <p className="text-xs text-blue-800 mb-2 font-semibold">
🧪 Test Flutter Connection: 🧪 Test Flutter Connection:
</p> </p>
<Button onClick={handleSendWebReady}>
🚀 Send WEB_READY
</Button>
<Button onClick={handleSendWebReady}>🚀 Send WEB_READY</Button>
</div> </div>
{/* Last Received Event */} {/* Last Received Event */}
@ -228,9 +239,7 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
📥 Last Event from Flutter: 📥 Last Event from Flutter:
</p> </p>
<div className="bg-white rounded p-2 text-xs font-mono break-all"> <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"> <div className="text-gray-600 mt-1">
{JSON.stringify(lastEvent.payload, null, 2)} {JSON.stringify(lastEvent.payload, null, 2)}
</div> </div>
@ -275,11 +284,9 @@ export function ReportActionsSheet({ onClose }: ReportActionsSheetProps) {
<div className="bg-yellow-50 rounded-lg p-3 border border-yellow-200"> <div className="bg-yellow-50 rounded-lg p-3 border border-yellow-200">
<p className="text-xs text-yellow-900"> <p className="text-xs text-yellow-900">
<strong>💡 راهنما:</strong> <strong>💡 راهنما:</strong>
<br />
WEB_READY هنگام باز شدن این شیت بهصورت خودکار از کانال
<br /> WEB_READY هنگام باز شدن این شیت بهصورت خودکار از کانال
HabibApp ارسال میشود HabibApp ارسال میشود
<br />
نقطهٔ سبز یعنی کانال HabibApp در دسترس است
<br /> نقطهٔ سبز یعنی کانال HabibApp در دسترس است
<br /> برای ارسال دستی، دکمه «ارسال WEB_READY» را بزنید <br /> برای ارسال دستی، دکمه «ارسال WEB_READY» را بزنید
</p> </p>
</div> </div>

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

@ -2,7 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { MdOutlineSwitchAccount } from "react-icons/md"; 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 FEMALE_TOKEN = "545f61bb3e061ccb9b19f84715eb1b1ed4740331";
export const MALE_TOKEN = "f3a7543b44ef0a713d1ee0d4f7866b3825cf1308"; export const MALE_TOKEN = "f3a7543b44ef0a713d1ee0d4f7866b3825cf1308";

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(); const { bottom } = useViewPaddings();
return ( return (
<div
style={{ paddingBottom: `${bottom}px` }}
className={className}
>
<div style={{ paddingBottom: `${bottom}px` }} className={className}>
{children} {children}
</div> </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, question_number: 1,
factor_code: "S", factor_code: "S",
text: "مسایلی مثل پس انداز، مخارج زندگی، مسکن، آینده شغلی و.. تا چه اندازه ذهن شما را به خود مشغول می دارد؟"
text: "مسایلی مثل پس انداز، مخارج زندگی، مسکن، آینده شغلی و.. تا چه اندازه ذهن شما را به خود مشغول می دارد؟",
}, },
{ {
question_number: 2, question_number: 2,
factor_code: "S", factor_code: "S",
text: "تا چه اندازه به سلامت جسمانی، بهداشت و احتمال ابتلا به بیماری فکر میکنید؟"
text: "تا چه اندازه به سلامت جسمانی، بهداشت و احتمال ابتلا به بیماری فکر میکنید؟",
}, },
{ {
question_number: 3, question_number: 3,
factor_code: "S", factor_code: "S",
text: "شدت میل جنسی خود را چگونه ارزیابی میکنید؟"
text: "شدت میل جنسی خود را چگونه ارزیابی میکنید؟",
}, },
{ {
question_number: 4, question_number: 4,
factor_code: "S", factor_code: "S",
text: "در انجام کارها و اقدامات مخاطره انگیز تا چه اندازه محتاطانه عمل میکنید؟"
text: "در انجام کارها و اقدامات مخاطره انگیز تا چه اندازه محتاطانه عمل میکنید؟",
}, },
{ {
question_number: 5, question_number: 5,
factor_code: "S", factor_code: "S",
text: "از روبه رویی با تجارب جدید و شروع راه های ناشناخته تا چه اندازه اجتناب میکنید؟"
text: "از روبه رویی با تجارب جدید و شروع راه های ناشناخته تا چه اندازه اجتناب میکنید؟",
}, },
{ {
question_number: 6, question_number: 6,
factor_code: "S", factor_code: "S",
text: "از نظر دوستان و همکاران خود چقدر وقت شناس با نظم و ترتیب و دقیق هستید؟"
text: "از نظر دوستان و همکاران خود چقدر وقت شناس با نظم و ترتیب و دقیق هستید؟",
}, },
{ {
question_number: 7, question_number: 7,
factor_code: "S", factor_code: "S",
text: "تا چه اندازه امنیت شغلی و درآمد ثابت برایتان اهمیت دارد؟"
text: "تا چه اندازه امنیت شغلی و درآمد ثابت برایتان اهمیت دارد؟",
}, },
{ {
question_number: 8, question_number: 8,
factor_code: "L", factor_code: "L",
text: "احساس می کنید به چه میزان عشق، صمیمیت و مهرورزی نیاز دارید؟"
text: "احساس می کنید به چه میزان عشق، صمیمیت و مهرورزی نیاز دارید؟",
}, },
{ {
question_number: 9, question_number: 9,
factor_code: "L", factor_code: "L",
text: "تا چه اندازه رفاه و سعادت انسان های دیگر برایتان مهم است؟"
text: "تا چه اندازه رفاه و سعادت انسان های دیگر برایتان مهم است؟",
}, },
{ {
question_number: 10, question_number: 10,
factor_code: "L", factor_code: "L",
text: "تا چه اندازه تمایل دارید تجارب شخصی، احساسات عمیق و اسرار خود را با دیگران در میان بگذارید؟"
text: "تا چه اندازه تمایل دارید تجارب شخصی، احساسات عمیق و اسرار خود را با دیگران در میان بگذارید؟",
}, },
{ {
question_number: 11, question_number: 11,
factor_code: "L", factor_code: "L",
text: "در ایجاد صمیمیت و برقراری ارتباط عاطفی با دیگران چقدر پیش قدم می شوید؟"
text: "در ایجاد صمیمیت و برقراری ارتباط عاطفی با دیگران چقدر پیش قدم می شوید؟",
}, },
{ {
question_number: 12, question_number: 12,
factor_code: "L", factor_code: "L",
text: "تا چه اندازه از دیدن، وقت گذراندن و گفتگو با دوستان و آشنایان خود لذت می برید؟"
text: "تا چه اندازه از دیدن، وقت گذراندن و گفتگو با دوستان و آشنایان خود لذت می برید؟",
}, },
{ {
question_number: 13, question_number: 13,
factor_code: "L", factor_code: "L",
text: "میزان بخشندگی، گذشت و فراموش کردن خطای دیگران را در خود چگونه ارزیابی می کنید؟"
text: "میزان بخشندگی، گذشت و فراموش کردن خطای دیگران را در خود چگونه ارزیابی می کنید؟",
}, },
{ {
question_number: 14, question_number: 14,
factor_code: "L", factor_code: "L",
text: "تا چه اندازه به ایجاد و حفظ روابط دوستانه، صمیمی و پایدار با دیگران علاقه مند هستید؟"
text: "تا چه اندازه به ایجاد و حفظ روابط دوستانه، صمیمی و پایدار با دیگران علاقه مند هستید؟",
}, },
{ {
question_number: 15, question_number: 15,
factor_code: "P", factor_code: "P",
text: "تا چه اندازه تمایل دارید رهبری گروه‌ها، هدایت پروژه‌ها یا مدیریت امور را بر عهده بگیرید؟"
text: "تا چه اندازه تمایل دارید رهبری گروه‌ها، هدایت پروژه‌ها یا مدیریت امور را بر عهده بگیرید؟",
}, },
{ {
question_number: 16, question_number: 16,
factor_code: "P", factor_code: "P",
text: "رسیدن به موفقیت، موقعیت اجتماعی ممتاز و جایگاه برتر تا چه حد برایتان اهمیت دارد؟"
text: "رسیدن به موفقیت، موقعیت اجتماعی ممتاز و جایگاه برتر تا چه حد برایتان اهمیت دارد؟",
}, },
{ {
question_number: 17, question_number: 17,
factor_code: "P", factor_code: "P",
text: "در صورت بروز نقد، مخالفت یا اشتباه از سوی دیگران، تا چه حد رفتار تند، قاطع یا اصلاحی نشان می‌دهید؟"
text: "در صورت بروز نقد، مخالفت یا اشتباه از سوی دیگران، تا چه حد رفتار تند، قاطع یا اصلاحی نشان می‌دهید؟",
}, },
{ {
question_number: 18, question_number: 18,
factor_code: "P", factor_code: "P",
text: "تا چه اندازه تمایل دارید کارها دقیقاً طبق نظرات، استانداردها و روش‌های شما انجام شوند؟"
text: "تا چه اندازه تمایل دارید کارها دقیقاً طبق نظرات، استانداردها و روش‌های شما انجام شوند؟",
}, },
{ {
question_number: 19, question_number: 19,
factor_code: "P", factor_code: "P",
text: "در بحث‌ها و گفتگوها تا چه حد اصرار دارید حرف و نظر خود را به عنوان نظر درست به اثبات برسانید؟"
text: "در بحث‌ها و گفتگوها تا چه حد اصرار دارید حرف و نظر خود را به عنوان نظر درست به اثبات برسانید؟",
}, },
{ {
question_number: 20, question_number: 20,
factor_code: "P", factor_code: "P",
text: "وقتی احساس می‌کنید حق با شماست، تا چه اندازه حاضر به رقابت یا پافشاری بر روی خواسته‌هایتان هستید؟"
text: "وقتی احساس می‌کنید حق با شماست، تا چه اندازه حاضر به رقابت یا پافشاری بر روی خواسته‌هایتان هستید؟",
}, },
{ {
question_number: 21, question_number: 21,
factor_code: "P", factor_code: "P",
text: "دیدگاه شما نسبت به یادگیری، کسب دانش و بالا بردن اطلاعات تخصصی چیست؟"
text: "دیدگاه شما نسبت به یادگیری، کسب دانش و بالا بردن اطلاعات تخصصی چیست؟",
}, },
{ {
question_number: 22, question_number: 22,
factor_code: "Fr", factor_code: "Fr",
text: "تا چه اندازه تمایل دارید بدون دخالت، نظارت یا دستور دیگران اهداف شخصی خود را دنبال کنید؟"
text: "تا چه اندازه تمایل دارید بدون دخالت، نظارت یا دستور دیگران اهداف شخصی خود را دنبال کنید؟",
}, },
{ {
question_number: 23, question_number: 23,
factor_code: "Fr", factor_code: "Fr",
text: "در برابر احساس محدودیت، اجبار یا رعایت قوانین دست و پا گیر تا چه حد واکنش نشان می‌دهید؟"
text: "در برابر احساس محدودیت، اجبار یا رعایت قوانین دست و پا گیر تا چه حد واکنش نشان می‌دهید؟",
}, },
{ {
question_number: 24, question_number: 24,
factor_code: "Fr", factor_code: "Fr",
text: "میزان تمایل شما به داشتن حریم خصوصی، زمان تنهایی و استقلال در تصمیم‌گیری‌ها چقدر است؟"
text: "میزان تمایل شما به داشتن حریم خصوصی، زمان تنهایی و استقلال در تصمیم‌گیری‌ها چقدر است؟",
}, },
{ {
question_number: 25, question_number: 25,
factor_code: "Fr", factor_code: "Fr",
text: "تا چه اندازه رهایی از قید و بندها و برنامه‌های فشرده را ترجیح می‌دهید؟"
text: "تا چه اندازه رهایی از قید و بندها و برنامه‌های فشرده را ترجیح می‌دهید؟",
}, },
{ {
question_number: 26, question_number: 26,
factor_code: "Fr", factor_code: "Fr",
text: "در مواجهه با فشار روانی یا استرس، تا چه حد نیاز دارید فضا و وقت مستقل داشته باشید؟"
text: "در مواجهه با فشار روانی یا استرس، تا چه حد نیاز دارید فضا و وقت مستقل داشته باشید؟",
}, },
{ {
question_number: 27, question_number: 27,
factor_code: "Fr", factor_code: "Fr",
text: "تا چه اندازه تمایل دارید مسیر زندگی، سبک پوشش یا رفتارهای خود را بر اساس معیارهای فردی انتخاب کنید؟"
text: "تا چه اندازه تمایل دارید مسیر زندگی، سبک پوشش یا رفتارهای خود را بر اساس معیارهای فردی انتخاب کنید؟",
}, },
{ {
question_number: 28, question_number: 28,
factor_code: "Fr", factor_code: "Fr",
text: "چقدر برای آزادی اندیشه و ابراز دیدگاه‌های غیرمعمول اهمیت قائل هستید؟"
text: "چقدر برای آزادی اندیشه و ابراز دیدگاه‌های غیرمعمول اهمیت قائل هستید؟",
}, },
{ {
question_number: 29, question_number: 29,
factor_code: "Fu", factor_code: "Fu",
text: "تا چه اندازه اهل شوخ‌طبعی، خنده، لطیفه‌گویی و ایجاد فضای شاد هستید؟"
text: "تا چه اندازه اهل شوخ‌طبعی، خنده، لطیفه‌گویی و ایجاد فضای شاد هستید؟",
}, },
{ {
question_number: 30, question_number: 30,
factor_code: "Fu", factor_code: "Fu",
text: "چقدر زمان و هزینه برای سرگرمی، تفریحات بی‌دغدغه و فعالیت‌های مفرح اختصاص می‌دهید؟"
text: "چقدر زمان و هزینه برای سرگرمی، تفریحات بی‌دغدغه و فعالیت‌های مفرح اختصاص می‌دهید؟",
}, },
{ {
question_number: 31, question_number: 31,
factor_code: "Fu", factor_code: "Fu",
text: "تا چه اندازه از کارهای خلاقانه، هنری، بازی و سرگرمی لذت می‌برید؟"
text: "تا چه اندازه از کارهای خلاقانه، هنری، بازی و سرگرمی لذت می‌برید؟",
}, },
{ {
question_number: 32, question_number: 32,
factor_code: "Fu", factor_code: "Fu",
text: "چقدر قادر هستید لحظات سخت و جدیت زندگی را با نگاهی سبک، طنزآمیز و مثبت سپری کنید؟"
text: "چقدر قادر هستید لحظات سخت و جدیت زندگی را با نگاهی سبک، طنزآمیز و مثبت سپری کنید؟",
}, },
{ {
question_number: 33, question_number: 33,
factor_code: "Fu", factor_code: "Fu",
text: "تمایل شما به شرکت در دورهمی‌ها، برنامه‌های تفریحی و سفر با دوستان چقدر است؟"
text: "تمایل شما به شرکت در دورهمی‌ها، برنامه‌های تفریحی و سفر با دوستان چقدر است؟",
}, },
{ {
question_number: 34, question_number: 34,
factor_code: "Fu", factor_code: "Fu",
text: "چقدر برای داشتن سرگرمی‌ها (هابی) و فعالیت‌های غیرکاری وقت می‌گذارید؟"
text: "چقدر برای داشتن سرگرمی‌ها (هابی) و فعالیت‌های غیرکاری وقت می‌گذارید؟",
}, },
{ {
question_number: 35, question_number: 35,
factor_code: "Fu", 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 enQuestions from "@/data/questions/en.json";
import faQuestions from "@/data/questions/fa.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 = [ export const bookingTerms = [
"You will be contacted by your consultant.", "You will be contacted by your consultant.",
@ -108,7 +108,11 @@ function mapQuestionListItem(item: RawQuestionListItem): QuestionListItem {
} }
export function getQuestionListItems(locale: Locale = defaultLocale) { 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); return items.map(mapQuestionListItem);
} }

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

658
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, [...marriageQueryKeys.sections(), slug, "data"] as const,
sections: () => [...marriageQueryKeys.all, "sections"] as const, sections: () => [...marriageQueryKeys.all, "sections"] as const,
cattellQuestions: (lang?: string) => cattellQuestions: (lang?: string) =>
[...marriageQueryKeys.all, "cattell", "questions", lang ?? "default"] as const,
[
...marriageQueryKeys.all,
"cattell",
"questions",
lang ?? "default",
] as const,
cattellResult: (lang?: string) => cattellResult: (lang?: string) =>
[...marriageQueryKeys.all, "cattell", "result", lang ?? "default"] as const, [...marriageQueryKeys.all, "cattell", "result", lang ?? "default"] as const,
glasserQuestions: (lang?: string) => glasserQuestions: (lang?: string) =>
[...marriageQueryKeys.all, "glasser", "questions", lang ?? "default"] as const,
[
...marriageQueryKeys.all,
"glasser",
"questions",
lang ?? "default",
] as const,
glasserResult: (lang?: string) => glasserResult: (lang?: string) =>
[...marriageQueryKeys.all, "glasser", "result", lang ?? "default"] as const, [...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; contact_shared_at?: string | null;
minutes_since_contact_shared?: number | null; minutes_since_contact_shared?: number | null;
can_report_no_contact?: boolean; 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; other_action?: string;
}; };
@ -242,4 +249,3 @@ export type GlasserSubmitResponse = {
export type GlasserResultResponse = { export type GlasserResultResponse = {
results: Record<string, unknown>; results: Record<string, unknown>;
}; };

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

@ -1,6 +1,7 @@
"use client"; "use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toBackendSlug } from "@/data/section-slug-map";
import { http } from "@/lib/http"; import { http } from "@/lib/http";
import type { MutationOptions, QueryOptions } from "./options"; import type { MutationOptions, QueryOptions } from "./options";
import { pathParam } from "./path-param"; import { pathParam } from "./path-param";
@ -10,8 +11,6 @@ import type {
UpdateMarriageSectionDataPayload, UpdateMarriageSectionDataPayload,
} from "./types"; } from "./types";
import { toBackendSlug } from "@/data/section-slug-map";
export async function getMarriageSectionData(slug: string) { export async function getMarriageSectionData(slug: string) {
const backendSlug = toBackendSlug(slug); const backendSlug = toBackendSlug(slug);
const { data } = await http.get<MarriageSectionData>( 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 = () => { const handlePopState = () => {
if ((window as any).HabibApp?.postMessage) { if ((window as any).HabibApp?.postMessage) {
// Keep the state pinned in webview and notify Flutter to close the webview // 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( (window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" }), JSON.stringify({ action: "close_service" }),
); );

104
src/hooks/useFlutterBridge.ts

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

11
src/lib/auth-bridge.ts

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

6
src/lib/http.ts

@ -56,7 +56,9 @@ export function getApiRequestUrl(path: string) {
} }
export const http = axios.create({ 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: { headers: {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
@ -98,5 +100,5 @@ http.interceptors.request.use((config) => {
http.interceptors.response.use( http.interceptors.response.use(
(response) => response, (response) => response,
(error) => Promise.reject(error)
(error) => Promise.reject(error),
); );

18
src/lib/performance.ts

@ -1,8 +1,10 @@
export function measurePerformance() { 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 = { const metrics = {
dns: perfData.domainLookupEnd - perfData.domainLookupStart, dns: perfData.domainLookupEnd - perfData.domainLookupStart,
@ -14,14 +16,16 @@ export function measurePerformance() {
loadComplete: perfData.loadEventEnd - perfData.fetchStart, loadComplete: perfData.loadEventEnd - perfData.fetchStart,
}; };
console.log('⏱️ Performance Metrics:', metrics);
console.log("⏱️ Performance Metrics:", metrics);
// ارسال به analytics (اختیاری) // ارسال به analytics (اختیاری)
if ((window as any).HabibApp) { if ((window as any).HabibApp) {
(window as any).HabibApp.postMessage(JSON.stringify({
action: 'performance_metrics',
(window as any).HabibApp.postMessage(
JSON.stringify({
action: "performance_metrics",
data: metrics, data: metrics,
}));
}),
);
} }
}); });
} }

18
src/lib/view-paddings.ts

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

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

@ -35,7 +35,9 @@ module.exports = function (source, inputSourceMap) {
return; return;
} }
} catch (err) { } 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; return source;

5
src/translations/dictionaries.ts

@ -55,5 +55,8 @@ export const dictionaries = {
export type Dictionary = typeof dictionaries.en; export type Dictionary = typeof dictionaries.en;
export function getDictionary(locale: Locale): Dictionary { 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
);
} }

2
src/translations/locales/ar.json

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

2
src/translations/locales/az.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/bn.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/da.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/de.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/en.json

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

2
src/translations/locales/es.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/fa.json

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

2
src/translations/locales/fr.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/gu.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/ha.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/he.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/hi.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/id.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/ks.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/pt.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/ru.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/sw.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/tg.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/tr.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Eşleşme isteği gönderilemedi. Lütfen bağlantınızı kontrol edip tekrar deneyin.",
"moveToEnd": "Sona Taşı" "moveToEnd": "Sona Taşı"
}, },

2
src/translations/locales/ul.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/ur.json

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

2
src/translations/locales/uz.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

2
src/translations/locales/zh.json

@ -63,7 +63,7 @@
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships." "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.", "startMatchFailed": "Sending the match request failed. Please check your connection and try again.",
"moveToEnd": "Move to the End" "moveToEnd": "Move to the End"
}, },

6
src/translations/provider.tsx

@ -7,7 +7,11 @@ import {
useEffect, useEffect,
useMemo, useMemo,
} from "react"; } from "react";
import { defaultLocale, type Locale, localeDirections } from "@/translations/config";
import {
defaultLocale,
type Locale,
localeDirections,
} from "@/translations/config";
import { type Dictionary, getDictionary } from "@/translations/dictionaries"; import { type Dictionary, getDictionary } from "@/translations/dictionaries";
type I18nContextValue = { type I18nContextValue = {

2
src/view-paddings.ts

@ -1,7 +1,7 @@
export { FixedBottom } from "./components/utils/fixed-bottom";
export { useFlutterConfig, useViewPaddings } from "./hooks/use-view-paddings"; export { useFlutterConfig, useViewPaddings } from "./hooks/use-view-paddings";
export { export {
type InitialConfig, type InitialConfig,
type ViewPaddings, type ViewPaddings,
viewPaddingsBridge, viewPaddingsBridge,
} from "./lib/view-paddings"; } from "./lib/view-paddings";
export { FixedBottom } from "./components/utils/fixed-bottom";
Loading…
Cancel
Save