parent
commit
7457719e45
  1. 30
      package-lock.json
  2. 11
      src/app/[lang]/questions-list/[slug]/page.tsx
  3. 5
      src/app/api/proxy/route.ts
  4. 82
      src/app/intro/page.tsx
  5. 2
      src/app/new-match/page.tsx
  6. 25
      src/app/questions-list/[slug]/page.tsx
  7. 275
      src/app/questions-list/[slug]/question-detail-client.test.tsx
  8. 512
      src/app/questions-list/[slug]/question-detail-client.tsx
  9. 89
      src/app/questions-list/page.tsx
  10. 6
      src/app/questions-list/sections-request.tsx
  11. 100
      src/app/terms/page.test.tsx
  12. 59
      src/app/terms/page.tsx
  13. 72
      src/components/Componentes/conditional-questions.tsx
  14. 10
      src/components/Componentes/fix-to-the-end.tsx
  15. 524
      src/components/Componentes/progress-helper.ts
  16. 171
      src/components/Componentes/question-answer-storage.tsx
  17. 260
      src/components/Componentes/question-answer.test.tsx
  18. 12
      src/components/Componentes/question-birthplace.tsx
  19. 6
      src/components/Componentes/question-button.tsx
  20. 2
      src/components/Componentes/question-card.tsx
  21. 49
      src/components/Componentes/question-checkbox.tsx
  22. 38
      src/components/Componentes/question-date.tsx
  23. 188
      src/components/Componentes/question-dropdown.tsx
  24. 18
      src/components/Componentes/question-exit-navigation-button.tsx
  25. 20
      src/components/Componentes/question-file.tsx
  26. 38
      src/components/Componentes/question-number.tsx
  27. 8
      src/components/Componentes/question-phone.tsx
  28. 18
      src/components/Componentes/question-photo.tsx
  29. 28
      src/components/Componentes/question-radio.tsx
  30. 29
      src/components/Componentes/question-renderer.tsx
  31. 22
      src/components/Componentes/question-section-flow.tsx
  32. 14
      src/components/Componentes/question-slider.tsx
  33. 35
      src/components/Componentes/question-text.tsx
  34. 10
      src/components/Componentes/question-textarea.tsx
  35. 49
      src/components/Componentes/question-title.tsx
  36. 78
      src/components/Componentes/required-steps-card.tsx
  37. 235
      src/components/Componentes/schema-question-flow.integration.test.tsx
  38. 265
      src/components/Componentes/slider-page.test.tsx
  39. 96
      src/components/Componentes/slider-page.tsx
  40. 17
      src/components/Componentes/slider-slide-one.tsx
  41. 1
      src/components/Componentes/test-questions-flow.tsx
  42. 114
      src/components/Componentes/ui-config.test.tsx
  43. 1067
      src/data/cattell-fallback.ts
  44. 183
      src/data/glasser-fallback.ts
  45. 345
      src/data/question-data.ts
  46. 1870
      src/data/questions/en.json
  47. 1870
      src/data/questions/fa.json
  48. 47
      src/data/section-slug-map.ts
  49. 4
      src/hooks/marriage/types.ts
  50. 9
      src/hooks/marriage/use-form-schema.ts
  51. 196
      src/hooks/marriage/use-section-data.ts
  52. 27
      src/lib/auth-bridge.ts
  53. 88
      src/lib/get-submit-path.test.ts
  54. 144
      src/lib/schema-adapter.ts
  55. 5
      src/translations/locales/ar.json
  56. 5
      src/translations/locales/az.json
  57. 5
      src/translations/locales/bn.json
  58. 5
      src/translations/locales/da.json
  59. 5
      src/translations/locales/de.json
  60. 6
      src/translations/locales/en.json
  61. 5
      src/translations/locales/es.json
  62. 6
      src/translations/locales/fa.json
  63. 5
      src/translations/locales/fr.json
  64. 5
      src/translations/locales/gu.json
  65. 5
      src/translations/locales/ha.json
  66. 5
      src/translations/locales/he.json
  67. 5
      src/translations/locales/hi.json
  68. 5
      src/translations/locales/id.json
  69. 5
      src/translations/locales/ks.json
  70. 5
      src/translations/locales/pt.json
  71. 5
      src/translations/locales/ru.json
  72. 5
      src/translations/locales/sw.json
  73. 5
      src/translations/locales/tg.json
  74. 5
      src/translations/locales/tr.json
  75. 5
      src/translations/locales/ul.json
  76. 5
      src/translations/locales/ur.json
  77. 5
      src/translations/locales/uz.json
  78. 5
      src/translations/locales/zh.json

30
package-lock.json

@ -136,6 +136,7 @@
"integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^8.0.0", "@babel/code-frame": "^8.0.0",
"@babel/generator": "^8.0.0", "@babel/generator": "^8.0.0",
@ -906,6 +907,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
}, },
@ -954,6 +956,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
} }
@ -2231,7 +2234,6 @@
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-validator-identifier": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0", "js-tokens": "^4.0.0",
@ -2247,7 +2249,6 @@
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@ -2257,8 +2258,7 @@
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true, "dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
}, },
"node_modules/@testing-library/jest-dom": { "node_modules/@testing-library/jest-dom": {
"version": "7.0.0", "version": "7.0.0",
@ -2337,8 +2337,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
}, },
"node_modules/@types/chai": { "node_modules/@types/chai": {
"version": "5.2.3", "version": "5.2.3",
@ -2392,6 +2391,7 @@
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@ -2402,6 +2402,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@ -2412,6 +2413,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@ -2547,7 +2549,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -2558,7 +2559,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@ -2646,6 +2646,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.44", "baseline-browser-mapping": "^2.10.44",
"caniuse-lite": "^1.0.30001806", "caniuse-lite": "^1.0.30001806",
@ -2828,8 +2829,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
}, },
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
@ -3240,6 +3240,7 @@
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1", "@asamuzakjp/dom-selector": "^7.1.1",
@ -3578,7 +3579,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@ -3773,6 +3773,7 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -3814,7 +3815,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@ -3848,6 +3848,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -3857,6 +3858,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@ -3878,8 +3880,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
}, },
"node_modules/redent": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
@ -4273,6 +4274,7 @@
"integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.33.0", "lightningcss": "^1.33.0",
"picomatch": "^4.0.5", "picomatch": "^4.0.5",

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

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

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

@ -131,7 +131,7 @@ function getRequestHeaders(request: NextRequest, targetUrl: URL) {
getCookieValue(cookieHeader, "token") ?? getCookieValue(cookieHeader, "token") ??
getCookieValue(cookieHeader, "auth_token"); getCookieValue(cookieHeader, "auth_token");
if (cookieToken && cookieToken !== "NO_TOKEN") {
if (cookieToken && cookieToken !== "NO_TOKEN" && cookieToken.trim() !== "") {
headers.set("authorization", `Token ${cookieToken}`); headers.set("authorization", `Token ${cookieToken}`);
} }
} }
@ -143,8 +143,9 @@ function getRequestHeaders(request: NextRequest, targetUrl: URL) {
// Fall back to the development default token when nothing else provided auth. // Fall back to the development default token when nothing else provided auth.
if (!headers.has("authorization")) { if (!headers.has("authorization")) {
const isDevelopment = process.env.NODE_ENV !== "production";
const defaultToken = process.env.NEXT_PUBLIC_DEFAULT_TOKEN; const defaultToken = process.env.NEXT_PUBLIC_DEFAULT_TOKEN;
if (defaultToken && defaultToken !== "NO_TOKEN") {
if (isDevelopment && defaultToken && defaultToken !== "NO_TOKEN") {
headers.set("authorization", `Token ${defaultToken}`); headers.set("authorization", `Token ${defaultToken}`);
} }
} }

82
src/app/intro/page.tsx

@ -2,12 +2,11 @@
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import Button from "@/components/Componentes/button"; import Button from "@/components/Componentes/button";
import PageHeader from "@/components/Componentes/page-header"; import PageHeader from "@/components/Componentes/page-header";
import ReportActionsSheet from "@/components/Componentes/report-actions-sheet"; import ReportActionsSheet from "@/components/Componentes/report-actions-sheet";
import VideoPlayer from "@/components/Componentes/video-player"; import VideoPlayer from "@/components/Componentes/video-player";
import type { MarriageProfileResponse } from "@/hooks/marriage/types";
import { useMarriageConfigQuery } from "@/hooks/marriage/use-marriage-config"; import { useMarriageConfigQuery } from "@/hooks/marriage/use-marriage-config";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { authBridge } from "@/lib/auth-bridge"; import { authBridge } from "@/lib/auth-bridge";
@ -15,8 +14,6 @@ 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";
const REDIRECT_SESSION_KEY = "redirect";
export default function Intro() { export default function Intro() {
const router = useRouter(); const router = useRouter();
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
@ -26,80 +23,10 @@ export default function Intro() {
}); });
const [isReportSheetOpen, setIsReportSheetOpen] = useState(false); const [isReportSheetOpen, setIsReportSheetOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isCheckingRedirect, setIsCheckingRedirect] = useState(true);
const [isPlayerOpen, setIsPlayerOpen] = useState(false); const [isPlayerOpen, setIsPlayerOpen] = useState(false);
const isRedirectingRef = useRef(false);
const { data: config } = useMarriageConfigQuery(); const { data: config } = useMarriageConfigQuery();
useEffect(() => {
let isCancelled = false;
const redirectIfNeeded = async () => {
if (isRedirectingRef.current) {
return;
}
try {
const isAuthenticated = authBridge.isAuthenticated();
if (!isAuthenticated) {
if (!isCancelled) {
setIsCheckingRedirect(false);
}
return;
}
isRedirectingRef.current = true;
if (!isCancelled) {
setIsCheckingRedirect(true);
}
const profileResponse = profile ?? (await refetch()).data;
const nextPath = localizePath(getSubmitPath(profileResponse), locale);
if (typeof window !== "undefined") {
try {
window.sessionStorage.removeItem(REDIRECT_SESSION_KEY);
} catch (e) {
console.warn("sessionStorage is not accessible:", e);
}
}
if (!isCancelled) {
const currentPath =
typeof window !== "undefined" ? window.location.pathname : "";
if (currentPath === nextPath) {
setIsCheckingRedirect(false);
} else {
router.replace(nextPath);
}
return;
}
} catch (error) {
console.error("Error in redirectIfNeeded:", error);
if (!isCancelled) {
setIsCheckingRedirect(false);
}
} finally {
isRedirectingRef.current = false;
}
};
void redirectIfNeeded();
const unsubscribe = window.addFlutterResponseListener?.((event) => {
if (event.action === "login" && event.success) {
void redirectIfNeeded();
}
});
return () => {
isCancelled = true;
unsubscribe?.();
};
}, [locale, profile, refetch, router]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting) { if (isSubmitting) {
return; return;
@ -111,7 +38,8 @@ export default function Intro() {
if (!authBridge.isAuthenticated()) { if (!authBridge.isAuthenticated()) {
const token = await authBridge.ensureToken(); const token = await authBridge.ensureToken();
if (!token) { if (!token) {
console.warn("No token from bridge – proceeding with fallback path");
console.warn("No token from bridge – login was not completed");
return;
} }
} }
@ -140,10 +68,6 @@ export default function Intro() {
} }
}; };
if (isCheckingRedirect) {
return null;
}
return ( return (
<div className="pt-[max(16px,calc(var(--safe-top)+8px))]"> <div className="pt-[max(16px,calc(var(--safe-top)+8px))]">
{isReportSheetOpen && ( {isReportSheetOpen && (

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

@ -501,7 +501,7 @@ export default function NewMatchPage() {
{profile?.can_edit_profile === false && ( {profile?.can_edit_profile === false && (
<div <div
style={{ paddingBottom: `${16 + bottom}px` }} style={{ paddingBottom: `${16 + bottom}px` }}
className="fixed inset-x-0 bottom-0 z-20 mx-auto w-full max-w-[375px] bg-background/95 px-[17px] pt-3 pb-[16px] backdrop-blur-md"
className="fixed inset-x-0 bottom-0 z-20 mx-auto w-full sm:max-w-[375px] bg-background/95 px-[17px] pt-3 pb-[16px] backdrop-blur-md"
> >
<div <div
className="inline-flex w-full items-center justify-center gap-2 rounded-[9px] border-none bg-[#D1D1D6] px-4 h-[52px] text-center text-[#747474] shadow-none" className="inline-flex w-full items-center justify-center gap-2 rounded-[9px] border-none bg-[#D1D1D6] px-4 h-[52px] text-center text-[#747474] shadow-none"

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

@ -1,23 +1,7 @@
import { notFound } from "next/navigation";
import {
getQuestionListItemBySlug,
getQuestionListItems,
} from "@/data/question-data";
import { defaultLocale, isLocale, locales } from "@/translations/config";
import { defaultLocale, isLocale } from "@/translations/config";
import { getDictionary } from "@/translations/dictionaries"; import { getDictionary } from "@/translations/dictionaries";
import QuestionDetailClient from "./question-detail-client"; import QuestionDetailClient from "./question-detail-client";
export function generateStaticParams() {
const slugs = new Set(
locales.flatMap((locale) =>
getQuestionListItems(locale).map((item) => item.slug),
),
);
return Array.from(slugs).map((slug) => ({ slug }));
}
type QuestionDetailPageProps = { type QuestionDetailPageProps = {
params: Promise<{ params: Promise<{
lang?: string; lang?: string;
@ -35,15 +19,10 @@ export default async function QuestionDetailPage({
? `/${locale}/questions-list` ? `/${locale}/questions-list`
: "/questions-list"; : "/questions-list";
const t = getDictionary(locale); const t = getDictionary(locale);
const item = getQuestionListItemBySlug(slug, locale);
if (!item) {
notFound();
}
return ( return (
<QuestionDetailClient <QuestionDetailClient
itemSlug={item.slug}
itemSlug={slug}
locale={locale} locale={locale}
questionsListHref={questionsListHref} questionsListHref={questionsListHref}
title={t["Answer at Your Own Pace"]} title={t["Answer at Your Own Pace"]}

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

@ -0,0 +1,275 @@
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import QuestionDetailClient from "./question-detail-client";
import { useCattellQuestionsQuery } from "@/hooks/marriage/use-cattell";
import { useGlasserQuestionsQuery } from "@/hooks/marriage/use-glasser";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ replace: vi.fn(), push: vi.fn() })),
}));
vi.mock("@/translations/provider", () => ({
useI18n: vi.fn(() => ({ locale: "en", dictionary: new Proxy({}, { get: (_, key) => key }) })),
}));
vi.mock("@/hooks/marriage/use-cattell", () => ({
useCattellQuestionsQuery: vi.fn(),
useSubmitCattellAssessmentMutation: vi.fn(() => ({ mutateAsync: vi.fn() })),
}));
vi.mock("@/hooks/marriage/use-glasser", () => ({
useGlasserQuestionsQuery: vi.fn(),
useSubmitGlasserAssessmentMutation: vi.fn(() => ({ mutateAsync: vi.fn() })),
}));
vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-form-schema", () => ({
useFormSchemaQuery: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-habcoin-payment", () => ({
useHabcoinPaymentMutation: vi.fn(() => ({ mutateAsync: vi.fn() })),
}));
vi.mock("@/lib/schema-adapter", () => ({
convertSchemaToFrontendItems: vi.fn(),
}));
describe("QuestionDetailClient Validation", () => {
const mockCattellRefetch = vi.fn();
const mockGlasserRefetch = vi.fn();
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.clearAllMocks();
(useMarriageProfileQuery as any).mockReturnValue({
data: { age: 30, gender: "male" },
isLoading: false,
});
(useFormSchemaQuery as any).mockReturnValue({
data: {},
isLoading: false,
});
});
const setupTest = (slug: string, cattellData: any, glasserData: any) => {
// Mock schema adapter to return an item for the requested slug
(convertSchemaToFrontendItems as any).mockReturnValue([
{
slug: slug,
title: "Test",
questions: []
}
]);
(useCattellQuestionsQuery as any).mockReturnValue({
data: cattellData,
isLoading: false,
isError: false,
refetch: mockCattellRefetch,
});
(useGlasserQuestionsQuery as any).mockReturnValue({
data: glasserData,
isLoading: false,
isError: false,
refetch: mockGlasserRefetch,
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
render(
<QueryClientProvider client={queryClient}>
<QuestionDetailClient
closeLabel="Close"
continueLabel="Continue"
description="Desc"
informationLabel="Info"
itemSlug={slug}
questionsListHref="/questions-list"
title="Test"
/>
</QueryClientProvider>
);
// Intro screen might render first; if Start is available, click it to mount questions flow
const startButton = screen.queryByText("Start");
if (startButton) {
fireEvent.click(startButton);
}
};
it("should render correctly when Cattell API data is completely valid", () => {
setupTest("personality_test", {
questions: [
{
question_number: 1,
text: "Valid Question Cattell",
options: [
{ id: "opt_a", label: "Opt1", value: "A" },
{ id: "opt_b", label: "Opt2", value: "B" },
{ id: "opt_c", label: "Opt3", value: "C" },
],
}
]
}, null);
// Retry UI should NOT be present
expect(screen.queryByText("Retry")).toBeNull();
// Question text should be visible
expect(screen.getByText("Valid Question Cattell")).toBeDefined();
});
it("should render Retry UI when Cattell API data is empty", () => {
setupTest("personality_test", { questions: [] }, null);
expect(screen.getAllByText("No questions found for this test.")).toBeDefined();
expect(screen.getAllByText("Retry")).toBeDefined();
});
it("should render Retry UI when Cattell options are invalid (schema failure) and trigger refetch on Retry", async () => {
setupTest("personality_test", {
questions: [
{
question_number: 1,
text: "Invalid Question",
options: [{ label: "Opt1", value: "A" }], // Invalid schema
}
]
}, null);
expect(screen.getAllByText("No questions found for this test.")).toBeDefined();
const retryBtn = screen.getAllByText("Retry")[0];
fireEvent.click(retryBtn);
await waitFor(() => {
expect(mockCattellRefetch).toHaveBeenCalled();
});
});
it("should render correctly when Glasser API data is completely valid", () => {
setupTest("glasser_5_needs_test", null, {
questions: [
{
question_number: 1,
text: "Valid Question Glasser",
factor_code: "SUR",
options: [
{ id: "o1", label: "O1", value: 1 },
{ id: "o2", label: "O2", value: 2 },
{ id: "o3", label: "O3", value: 3 },
{ id: "o4", label: "O4", value: 4 },
{ id: "o5", label: "O5", value: 5 },
],
}
]
});
expect(screen.queryByText("Retry")).toBeNull();
expect(screen.getByText("Valid Question Glasser")).toBeDefined();
});
it("should render Retry UI when Glasser options are invalid (schema failure) and trigger refetch on Retry", async () => {
setupTest("glasser_5_needs_test", null, {
questions: [
{
question_number: 1,
text: "Invalid Question Glasser",
factor_code: "SUR",
options: [
{ label: "O1", value: 1 },
{ label: "O2", value: 2 },
{ label: "O3", value: 3 },
{ label: "O4", value: 4 },
],
}
]
});
expect(screen.getAllByText("No questions found for this test.")).toBeDefined();
const retryBtn = screen.getAllByText("Retry")[0];
fireEvent.click(retryBtn);
await waitFor(() => {
expect(mockGlasserRefetch).toHaveBeenCalled();
});
});
it("should render profile questions using ID-based data flow", () => {
(convertSchemaToFrontendItems as any).mockReturnValue([
{
slug: "profile_test",
title: "Profile Form",
questions: [
{
id: "q_123",
title: "Dynamic ID Question",
type: "text",
order: 1,
required: true,
isVisible: true,
private: false,
description: "",
tooltip: "",
extras: {},
options: []
},
{
id: "q_456",
title: "Another ID Question",
type: "radio",
order: 2,
required: false,
isVisible: true,
private: false,
description: "",
tooltip: "",
extras: {},
options: [
{ id: "opt_1", label: "Yes", value: "yes", order: 1 },
{ id: "opt_2", label: "No", value: "no", order: 2 }
]
}
]
}
]);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<QuestionDetailClient
closeLabel="Close"
continueLabel="Continue"
description="Desc"
informationLabel="Info"
itemSlug="profile_test"
questionsListHref="/questions-list"
title="Profile Test"
/>
</QueryClientProvider>
);
// Profile questions render directly, no start button
expect(screen.getByText(/Dynamic ID/)).toBeDefined();
});
});

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

@ -18,25 +18,16 @@ import TestIntroPage from "@/components/Componentes/test-intro-page";
import TestQuestionsFlow, { import TestQuestionsFlow, {
type TestQuestion, type TestQuestion,
} from "@/components/Componentes/test-questions-flow"; } from "@/components/Componentes/test-questions-flow";
import { cattellFallbackQuestions } from "@/data/cattell-fallback";
import { glasserFallbackQuestions } from "@/data/glasser-fallback";
import { import {
getQuestionListItemBySlug,
isQuestionListItemVisibleForProfile,
isQuestionRequiredForProfile,
isQuestionVisibleForProfile,
type QuestionField,
} from "@/data/question-data";
import type { MarriageGender } from "@/hooks/marriage/types";
useGlasserQuestionsQuery,
useSubmitGlasserAssessmentMutation,
} from "@/hooks/marriage/use-glasser";
import { import {
useCattellQuestionsQuery, useCattellQuestionsQuery,
useSubmitCattellAssessmentMutation, useSubmitCattellAssessmentMutation,
} from "@/hooks/marriage/use-cattell"; } from "@/hooks/marriage/use-cattell";
import {
useGlasserQuestionsQuery,
useSubmitGlasserAssessmentMutation,
} from "@/hooks/marriage/use-glasser";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { convertSchemaToFrontendItems, type QuestionField } from "@/lib/schema-adapter";
import { defaultLocale, type Locale } from "@/translations/config"; import { defaultLocale, type Locale } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
@ -70,353 +61,24 @@ function getQuestionStorageKey(slug: string) {
return `marriage:sections:${slug}:answers`; return `marriage:sections:${slug}:answers`;
} }
function parseStoredAge(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const trimmedValue = value.trim();
if (!trimmedValue) {
return null;
}
const numericAge = Number(trimmedValue);
if (Number.isFinite(numericAge)) {
return numericAge;
}
const dateOfBirth = new Date(trimmedValue);
if (Number.isNaN(dateOfBirth.getTime())) {
return null;
}
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
const hasBirthdayPassed =
today.getMonth() > dateOfBirth.getMonth() ||
(today.getMonth() === dateOfBirth.getMonth() &&
today.getDate() >= dateOfBirth.getDate());
if (!hasBirthdayPassed) {
age -= 1;
}
return age >= 0 ? age : null;
}
return null;
}
function getStoredAge() {
try {
const rawValue = window.localStorage.getItem(
getQuestionStorageKey("personal_info"),
);
if (!rawValue) {
return null;
}
const storedAnswers = JSON.parse(rawValue) as StoredAnswers;
const ageField = storedAnswers.fields?.find((field) => {
const f = field as { key?: string; type?: string; label?: string };
return (
f.type === "number" ||
f.label === "Age" ||
f.label === "ط³ظ†" ||
(typeof f.key === "string" &&
(f.key.endsWith("_age") || f.key.endsWith("_sn")))
);
});
if (ageField) {
return parseStoredAge(ageField.value);
}
const dateOfBirthField = storedAnswers.fields?.find((field) => {
const f = field as { key?: string; type?: string; label?: string };
return (
f.type === "date" ||
f.label === "Date of Birth" ||
f.label === "طھط§ط±غŒط® طھظˆظ„ط¯" ||
(typeof f.key === "string" &&
(f.key.endsWith("_date_of_birth") || f.key.endsWith("_tarykh_twld")))
);
});
return parseStoredAge(dateOfBirthField?.value);
} catch {
return null;
}
}
function QuestionFlowWrapper({ function QuestionFlowWrapper({
visibleQuestions, visibleQuestions,
itemSlug, itemSlug,
dobQuestion, dobQuestion,
dobQuestionIndex,
continueLabel, continueLabel,
questionsListHref, questionsListHref,
}: { }: {
visibleQuestions: QuestionField[]; visibleQuestions: QuestionField[];
itemSlug: string; itemSlug: string;
dobQuestion?: QuestionField; dobQuestion?: QuestionField;
dobQuestionIndex?: number;
requiredQuestionsCount: number; requiredQuestionsCount: number;
continueLabel: string; continueLabel: string;
questionsListHref: string; questionsListHref: string;
}) { }) {
const { getAnswerValue } = useQuestionAnswers(); const { getAnswerValue } = useQuestionAnswers();
const dynamicQuestions = useMemo(() => {
// Find employment status question by title or by choices length (10)
const employmentQuestionIndex = visibleQuestions.findIndex((q) => {
const enTitle = q.englishTitle || q.title;
return (
enTitle === "Employment Status" ||
(q.type === "dropdown" && q.extras.options?.length === 10)
);
});
let employmentSelectedOptionIndex = -1;
if (employmentQuestionIndex !== -1) {
const employmentQuestion = visibleQuestions[employmentQuestionIndex];
const ans = getAnswerValue(employmentQuestion, employmentQuestionIndex);
if (ans) {
employmentSelectedOptionIndex =
employmentQuestion.extras.options?.indexOf(String(ans)) ?? -1;
}
}
// Find Parents' Survival Status question index and check selected index
const survivalStatusQuestionIndex = visibleQuestions.findIndex((q) => {
const enTitle = q.englishTitle || q.title;
return enTitle === "Parents' Survival Status";
});
let survivalSelectedOptionIndex = -1;
if (survivalStatusQuestionIndex !== -1) {
const survivalQuestion = visibleQuestions[survivalStatusQuestionIndex];
const ans = getAnswerValue(survivalQuestion, survivalStatusQuestionIndex);
if (ans) {
survivalSelectedOptionIndex =
survivalQuestion.extras.options?.indexOf(String(ans)) ?? -1;
}
}
// Find Parents' Marital Status question index and check selected index
const maritalStatusQuestionIndex = visibleQuestions.findIndex((q) => {
const enTitle = q.englishTitle || q.title;
return enTitle === "Parents' Marital Status";
});
let isCircumstancesSelected = false;
if (maritalStatusQuestionIndex !== -1) {
const maritalQuestion = visibleQuestions[maritalStatusQuestionIndex];
const ans = getAnswerValue(maritalQuestion, maritalStatusQuestionIndex);
if (ans) {
const selectedIdx =
maritalQuestion.extras.options?.indexOf(String(ans)) ?? -1;
isCircumstancesSelected = selectedIdx === 2;
}
}
// Find Current Marital Status question index and check selected index
const currentMaritalQuestionIndex = visibleQuestions.findIndex((q) => {
const enTitle = q.englishTitle || q.title;
return enTitle === "Current Marital Status";
});
let maritalSelectedIndex = -1;
if (currentMaritalQuestionIndex !== -1) {
const maritalQuestion = visibleQuestions[currentMaritalQuestionIndex];
const ans = getAnswerValue(maritalQuestion, currentMaritalQuestionIndex);
if (ans) {
maritalSelectedIndex =
maritalQuestion.extras.options?.indexOf(String(ans)) ?? -1;
}
}
// Find Children and Guardianship Status question index
const custodyQuestionIndex = visibleQuestions.findIndex((q) => {
const enTitle = q.englishTitle || q.title;
return enTitle === "Children and Guardianship Status";
});
let hasChildrenSelected = false;
let hasAnyGuardianshipSelected = false;
if (custodyQuestionIndex !== -1) {
const custodyQuestion = visibleQuestions[custodyQuestionIndex];
const ans = getAnswerValue(custodyQuestion, custodyQuestionIndex);
if (ans) {
const ansList = Array.isArray(ans) ? ans.map(String) : [String(ans)];
const selectedIndices = ansList.map((val) =>
custodyQuestion.extras.options?.indexOf(val),
);
hasChildrenSelected = selectedIndices.some(
(idx) => idx === 1 || idx === 2,
);
hasAnyGuardianshipSelected = selectedIndices.some(
(idx) => idx === 2 || idx === 3,
);
}
}
const filtered = visibleQuestions.filter((question, index) => {
const enTitle = question.englishTitle || question.title;
// Check if this is one of the marital status/children questions
if (currentMaritalQuestionIndex !== -1) {
const isDuration =
index === currentMaritalQuestionIndex + 1 ||
enTitle === "Previous Marriage Duration";
const isSeparation =
index === currentMaritalQuestionIndex + 2 ||
enTitle === "Reason for Separation";
const isCustody =
index === currentMaritalQuestionIndex + 3 ||
enTitle === "Children and Guardianship Status";
const isChildrenCount =
index === currentMaritalQuestionIndex + 4 ||
enTitle === "Number of Children";
const isChildrenExplanation =
index === currentMaritalQuestionIndex + 5 ||
enTitle === "Short Children/Guardianship Explanation";
if (isDuration) {
return [1, 2, 3].includes(maritalSelectedIndex);
}
if (isSeparation) {
return [1, 2].includes(maritalSelectedIndex);
}
if (isCustody) {
return [2, 3].includes(maritalSelectedIndex);
}
if (isChildrenCount) {
return [2, 3].includes(maritalSelectedIndex) && hasChildrenSelected;
}
if (isChildrenExplanation) {
return (
[2, 3].includes(maritalSelectedIndex) && hasAnyGuardianshipSelected
);
}
}
// Check if this is one of the three job-related questions
if (employmentQuestionIndex !== -1) {
const isJobTitle =
index === employmentQuestionIndex + 1 || enTitle === "Job Title";
const isWorkLocation =
index === employmentQuestionIndex + 2 || enTitle === "Work Location";
const isMonthlyIncome =
index === employmentQuestionIndex + 3 || enTitle === "Monthly Income";
if (isJobTitle || isWorkLocation || isMonthlyIncome) {
// If no employment status is selected yet, hide them by default
if (employmentSelectedOptionIndex === -1) {
return false;
}
// Options logic:
// Show all 3 for: index 0 (Full-time), 1 (Part-time), 2 (Self-employed), 3 (Entrepreneur), 5 (Working Student)
const showAll = [0, 1, 2, 3, 5].includes(
employmentSelectedOptionIndex,
);
// Hide all 3 for: index 4 (Student), 6 (Student & Job Seeking), 7 (Job Seeking / Unemployed), 8 (Homemaker)
const hideAll = [4, 6, 7, 8].includes(employmentSelectedOptionIndex);
// Special Retired logic: index 9 (Retired)
const isRetired = employmentSelectedOptionIndex === 9;
if (showAll) {
return true;
}
if (hideAll) {
return false;
}
if (isRetired) {
if (isJobTitle || isMonthlyIncome) {
return true;
}
if (isWorkLocation) {
return false;
}
}
return false;
}
}
// Check Parents' Survival Status to decide if Parents' Marital Status is visible
if (survivalStatusQuestionIndex !== -1) {
const isParentsMaritalStatus = enTitle === "Parents' Marital Status";
if (isParentsMaritalStatus) {
return survivalSelectedOptionIndex === 0;
}
}
// Default dependsOn logic
if (question.logic?.dependsOn) {
const { title, values } = question.logic.dependsOn;
const dependentQuestionIndex = visibleQuestions.findIndex(
(q) => q.title === title,
);
if (dependentQuestionIndex !== -1) {
const dependentQuestion = visibleQuestions[dependentQuestionIndex];
const answer = getAnswerValue(
dependentQuestion,
dependentQuestionIndex,
);
if (Array.isArray(answer)) {
return answer.some((ans) => values.includes(String(ans)));
}
return values.includes(String(answer));
}
return false;
}
return true;
});
return filtered.map((question) => {
const enTitle = question.englishTitle || question.title;
if (enTitle === "Short Family Description") {
return {
...question,
required: isCircumstancesSelected,
};
}
if (
enTitle === "Previous Marriage Duration" ||
enTitle === "Number of Children" ||
enTitle === "Short Children/Guardianship Explanation" ||
enTitle === "Additional details about family responsibility" ||
enTitle === "Do the supported individual(s) live with you?" ||
enTitle === "What is the custody status of your child(ren)?" ||
enTitle ===
"Does the custody, visitation, or relocation schedule impact your residence or immigration?" ||
enTitle === "What is the payment or receipt status of child support?" ||
enTitle ===
"Acceptance of necessary communication between future spouse and the other parent"
) {
return {
...question,
required: true,
};
}
return question;
});
}, [visibleQuestions, getAnswerValue]);
// dynamicQuestions is now exactly what the backend gives as visible
const dynamicQuestions = visibleQuestions;
const requiredCount = useMemo( const requiredCount = useMemo(
() => dynamicQuestions.filter((q) => q.required).length, () => dynamicQuestions.filter((q) => q.required).length,
@ -435,23 +97,12 @@ function QuestionFlowWrapper({
questions={dynamicQuestions} questions={dynamicQuestions}
> >
{dynamicQuestions.map((question, index) => { {dynamicQuestions.map((question, index) => {
let originalIndex = visibleQuestions.indexOf(question);
if (originalIndex === -1) {
// Spread-copied questions lose reference equality; fall back to title
originalIndex = visibleQuestions.findIndex(
(q) =>
(q.englishTitle || q.title) ===
(question.englishTitle || question.title),
);
}
const answer = getAnswerValue(question, originalIndex);
const answer = getAnswerValue(question);
const hasAnswer = hasQuestionAnswerValue(answer ?? null); const hasAnswer = hasQuestionAnswerValue(answer ?? null);
let isAnswered = hasAnswer; let isAnswered = hasAnswer;
if (hasAnswer) { if (hasAnswer) {
const isEmailQuestion = (question.englishTitle || question.title)
.toLowerCase()
.includes("email");
const isEmailQuestion = question.type === "email" || question.validation?.format === "email";
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());
@ -467,19 +118,17 @@ function QuestionFlowWrapper({
return ( return (
<div <div
key={`${itemSlug}-${question.title}`}
key={question.id}
data-question-required={String(question.required)} data-question-required={String(question.required)}
data-question-optional={String(!question.required)} data-question-optional={String(!question.required)}
data-question-index={index} data-question-index={index}
data-question-original-index={originalIndex}
data-question-original-index={question.order}
data-question-disabled="false" data-question-disabled="false"
data-question-answered={String(isAnswered)} data-question-answered={String(isAnswered)}
> >
<QuestionRenderer <QuestionRenderer
question={question} question={question}
questionIndex={originalIndex}
dobQuestion={dobQuestion} dobQuestion={dobQuestion}
dobQuestionIndex={dobQuestionIndex}
/> />
</div> </div>
); );
@ -525,11 +174,9 @@ export default function QuestionDetailClient({
} }
}, [itemSlug, isTestStarted]); }, [itemSlug, isTestStarted]);
const { data: profile, isLoading: isProfileLoading } =
useMarriageProfileQuery();
const profileGender = profile?.gender;
const age = getStoredAge();
const item = getQuestionListItemBySlug(itemSlug, locale);
const { data: schema, isLoading: isSchemaLoading } = useFormSchemaQuery("profile", locale);
const items = useMemo(() => convertSchemaToFrontendItems(schema, locale), [schema, locale]);
const item = items.find((i) => i.slug === itemSlug);
const isCattellSlug = itemSlug === "personality_test"; const isCattellSlug = itemSlug === "personality_test";
const isGlasserSlug = itemSlug === "glasser_5_needs_test"; const isGlasserSlug = itemSlug === "glasser_5_needs_test";
@ -546,58 +193,44 @@ export default function QuestionDetailClient({
}); });
const submitGlasserMutation = useSubmitGlasserAssessmentMutation(); const submitGlasserMutation = useSubmitGlasserAssessmentMutation();
const profileContext = useMemo(
() => ({
age,
gender: profileGender as MarriageGender | null | undefined,
}),
[age, profileGender],
);
const cattellTestQuestions: TestQuestion[] = useMemo(() => { const cattellTestQuestions: TestQuestion[] = useMemo(() => {
const questionsList =
cattellQuery.data?.questions && cattellQuery.data.questions.length > 0
? cattellQuery.data.questions
: cattellFallbackQuestions;
return questionsList.map((q) => {
const rawOptions =
q.options && q.options.length > 0
? q.options
: locale === "fa"
? ["ط¨ظ„ظ‡", "ط¨ظ‡ ط§ظ†ط¯ط§ط²ظ‡ ع©ط§ظپغŒ ظˆط§ط¶ط­ ظ†غŒط³طھ", "ظ†ظ‡"]
: ["Yes", "Not clear enough", "No"];
const mappedOptions = rawOptions.map((optText, idx) => ({
label: optText,
value: idx === 0 ? "A" : idx === 1 ? "B" : "C",
}));
return {
id: q.question_number,
text: q.text,
options: mappedOptions,
};
});
}, [cattellQuery.data, locale]);
const questionsList = cattellQuery.data?.questions || [];
// Strict schema validation for Cattell
const isValidCattell = (q: any) =>
q.question_number &&
q.text &&
q.options &&
q.options.length === 3 &&
q.options.every((o: any) => o.id && o.label && o.value);
if (questionsList.length > 0 && !questionsList.every(isValidCattell)) {
console.error("Invalid Cattell API response schema");
return [];
}
return questionsList.map((q) => ({
id: q.question_number,
text: q.text,
options: q.options || [],
}));
}, [cattellQuery.data]);
const glasserTestQuestions: TestQuestion[] = useMemo(() => { const glasserTestQuestions: TestQuestion[] = useMemo(() => {
const questionsList =
glasserQuery.data?.questions && glasserQuery.data.questions.length > 0
? glasserQuery.data.questions
: glasserFallbackQuestions;
const defaultGlasserOptions = [
{
label: locale === "fa" ? "خیلی کم" : "Very Low",
value: 1,
},
{ label: locale === "fa" ? "کم" : "Low", value: 2 },
{ label: locale === "fa" ? "متوسط" : "Moderate", value: 3 },
{ label: locale === "fa" ? "زیاد" : "High", value: 4 },
{
label: locale === "fa" ? "خیلی زیاد" : "Very High",
value: 5,
},
];
const questionsList = glasserQuery.data?.questions || [];
// Strict schema validation for Glasser
const isValidGlasser = (q: any) =>
q.question_number &&
q.text &&
q.options &&
q.options.length === 5 &&
q.options.every((o: any) => o.id && o.label && typeof o.value === 'number' && o.value >= 1 && o.value <= 5);
if (questionsList.length > 0 && !questionsList.every(isValidGlasser)) {
console.error("Invalid Glasser API response schema");
return [];
}
return questionsList.map((q) => ({ return questionsList.map((q) => ({
id: q.question_number, id: q.question_number,
@ -608,34 +241,22 @@ export default function QuestionDetailClient({
: "factor_code" in q : "factor_code" in q
? (q.factor_code as string) ? (q.factor_code as string)
: undefined, : undefined,
options: defaultGlasserOptions,
options: q.options || [],
})); }));
}, [glasserQuery.data, locale]);
}, [glasserQuery.data]);
const visibleQuestions = useMemo(() => { const visibleQuestions = useMemo(() => {
if (!item) { if (!item) {
return []; return [];
} }
const hasDobQuestion = item.questions.some(
(q) => q.title === "Date of Birth" || q.title === "طھط§ط±غŒط® طھظˆظ„ط¯",
);
return item.questions return item.questions
.filter((question) => {
if (
hasDobQuestion &&
(question.title === "Age" || question.title === "ط³ظ†")
) {
return false;
}
return isQuestionVisibleForProfile(question, profileContext);
})
.filter((question) => (question as any).isVisible !== false)
.map((question) => ({ .map((question) => ({
...question, ...question,
required: isQuestionRequiredForProfile(question, profileContext),
required: Boolean(question.required),
})); }));
}, [item, profileContext]);
}, [item]);
const requiredQuestionsCount = useMemo( const requiredQuestionsCount = useMemo(
() => visibleQuestions.filter((q) => q.required).length, () => visibleQuestions.filter((q) => q.required).length,
@ -643,28 +264,19 @@ export default function QuestionDetailClient({
); );
useEffect(() => { useEffect(() => {
if (isProfileLoading) {
return;
if (!isSchemaLoading && !item) {
router.replace(questionsListHref);
} }
}, [isSchemaLoading, item, questionsListHref, router]);
if (!item || isQuestionListItemVisibleForProfile(item, profileContext)) {
return;
}
router.replace(questionsListHref);
}, [isProfileLoading, item, profileContext, questionsListHref, router]);
if (!profile && isProfileLoading && item) {
if (isSchemaLoading) {
return ( return (
<PageLoadingSkeleton <PageLoadingSkeleton
compact compact
variant={isCattellSlug || isGlasserSlug ? "test" : "questions"} variant={isCattellSlug || isGlasserSlug ? "test" : "questions"}
/> />
); );
} else if (
!item ||
!isQuestionListItemVisibleForProfile(item, profileContext)
) {
} else if (!item) {
return null; return null;
} }
@ -961,10 +573,7 @@ export default function QuestionDetailClient({
} }
const dobQuestion = visibleQuestions.find( const dobQuestion = visibleQuestions.find(
(question) => question.title === "Date of Birth",
);
const dobQuestionIndex = visibleQuestions.findIndex(
(question) => question.title === "Date of Birth",
(question) => question.ui_config?.isDob === true || question.type === "date",
); );
return ( return (
@ -1001,7 +610,6 @@ export default function QuestionDetailClient({
visibleQuestions={visibleQuestions} visibleQuestions={visibleQuestions}
itemSlug={item.slug} itemSlug={item.slug}
dobQuestion={dobQuestion} dobQuestion={dobQuestion}
dobQuestionIndex={dobQuestionIndex}
requiredQuestionsCount={requiredQuestionsCount} requiredQuestionsCount={requiredQuestionsCount}
continueLabel={continueLabel} continueLabel={continueLabel}
questionsListHref={questionsListHref} questionsListHref={questionsListHref}

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

@ -5,29 +5,23 @@ import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { IoClose } from "react-icons/io5"; import { IoClose } from "react-icons/io5";
import { getSubmitPath } from "@/lib/get-submit-path"; import { getSubmitPath } from "@/lib/get-submit-path";
import {
getLocalSectionProgress,
getStoredAge,
} from "@/components/Componentes/progress-helper";
import QuestionCard from "@/components/Componentes/question-card"; import QuestionCard from "@/components/Componentes/question-card";
import RequiredStepsCard from "@/components/Componentes/required-steps-card"; import RequiredStepsCard from "@/components/Componentes/required-steps-card";
import Button from "@/components/Componentes/button"; import Button from "@/components/Componentes/button";
import InformationSheet from "@/components/Componentes/information-sheet"; import InformationSheet from "@/components/Componentes/information-sheet";
import NavigationButton from "@/components/Componentes/navigation-button"; import NavigationButton from "@/components/Componentes/navigation-button";
import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton"; import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton";
import { PageBackground } from "@/components/Componentes/page-background"; import { PageBackground } from "@/components/Componentes/page-background";
import ErrorToast from "@/components/Componentes/error-toast"; import ErrorToast from "@/components/Componentes/error-toast";
import {
getQuestionListItems,
isQuestionListItemVisibleForProfile,
type QuestionListItem,
} from "@/data/question-data";
import { toFrontendSlug } from "@/data/section-slug-map";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start"; import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start";
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 { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import type { QuestionListItem } from "@/lib/schema-adapter";
import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back"; import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back";
import { import {
clearMatchStartGrace, clearMatchStartGrace,
@ -47,6 +41,8 @@ export default function QuestionsListPage() {
useMarriageProfileQuery(); useMarriageProfileQuery();
const { data: sections, isLoading: isSectionsLoading } = const { data: sections, isLoading: isSectionsLoading } =
useMarriageSectionsQuery(); useMarriageSectionsQuery();
const { data: schema, isLoading: isSchemaLoading } =
useFormSchemaQuery("profile", locale);
useEffect(() => { useEffect(() => {
triggerSilentReload(queryClient); triggerSilentReload(queryClient);
@ -68,59 +64,28 @@ export default function QuestionsListPage() {
const [selectedSection, setSelectedSection] = const [selectedSection, setSelectedSection] =
useState<QuestionListItem | null>(null); useState<QuestionListItem | null>(null);
const questionListItems = useMemo( const questionListItems = useMemo(
() =>
getQuestionListItems(locale).filter((item) =>
isQuestionListItemVisibleForProfile(item, {
gender: profile?.gender,
}),
),
[locale, profile?.gender],
() => convertSchemaToFrontendItems(schema, locale),
[schema, locale],
); );
const sectionProgressBySlug = useMemo(() => { const sectionProgressBySlug = useMemo(() => {
const progressBySlug = new Map<string, number>(); const progressBySlug = new Map<string, number>();
const age = getStoredAge();
sections?.forEach((section) => {
const frontendSlug = toFrontendSlug(section.slug);
const progress = Math.max(
0,
Math.min(100, Math.round(section.completion_percent)),
);
progressBySlug.set(frontendSlug, progress);
progressBySlug.set(section.slug, progress);
});
const fbSec = sections?.find((s) => s.slug === "family_background");
const mhSec = sections?.find((s) => s.slug === "marital_history");
if (fbSec || mhSec) {
const fbTotal = fbSec?.total_steps ?? 6;
const fbCurrent = fbSec
? Math.round((fbSec.completion_percent / 100) * fbTotal)
: 0;
const mhTotal = mhSec?.total_steps ?? 6;
const mhCurrent = mhSec
? Math.round((mhSec.completion_percent / 100) * mhTotal)
: 0;
const combinedProgress =
fbTotal + mhTotal > 0
? ((fbCurrent + mhCurrent) / (fbTotal + mhTotal)) * 100
: 0;
progressBySlug.set(
"family_marital_history",
Math.max(0, Math.min(100, Math.round(combinedProgress))),
);
if (schema?.progress?.sections_progress) {
Object.entries(schema.progress.sections_progress).forEach(([slug, prog]) => {
progressBySlug.set(slug, Math.max(0, Math.min(100, Math.round(prog.completion_percent))));
});
} }
// Add fallback for combined section from the schema adapter which attaches it to questionListItems directly
questionListItems.forEach((item) => { questionListItems.forEach((item) => {
const localProgress = getLocalSectionProgress(item, profile, age);
if (localProgress !== null) {
progressBySlug.set(item.slug, localProgress);
if (!progressBySlug.has(item.slug)) {
progressBySlug.set(item.slug, item.progress);
} }
}); });
return progressBySlug; return progressBySlug;
}, [sections, questionListItems, profile]);
}, [schema, questionListItems]);
const requiredQuestionListItems = useMemo( const requiredQuestionListItems = useMemo(
() => questionListItems.filter((item) => Boolean(item.required)), () => questionListItems.filter((item) => Boolean(item.required)),
@ -189,21 +154,11 @@ export default function QuestionsListPage() {
setIsSyncError(false); setIsSyncError(false);
try { try {
const slugsToCheck = [
"personal_info",
"contact_residence_family_communication",
"appearance_health_activity",
"education_career_economic_status",
"family_marital_history",
"beliefs_lifestyle_boundaries",
"future_spouse_criteria",
"identity_verification",
];
const slugsToCheck = questionListItems.map(item => item.slug);
for (const slug of slugsToCheck) { for (const slug of slugsToCheck) {
const rawValue = window.localStorage.getItem(
`marriage:sections:${slug}:answers`,
);
const storageKey = getQuestionAnswersStorageKey(slug);
const rawValue = window.localStorage.getItem(storageKey);
if (rawValue) { if (rawValue) {
const storedValue = JSON.parse(rawValue); const storedValue = JSON.parse(rawValue);
if (storedValue.pending_sync && storedValue.fields) { if (storedValue.pending_sync && storedValue.fields) {
@ -218,7 +173,7 @@ export default function QuestionsListPage() {
storedValue.pending_sync = false; storedValue.pending_sync = false;
window.localStorage.setItem( window.localStorage.setItem(
`marriage:sections:${slug}:answers`,
storageKey,
JSON.stringify(storedValue), JSON.stringify(storedValue),
); );
} }
@ -235,7 +190,7 @@ export default function QuestionsListPage() {
} }
}; };
if ((!profile || !sections) && (isProfileLoading || isSectionsLoading)) {
if (isProfileLoading || isSectionsLoading || isSchemaLoading) {
return ( return (
<> <>
<PageBackground disabled /> <PageBackground disabled />

6
src/app/questions-list/sections-request.tsx

@ -4,9 +4,13 @@ import { useEffect, useMemo, useState } from "react";
import { IoClose } from "react-icons/io5"; import { IoClose } from "react-icons/io5";
import Button from "@/components/Componentes/button"; import Button from "@/components/Componentes/button";
import InformationSheet from "@/components/Componentes/information-sheet"; import InformationSheet from "@/components/Componentes/information-sheet";
import { bookingTerms } from "@/data/question-data";
import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections"; import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
const bookingTerms = [
"All provided information is held in strict confidence.",
"Data is utilized exclusively to ensure optimal matchmaking accuracy.",
];
const FIRST_ENTRY_TERMS_SEEN_KEY = "marriage:first-entry-terms-seen"; const FIRST_ENTRY_TERMS_SEEN_KEY = "marriage:first-entry-terms-seen";
const FIRST_ENTRY_TERMS = [ const FIRST_ENTRY_TERMS = [

100
src/app/terms/page.test.tsx

@ -0,0 +1,100 @@
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import TermsRoute from './page';
const mockUseMarriageProfileQuery = vi.fn();
const mockReplace = vi.fn();
vi.mock('@/hooks/marriage/use-profile-main', () => ({
useMarriageProfileQuery: () => mockUseMarriageProfileQuery(),
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
}));
vi.mock('@/translations/provider', () => ({
useI18n: () => ({
locale: 'en',
dictionary: {
"Failed to load profile. Please try again.": "Failed to load profile. Please try again.",
"Retry": "Retry",
},
}),
}));
vi.mock('@/translations/config', () => ({
localizePath: (path: string) => path,
}));
vi.mock('@/components/Componentes/slider-page', () => ({
default: () => <div data-testid="slider-page">SliderPage</div>,
}));
describe('TermsRoute Guard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
it('renders loading state when isLoading is true', () => {
mockUseMarriageProfileQuery.mockReturnValue({ isLoading: true });
render(<TermsRoute />);
expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument();
// The spinner should be visible, maybe just check for a div. We can check slider is absent.
});
it('renders Error/Retry when isError is true', () => {
const mockRefetch = vi.fn();
mockUseMarriageProfileQuery.mockReturnValue({ isError: true, refetch: mockRefetch });
render(<TermsRoute />);
expect(screen.getByText('Failed to load profile. Please try again.')).toBeInTheDocument();
const retryBtn = screen.getByText('Retry');
fireEvent.click(retryBtn);
expect(mockRefetch).toHaveBeenCalled();
expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument();
});
it('pending_onboarding => SliderPage renders', () => {
mockUseMarriageProfileQuery.mockReturnValue({
isLoading: false,
data: { status: 'pending_onboarding' }
});
render(<TermsRoute />);
expect(screen.getByTestId('slider-page')).toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
it('pending_info => redirects to Questions and SliderPage is NOT rendered', async () => {
mockUseMarriageProfileQuery.mockReturnValue({
isLoading: false,
data: { status: 'pending_info' }
});
render(<TermsRoute />);
expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument();
await waitFor(() => {
expect(mockReplace).toHaveBeenCalled(); // Should redirect to /questions-list or /questions-list/personal_info
});
});
it('waiting => redirects to Finding/Waiting Page and SliderPage is NOT rendered', async () => {
mockUseMarriageProfileQuery.mockReturnValue({
isLoading: false,
data: { status: 'waiting' }
});
render(<TermsRoute />);
expect(screen.queryByTestId('slider-page')).not.toBeInTheDocument();
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/finding-match');
});
});
});

59
src/app/terms/page.tsx

@ -1,5 +1,64 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { getSubmitPath } from "@/lib/get-submit-path";
import SliderPage from "@/components/Componentes/slider-page"; import SliderPage from "@/components/Componentes/slider-page";
import Button from "@/components/Componentes/button";
import { useI18n } from "@/translations/provider";
import { localizePath } from "@/translations/config";
export default function TermsRoute() { export default function TermsRoute() {
const { data: profile, isLoading, isError, refetch } = useMarriageProfileQuery();
const router = useRouter();
const { locale, dictionary: t } = useI18n();
useEffect(() => {
if (!isLoading && profile) {
if (profile.status !== "pending_onboarding") {
const target = getSubmitPath(profile);
if (target !== "/terms") {
router.replace(localizePath(target, locale));
}
}
}
}, [profile, isLoading, router, locale]);
if (isLoading) {
return (
<div className="flex h-[100dvh] items-center justify-center bg-[#F5F5F5]">
<div className="size-8 animate-spin rounded-full border-4 border-[#F14B46] border-t-transparent" />
</div>
);
}
if (isError) {
return (
<div className="flex h-[100dvh] flex-col items-center justify-center p-4 bg-[#F5F5F5]">
<p className="mb-4 text-center font-medium text-[#F14B46]">
{/* @ts-expect-error - missing key */}
{t["Failed to load profile. Please try again."] || "Failed to load profile. Please try again."}
</p>
<div className="w-full max-w-[200px]">
<Button onClick={() => refetch()}>
{/* @ts-expect-error - missing key */}
{t["Retry"] || "Retry"}
</Button>
</div>
</div>
);
}
if (profile && profile.status !== "pending_onboarding") {
// If target is /terms but status is not pending_onboarding, we should still not render SliderPage
// to prevent showing Onboarding to users who already finished it but have an unknown status.
return (
<div className="flex h-[100dvh] items-center justify-center bg-[#F5F5F5]">
<div className="size-8 animate-spin rounded-full border-4 border-[#F14B46] border-t-transparent" />
</div>
);
}
return <SliderPage />; return <SliderPage />;
} }

72
src/components/Componentes/conditional-questions.tsx

@ -1,72 +0,0 @@
"use client";
import { useQuestionAnswers } from "./question-answer-storage";
import type { QuestionField } from "@/data/question-data";
import QuestionRenderer from "./question-renderer";
type ConditionalQuestionsProps = {
parentQuestion: QuestionField;
parentQuestionIndex: number;
rules: {
values: string[];
subQuestions: QuestionField[];
}[];
dobQuestion?: QuestionField;
dobQuestionIndex?: number;
};
export function ConditionalQuestions({
parentQuestion,
parentQuestionIndex,
rules,
dobQuestion,
dobQuestionIndex,
}: ConditionalQuestionsProps) {
const { getAnswerValue } = useQuestionAnswers();
const parentAnswer = getAnswerValue(parentQuestion, parentQuestionIndex);
// Find if there is a matching rule for the current parent answer
const matchedRule = rules.find((rule) => {
return rule.values.includes(String(parentAnswer));
});
if (!matchedRule) {
return (
<QuestionRenderer
question={parentQuestion}
questionIndex={parentQuestionIndex}
dobQuestion={dobQuestion}
dobQuestionIndex={dobQuestionIndex}
/>
);
}
return (
<div className="flex flex-col gap-5 w-full">
<QuestionRenderer
question={parentQuestion}
questionIndex={parentQuestionIndex}
dobQuestion={dobQuestion}
dobQuestionIndex={dobQuestionIndex}
/>
<div className="flex flex-col gap-5 pl-4 border-l-2 border-[#F2465F]/20 mt-2 animate-in fade-in slide-in-from-top-4 duration-300">
{matchedRule.subQuestions.map((subQuestion, index) => {
// Sub-question index is calculated uniquely to prevent conflicts
const subIndex = parentQuestionIndex * 100 + index + 1;
return (
<div key={subQuestion.title} className="w-full">
<QuestionRenderer
question={subQuestion}
questionIndex={subIndex}
dobQuestion={dobQuestion}
dobQuestionIndex={dobQuestionIndex}
/>
</div>
);
})}
</div>
</div>
);
}
export default ConditionalQuestions;

10
src/components/Componentes/fix-to-the-end.tsx

@ -4,13 +4,19 @@ import type { ReactNode } from "react";
type FixToTheEndProps = { type FixToTheEndProps = {
children: ReactNode; children: ReactNode;
className?: string;
}; };
export function FixToTheEnd({ children }: FixToTheEndProps) {
export function FixToTheEnd({ children, className }: FixToTheEndProps) {
return ( return (
<div <div
style={{ paddingBottom: "calc(16px + var(--safe-bottom))" }} style={{ paddingBottom: "calc(16px + var(--safe-bottom))" }}
className="fixed inset-x-0 bottom-0 z-20 mx-auto w-full max-w-[375px] bg-[#F5F5F5]/95 px-[17px] pt-3 backdrop-blur-md"
className={[
"fixed inset-x-0 bottom-0 z-20 mx-auto w-full sm:max-w-[375px] bg-[#F5F5F5]/95 px-[17px] pt-3 backdrop-blur-md",
className,
]
.filter(Boolean)
.join(" ")}
> >
{children} {children}
</div> </div>

524
src/components/Componentes/progress-helper.ts

@ -1,524 +0,0 @@
import {
isQuestionRequiredForProfile,
isQuestionVisibleForProfile,
type QuestionListItem,
} from "@/data/question-data";
import type {
MarriageFieldValue,
MarriageGender,
} from "@/hooks/marriage/types";
import { hasQuestionAnswerValue } from "./question-answer-storage";
export function getStoredAge(): number | null {
try {
if (typeof window === "undefined") return null;
const rawValue = window.localStorage.getItem(
"marriage:sections:personal_info:answers",
);
if (!rawValue) return null;
const storedAnswers = JSON.parse(rawValue);
const ageField = storedAnswers.fields?.find(
(f: Record<string, unknown>) =>
f.type === "number" ||
f.label === "Age" ||
f.label === "سن" ||
(typeof f.key === "string" &&
(f.key.endsWith("_age") || f.key.endsWith("_sn"))),
);
if (ageField && ageField.value !== undefined && ageField.value !== null) {
const num = Number(ageField.value);
if (Number.isFinite(num)) return num;
}
const dobField = storedAnswers.fields?.find(
(f: Record<string, unknown>) =>
f.type === "date" ||
f.label === "Date of Birth" ||
f.label === "تاریخ تولد" ||
(typeof f.key === "string" &&
(f.key.endsWith("_date_of_birth") || f.key.endsWith("_tarykh_twld"))),
);
if (dobField?.value) {
const dob = new Date(String(dobField.value));
if (!Number.isNaN(dob.getTime())) {
const today = new Date();
let age = today.getFullYear() - dob.getFullYear();
const m = today.getMonth() - dob.getMonth();
if (m < 0 || (m === 0 && today.getDate() < dob.getDate())) {
age--;
}
return age >= 0 ? age : null;
}
}
} catch (_e) {}
return null;
}
function isFieldAnswered(
q: Record<string, unknown>,
field: Record<string, unknown> | undefined,
): boolean {
if (!field || !hasQuestionAnswerValue(field.value as MarriageFieldValue)) {
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;
}
function hashString(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash.toString(36);
}
function slugifyTitle(title: string) {
const slug = title
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
return slug || `field_${hashString(title)}`;
}
export function getLocalSectionProgress(
item: QuestionListItem,
profile: { gender?: MarriageGender | null } | null | undefined,
age: number | null,
): number | null {
try {
if (typeof window === "undefined") return null;
if (item.slug === "personality_test" || item.slug === "glasser_5_needs_test") {
const draftKey = `marriage:tests:${item.slug}:draft`;
const draftRaw = window.localStorage.getItem(draftKey);
if (draftRaw) {
try {
const parsed = JSON.parse(draftRaw);
if (parsed && typeof parsed.answers === "object" && parsed.answers !== null) {
const answeredCount = Object.keys(parsed.answers).length;
const totalCount = item.slug === "personality_test" ? 187 : 35;
return Math.max(
0,
Math.min(
100,
Math.round((answeredCount / totalCount) * 100),
),
);
}
} catch {}
}
return null;
}
const fields: Record<string, unknown>[] = [];
let hasFoundStorage = false;
const mainKey = `marriage:sections:${item.slug}:answers`;
const mainRaw = window.localStorage.getItem(mainKey);
if (mainRaw) {
try {
const parsed = JSON.parse(mainRaw);
if (
parsed &&
Array.isArray(parsed.fields) &&
parsed.fields.length > 0
) {
fields.push(...parsed.fields);
hasFoundStorage = true;
}
} catch {}
}
if (item.slug === "family_marital_history") {
const fbRaw = window.localStorage.getItem(
"marriage:sections:family_background:answers",
);
if (fbRaw) {
try {
const parsed = JSON.parse(fbRaw);
if (parsed && Array.isArray(parsed.fields)) {
fields.push(...parsed.fields);
hasFoundStorage = true;
}
} catch {}
}
const mhRaw = window.localStorage.getItem(
"marriage:sections:marital_history_children:answers",
);
if (mhRaw) {
try {
const parsed = JSON.parse(mhRaw);
if (parsed && Array.isArray(parsed.fields)) {
fields.push(...parsed.fields);
hasFoundStorage = true;
}
} catch {}
}
}
if (!hasFoundStorage || fields.length === 0) {
return null;
}
const profileContext = {
age,
gender: profile?.gender,
};
const hasDobQuestion = item.questions.some(
(q) => q.title === "Date of Birth" || q.title === "تاریخ تولد",
);
const profileVisible = item.questions
.filter((question) => {
if (
hasDobQuestion &&
(question.title === "Age" || question.title === "سن")
) {
return false;
}
return isQuestionVisibleForProfile(question, profileContext);
})
.map((question) => ({
...question,
required: isQuestionRequiredForProfile(question, profileContext),
}));
const getQuestionFieldKey = (question: any, questionIndex: number) => {
const index =
question.originalIndex !== undefined
? question.originalIndex
: questionIndex;
return `q${index + 1}_${slugifyTitle(question.englishTitle || question.title)}`;
};
const findAnswer = (question: any, questionIndex: number) => {
const key = getQuestionFieldKey(question, questionIndex);
const engSlug = slugifyTitle(question.englishTitle || question.title);
const field = fields.find(
(f) =>
f &&
(f.key === key ||
f.label === question.title ||
f.label === question.englishTitle ||
(typeof f.key === "string" &&
(f.key.endsWith(`.${engSlug}`) || f.key.endsWith(`_${engSlug}`)))),
);
return field?.value;
};
// Find employment status question by title or by choices length (10)
const employmentQuestionIndex = profileVisible.findIndex((q) => {
return (
q.title === "Employment Status" ||
q.title === "وضعیت اشتغال" ||
(q.type === "dropdown" && q.extras?.options?.length === 10)
);
});
let employmentSelectedOptionIndex = -1;
if (employmentQuestionIndex !== -1) {
const employmentQuestion = profileVisible[employmentQuestionIndex];
const ans = findAnswer(employmentQuestion, employmentQuestionIndex);
if (ans) {
employmentSelectedOptionIndex =
employmentQuestion.extras?.options?.indexOf(String(ans)) ?? -1;
}
}
// Find Parents' Survival Status question index and check selected index
const survivalStatusQuestionIndex = profileVisible.findIndex((q) => {
return (
q.title === "Parents' Survival Status" ||
q.title === "وضعیت حیات والدین"
);
});
let survivalSelectedOptionIndex = -1;
if (survivalStatusQuestionIndex !== -1) {
const survivalQuestion = profileVisible[survivalStatusQuestionIndex];
const ans = findAnswer(survivalQuestion, survivalStatusQuestionIndex);
if (ans) {
survivalSelectedOptionIndex =
survivalQuestion.extras?.options?.indexOf(String(ans)) ?? -1;
}
}
// Find Parents' Marital Status question index and check selected index
const maritalStatusQuestionIndex = profileVisible.findIndex((q) => {
return (
q.title === "Parents' Marital Status" || q.title === "وضعیت تأهل والدین"
);
});
let isCircumstancesSelected = false;
if (maritalStatusQuestionIndex !== -1) {
const maritalQuestion = profileVisible[maritalStatusQuestionIndex];
const ans = findAnswer(maritalQuestion, maritalStatusQuestionIndex);
if (ans) {
const selectedIdx =
maritalQuestion.extras?.options?.indexOf(String(ans)) ?? -1;
isCircumstancesSelected = selectedIdx === 2;
}
}
// Find Current Marital Status question index and check selected index
const currentMaritalQuestionIndex = profileVisible.findIndex((q) => {
return (
q.title === "Current Marital Status" || q.title === "وضعیت تأهل فعلی"
);
});
let maritalSelectedIndex = -1;
if (currentMaritalQuestionIndex !== -1) {
const maritalQuestion = profileVisible[currentMaritalQuestionIndex];
const ans = findAnswer(maritalQuestion, currentMaritalQuestionIndex);
if (ans) {
maritalSelectedIndex =
maritalQuestion.extras?.options?.indexOf(String(ans)) ?? -1;
}
}
// Find Children and Guardianship Status question index
const custodyQuestionIndex = profileVisible.findIndex((q) => {
return (
q.title === "Children and Guardianship Status" ||
q.title === "وضعیت فرزند و تکفل"
);
});
let hasChildrenSelected = false;
let hasAnyGuardianshipSelected = false;
if (custodyQuestionIndex !== -1) {
const custodyQuestion = profileVisible[custodyQuestionIndex];
const ans = findAnswer(custodyQuestion, custodyQuestionIndex);
if (ans) {
const ansList = Array.isArray(ans) ? ans.map(String) : [String(ans)];
hasChildrenSelected = ansList.some(
(val) => val.includes("Have children") || val.includes("فرزند دارم"),
);
hasAnyGuardianshipSelected = ansList.some(
(val) =>
val.includes("Have children") ||
val.includes("فرزند دارم") ||
val.includes("under my guardianship") ||
val.includes("تحت تکفل"),
);
}
}
const filtered = profileVisible.filter((question, index) => {
// Check if this is one of the marital status/children questions
if (currentMaritalQuestionIndex !== -1) {
const isDuration =
index === currentMaritalQuestionIndex + 1 ||
question.title === "Previous Marriage Duration" ||
question.title === "مدت ازدواج یا عقد قبلی";
const isSeparation =
index === currentMaritalQuestionIndex + 2 ||
question.title === "Reason for Separation" ||
question.title === "علت جدایی، در صورت وجود";
const isCustody =
index === currentMaritalQuestionIndex + 3 ||
question.title === "Children and Guardianship Status" ||
question.title === "وضعیت فرزند و تکفل";
const isChildrenCount =
index === currentMaritalQuestionIndex + 4 ||
question.title === "Number of Children" ||
question.title === "تعداد فرزندان";
const isChildrenExplanation =
index === currentMaritalQuestionIndex + 5 ||
question.title === "Short Children/Guardianship Explanation" ||
question.title === "توضیح کوتاه درباره شرایط فرزند یا تکفل";
if (isDuration) {
return [1, 2, 3].includes(maritalSelectedIndex);
}
if (isSeparation) {
return [1, 2].includes(maritalSelectedIndex);
}
if (isCustody) {
return [2, 3].includes(maritalSelectedIndex);
}
if (isChildrenCount) {
return [2, 3].includes(maritalSelectedIndex) && hasChildrenSelected;
}
if (isChildrenExplanation) {
return (
[2, 3].includes(maritalSelectedIndex) && hasAnyGuardianshipSelected
);
}
}
// Check if this is one of the three job-related questions
if (employmentQuestionIndex !== -1) {
const isJobTitle =
index === employmentQuestionIndex + 1 ||
question.title === "Job Title" ||
question.title === "عنوان شغلی";
const isWorkLocation =
index === employmentQuestionIndex + 2 ||
question.title === "Work Location" ||
question.title === "محل فعالیت";
const isMonthlyIncome =
index === employmentQuestionIndex + 3 ||
question.title === "Monthly Income" ||
question.title === "میزان درآمد ماهانه";
if (isJobTitle || isWorkLocation || isMonthlyIncome) {
// If no employment status is selected yet, hide them by default
if (employmentSelectedOptionIndex === -1) {
return false;
}
// Options logic:
// Show all 3 for: index 0 (Full-time), 1 (Part-time), 2 (Self-employed), 3 (Entrepreneur), 5 (Working Student)
const showAll = [0, 1, 2, 3, 5].includes(
employmentSelectedOptionIndex,
);
// Hide all 3 for: index 4 (Student), 6 (Student & Job Seeking), 7 (Job Seeking / Unemployed), 8 (Homemaker)
const hideAll = [4, 6, 7, 8].includes(employmentSelectedOptionIndex);
// Special Retired logic: index 9 (Retired)
const isRetired = employmentSelectedOptionIndex === 9;
if (showAll) {
return true;
}
if (hideAll) {
return false;
}
if (isRetired) {
if (isJobTitle || isMonthlyIncome) {
return true;
}
if (isWorkLocation) {
return false;
}
}
return false;
}
}
// Check Parents' Survival Status to decide if Parents' Marital Status is visible
if (survivalStatusQuestionIndex !== -1) {
const isParentsMaritalStatus =
question.title === "Parents' Marital Status" ||
question.title === "وضعیت تأهل والدین";
if (isParentsMaritalStatus) {
return survivalSelectedOptionIndex === 0;
}
}
// Default dependsOn logic
if (question.logic?.dependsOn) {
const { title, values } = question.logic.dependsOn;
const dependentQuestionIndex = profileVisible.findIndex(
(q) => q.title === title,
);
if (dependentQuestionIndex !== -1) {
const dependentQuestion = profileVisible[dependentQuestionIndex];
const answer = findAnswer(dependentQuestion, dependentQuestionIndex);
if (Array.isArray(answer)) {
return answer.some((ans) => values.includes(String(ans)));
}
return values.includes(String(answer));
}
return false;
}
return true;
});
const activeQuestions = filtered.map((question) => {
if (
question.title === "Short Family Description" ||
question.title === "توضیح کوتاه درباره خانواده"
) {
return {
...question,
required: isCircumstancesSelected,
};
}
if (
question.title === "Previous Marriage Duration" ||
question.title === "مدت ازدواج یا عقد قبلی" ||
question.title === "Number of Children" ||
question.title === "تعداد فرزندان" ||
question.title === "Short Children/Guardianship Explanation" ||
question.title === "توضیح کوتاه درباره شرایط فرزند یا تکفل" ||
question.title === "Additional details about family responsibility" ||
question.title === "توضیحات تکمیلی درباره مسئولیت خانوادگی" ||
question.title === "Do the supported individual(s) live with you?" ||
question.title === "آیا فرد یا افراد تحت حمایت با شما زندگی میکنند؟" ||
question.title === "What is the custody status of your child(ren)?" ||
question.title === "وضعیت حضانت فرزند یا فرزندان شما چگونه است؟" ||
question.title ===
"Does the custody, visitation, or relocation schedule impact your residence or immigration?" ||
question.title ===
"آیا برنامه حضانت، ملاقات یا جابهجایی فرزند بر محل زندگی یا امکان مهاجرت شما تأثیر میگذارد؟" ||
question.title ===
"What is the payment or receipt status of child support?" ||
question.title ===
"وضعیت پرداخت یا دریافت نفقه و حمایت مالی فرزند چگونه است؟" ||
question.title ===
"Acceptance of necessary communication between future spouse and the other parent" ||
question.title === "پذیرش ارتباط ضروری همسر آینده با والد دیگرِ فرزند" ||
question.title === "پذیرش ارتباط ضروری همسر آینده با والد دیگر فرزند"
) {
return {
...question,
required: true,
};
}
return question;
});
const requiredQuestions = activeQuestions.filter((q) => q.required);
if (requiredQuestions.length === 0) {
return 100;
}
const answeredCount = requiredQuestions.filter((q) => {
const idx = profileVisible.indexOf(q);
const key = getQuestionFieldKey(q, idx);
const engSlug = slugifyTitle(q.englishTitle || q.title);
const field = fields.find(
(f) =>
f &&
(f.key === key ||
f.label === q.title ||
f.label === q.englishTitle ||
(typeof f.key === "string" &&
(f.key.endsWith(`.${engSlug}`) || f.key.endsWith(`_${engSlug}`)))),
);
return isFieldAnswered(q, field);
}).length;
return Math.max(
0,
Math.min(
100,
Math.round((answeredCount / requiredQuestions.length) * 100),
),
);
} catch (_e) {
return null;
}
}

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

@ -11,8 +11,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import type { QuestionField } from "@/data/question-data";
import { toBackendSlug } from "@/data/section-slug-map";
import type { QuestionField } from "@/lib/schema-adapter";
import { pathParam } from "@/hooks/marriage/path-param"; import { pathParam } from "@/hooks/marriage/path-param";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import type { import type {
@ -28,7 +27,7 @@ import {
} from "@/hooks/marriage/use-section-data"; } from "@/hooks/marriage/use-section-data";
import { getApiRequestUrl } from "@/lib/http"; import { getApiRequestUrl } from "@/lib/http";
const STORAGE_VERSION = 1;
const STORAGE_VERSION = 2;
type QuestionAnswersByKey = Record<string, MarriageField>; type QuestionAnswersByKey = Record<string, MarriageField>;
@ -49,14 +48,12 @@ type QuestionAnswersContextValue = {
flushAnswers: (options?: FlushAnswersOptions) => Promise<void>; flushAnswers: (options?: FlushAnswersOptions) => Promise<void>;
getAnswerValue: ( getAnswerValue: (
question: QuestionField, question: QuestionField,
questionIndex: number,
) => MarriageFieldValue | undefined; ) => MarriageFieldValue | undefined;
hasPendingSync: boolean; hasPendingSync: boolean;
isSaving: boolean; isSaving: boolean;
isLoading: boolean; isLoading: boolean;
setAnswerValue: ( setAnswerValue: (
question: QuestionField, question: QuestionField,
questionIndex: number,
value: MarriageFieldValue, value: MarriageFieldValue,
) => void; ) => void;
backendFields: MarriageField[]; backendFields: MarriageField[];
@ -71,37 +68,8 @@ type QuestionAnswersProviderProps = {
const QuestionAnswersContext = const QuestionAnswersContext =
createContext<QuestionAnswersContextValue | null>(null); createContext<QuestionAnswersContextValue | null>(null);
function hashString(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash.toString(36);
}
function slugifyQuestionTitle(title: string) {
const slug = title
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
return slug || `field_${hashString(title)}`;
}
function getQuestionFieldKey(question: QuestionField, questionIndex: number) {
const index =
question.originalIndex !== undefined
? question.originalIndex
: questionIndex;
return `q${index + 1}_${slugifyQuestionTitle(question.englishTitle || question.title)}`;
}
export function getQuestionAnswersStorageKey(slug: string) { export function getQuestionAnswersStorageKey(slug: string) {
return `marriage:sections:${slug}:answers`;
return `marriage:sections:${slug}:answers:v${STORAGE_VERSION}`;
} }
export function hasQuestionAnswerValue(value: MarriageFieldValue) { export function hasQuestionAnswerValue(value: MarriageFieldValue) {
@ -151,66 +119,35 @@ function isMarriagePhoneFieldValue(
); );
} }
function findQuestionFieldKey(
question: QuestionField,
questionIndex: number,
answers?: QuestionAnswersByKey,
backendFields?: MarriageField[],
): string {
const legacyKey = getQuestionFieldKey(question, questionIndex);
if (backendFields && backendFields.length > 0) {
const engSlug = slugifyQuestionTitle(question.englishTitle || question.title);
// 1. Try to match by slug
const matchBySlug = backendFields.find((f) =>
typeof f.key === "string" && (f.key.endsWith(`.${engSlug}`) || f.key.endsWith(`_${engSlug}`))
);
if (matchBySlug) return matchBySlug.key;
// 2. Try to match by label
const matchByLabel = backendFields.find((f) =>
f.label === question.title || f.label === question.englishTitle
);
if (matchByLabel) return matchByLabel.key;
// 3. Fallback to index
if (backendFields[questionIndex]) {
return backendFields[questionIndex].key;
}
}
if (!answers) return legacyKey;
const engSlug = slugifyQuestionTitle(question.englishTitle || question.title);
const foundEntry = Object.values(answers).find(
(f) =>
f &&
(f.key === legacyKey ||
f.label === question.title ||
f.label === question.englishTitle ||
(typeof f.key === "string" &&
(f.key.endsWith(`.${engSlug}`) || f.key.endsWith(`_${engSlug}`)))),
);
return foundEntry?.key || legacyKey;
}
function createQuestionField( function createQuestionField(
question: QuestionField, question: QuestionField,
questionIndex: number,
value: MarriageFieldValue, value: MarriageFieldValue,
currentAnswers?: QuestionAnswersByKey,
backendFields?: MarriageField[],
): MarriageField { ): MarriageField {
const key = findQuestionFieldKey(question, questionIndex, currentAnswers, backendFields);
let option_id = undefined;
if (question.options && Array.isArray(question.options)) {
if (question.type === "checkbox" && Array.isArray(value)) {
option_id = value;
} else {
const selectedOpt = question.options.find((opt) => opt.id === value);
if (selectedOpt) {
option_id = selectedOpt.id;
}
}
}
const key = question.id;
return { return {
key, key,
label: question.title, label: question.title,
type: question.type, type: question.type,
value, value,
private: question.private, private: question.private,
};
option_id: option_id,
} as MarriageField;
} }
function getOrderedFields( function getOrderedFields(
@ -221,8 +158,8 @@ function getOrderedFields(
const orderedFields: MarriageField[] = []; const orderedFields: MarriageField[] = [];
const orderedKeys = new Set<string>(); const orderedKeys = new Set<string>();
questions.forEach((question, index) => {
const key = findQuestionFieldKey(question, index, answers, backendFields);
questions.forEach((question) => {
const key = question.id;
const field = answers[key]; const field = answers[key];
if (field) { if (field) {
@ -245,16 +182,11 @@ function getCurrentStep(
questions: readonly QuestionField[], questions: readonly QuestionField[],
backendFields?: MarriageField[], backendFields?: MarriageField[],
) { ) {
return questions.filter((question, index) => {
if (!question.required || question.logic?.dependsOn) {
return questions.filter((question) => {
if (!question.required || !question.isVisible) {
return false; return false;
} }
const key = findQuestionFieldKey(
question,
index,
fieldsToAnswers(fields),
backendFields
);
const key = question.id;
const field = fields.find((f) => f.key === key); const field = fields.find((f) => f.key === key);
return field && hasQuestionAnswerValue(field.value); return field && hasQuestionAnswerValue(field.value);
}).length; }).length;
@ -267,7 +199,7 @@ function createPayload(
): UpdateMarriageSectionDataPayload { ): UpdateMarriageSectionDataPayload {
const fields = getOrderedFields(answers, questions, backendFields); const fields = getOrderedFields(answers, questions, backendFields);
const targetQuestions = questions.filter( const targetQuestions = questions.filter(
(q) => q.required && !q.logic?.dependsOn,
(q) => q.required && q.isVisible,
); );
return { return {
@ -279,7 +211,14 @@ function createPayload(
function fieldsToAnswers(fields: MarriageField[]) { function fieldsToAnswers(fields: MarriageField[]) {
return fields.reduce<QuestionAnswersByKey>((nextAnswers, field) => { return fields.reduce<QuestionAnswersByKey>((nextAnswers, field) => {
nextAnswers[field.key] = field;
if (field.option_id !== undefined && field.option_id !== null) {
nextAnswers[field.key] = {
...field,
value: field.option_id,
};
} else {
nextAnswers[field.key] = field;
}
return nextAnswers; return nextAnswers;
}, {}); }, {});
} }
@ -346,9 +285,8 @@ function writeStoredAnswers(
} }
function getKeepalivePatchUrl(slug: string) { function getKeepalivePatchUrl(slug: string) {
const backendSlug = toBackendSlug(slug);
return getApiRequestUrl( return getApiRequestUrl(
`/api/marriage/sections/${pathParam(backendSlug)}/data/`,
`/api/marriage/forms/profile/answers/`,
); );
} }
@ -453,29 +391,22 @@ export function QuestionAnswersProvider({
}, []); }, []);
const getAnswerValue = useCallback( const getAnswerValue = useCallback(
(question: QuestionField, questionIndex: number) => {
const key = findQuestionFieldKey(question, questionIndex, answers, serverSectionData?.data || undefined);
(question: QuestionField) => {
const key = question.id;
return answers[key]?.value; return answers[key]?.value;
}, },
[answers, serverSectionData?.data],
[answers],
); );
const setAnswerValue = useCallback( const setAnswerValue = useCallback(
( (
question: QuestionField, question: QuestionField,
questionIndex: number,
value: MarriageFieldValue, value: MarriageFieldValue,
) => { ) => {
if (!canEdit) { if (!canEdit) {
return; return;
} }
const field = createQuestionField(
question,
questionIndex,
value,
answersRef.current,
serverSectionData?.data || undefined,
);
const field = createQuestionField(question, value);
setAnswers((currentAnswers) => { setAnswers((currentAnswers) => {
const nextAnswers = { const nextAnswers = {
@ -584,14 +515,7 @@ export function QuestionAnswersProvider({
return; return;
} }
// The combined family card must be split across two backend endpoints by
// updateMarriageSectionData. Sending its full payload to either endpoint
// would overwrite the other half of the profile. The local pending draft
// remains available and is retried on the next visit.
if (
slugRef.current === "family_marital_history" ||
flushPromiseRef.current
) {
if (flushPromiseRef.current) {
return; return;
} }
@ -611,12 +535,18 @@ export function QuestionAnswersProvider({
headers["X-CSRFToken"] = csrfToken; headers["X-CSRFToken"] = csrfToken;
} }
const answersPayload = payload.fields.map((f) => ({
question_id: f.key,
value: f.value,
option_id: (f as any).option_id || undefined,
}));
fetch(getKeepalivePatchUrl(slugRef.current), { fetch(getKeepalivePatchUrl(slugRef.current), {
body: JSON.stringify(payload),
body: JSON.stringify({ answers: answersPayload }),
credentials: "include", credentials: "include",
headers, headers,
keepalive: true, keepalive: true,
method: "PATCH",
method: "PUT",
}) })
.then((response) => { .then((response) => {
if (!response.ok) { if (!response.ok) {
@ -702,14 +632,13 @@ export function useQuestionAnswers() {
export function useQuestionAnswer( export function useQuestionAnswer(
question: QuestionField, question: QuestionField,
questionIndex: number,
) { ) {
const context = useContext(QuestionAnswersContext); const context = useContext(QuestionAnswersContext);
return { return {
setValue: (value: MarriageFieldValue) => { setValue: (value: MarriageFieldValue) => {
context?.setAnswerValue(question, questionIndex, value);
context?.setAnswerValue(question, value);
}, },
value: context?.getAnswerValue(question, questionIndex),
value: context?.getAnswerValue(question),
}; };
} }

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

@ -0,0 +1,260 @@
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QuestionAnswersProvider, useQuestionAnswers } from "./question-answer-storage";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data";
vi.mock("@/hooks/marriage/use-form-schema", () => ({
useFormSchemaQuery: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-section-data", () => ({
useMarriageSectionDataQuery: vi.fn(),
useUpdateMarriageSectionDataMutation: vi.fn(),
}));
// Dummy component to interact with the context
function TestComponent({ slug }: { slug: string }) {
const { setAnswerValue, flushAnswers } = useQuestionAnswers();
return (
<div>
<button
data-testid="set-radio"
onClick={() =>
setAnswerValue(
{ id: "q1", type: "radio", title: "Q1", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt1", value: "A", label: "Option A", order: 1 }] },
"opt1"
)
}
>
Set Radio
</button>
<button
data-testid="set-checkbox"
onClick={() =>
setAnswerValue(
{ id: "q2", type: "checkbox", title: "Q2", order: 2, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt2", value: "B", label: "Option B", order: 1 }, { id: "opt3", value: "C", label: "Option C", order: 2 }] },
["opt2", "opt3"]
)
}
>
Set Checkbox
</button>
<button data-testid="save" onClick={() => flushAnswers()}>
Save
</button>
</div>
);
}
describe("Question Answer & Schema Integration", () => {
let capturedPayload: any = null;
beforeEach(() => {
capturedPayload = null;
const updateMutateAsync = vi.fn(async (payload) => {
capturedPayload = payload;
return payload;
});
(useUpdateMarriageSectionDataMutation as any).mockReturnValue({
mutateAsync: updateMutateAsync,
isPending: false,
});
(useMarriageProfileQuery as any).mockReturnValue({
data: { can_edit_profile: true },
});
(useMarriageSectionDataQuery as any).mockReturnValue({
data: [],
isLoading: false,
});
(useFormSchemaQuery as any).mockReturnValue({
data: { is_completed: false },
isLoading: false,
isFetching: false,
});
});
afterEach(() => {
cleanup();
});
it("should send option_id instead of label/value for radio", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider
slug="test_slug"
questions={[
{ id: "q1", type: "radio", title: "Q1", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt1", value: "A", label: "Option A", order: 1 }] },
]}
>
<TestComponent slug="test_slug" />
</QuestionAnswersProvider>
</QueryClientProvider>
);
fireEvent.click(screen.getByTestId("set-radio"));
fireEvent.click(screen.getByTestId("save"));
await waitFor(() => {
expect(capturedPayload).not.toBeNull();
const field = capturedPayload.fields.find((f: any) => f.key === "q1");
expect(field.option_id).toBe("opt1");
expect(field.value).toBe("opt1"); // ui value is option_id
});
});
it("should send array of option_ids for checkbox", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider
slug="test_slug"
questions={[
{ id: "q2", type: "checkbox", title: "Q2", order: 2, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", options: [], range: [0,0] }, options: [{ id: "opt2", value: "B", label: "Option B", order: 1 }, { id: "opt3", value: "C", label: "Option C", order: 2 }] },
]}
>
<TestComponent slug="test_slug" />
</QuestionAnswersProvider>
</QueryClientProvider>
);
fireEvent.click(screen.getByTestId("set-checkbox"));
fireEvent.click(screen.getByTestId("save"));
await waitFor(() => {
expect(capturedPayload).not.toBeNull();
const field = capturedPayload.fields.find((f: any) => f.key === "q2");
expect(field.option_id).toEqual(["opt2", "opt3"]);
});
});
it("should sort schema questions and options by order correctly", () => {
const mockSchema = {
sections: [
{
id: "sec1",
title: "Section",
order: 1,
icon: "user-circle",
is_required: true,
estimated_minutes: 5,
cards: [
{
id: "card1",
title: "Card",
order: 2,
questions: [
{ id: "q1", title: "Q1", type: "text", order: 10, required: true, is_visible: true, ui_config: {}, options: [] },
{ id: "q2", title: "Q2", type: "text", order: 5, required: true, is_visible: true, ui_config: {}, options: [
{ id: "opt1", value: "A", label: "Option A", order: 2 },
{ id: "opt2", value: "B", label: "Option B", order: 1 }
] },
]
},
{
id: "card2",
title: "Card 2",
order: 1,
questions: [
{ id: "q3", title: "Q3", type: "text", order: 1, required: true, is_visible: true, ui_config: {}, options: [] },
]
}
]
}
],
progress: { sections_progress: {} }
} as any;
const frontendItems = convertSchemaToFrontendItems(mockSchema, "en");
const questions = frontendItems[0].questions;
expect(questions[0].id).toBe("q3");
expect(questions[1].id).toBe("q2");
expect(questions[2].id).toBe("q1");
expect(questions[1].options[0].id).toBe("opt2");
expect(questions[1].options[1].id).toBe("opt1");
});
it("should hydrate radio/dropdown/checkbox with option_id instead of canonical value", async () => {
// Mock the backend sending canonical value but keeping option_id
(useMarriageSectionDataQuery as any).mockReturnValue({
data: {
slug: "test_slug",
data: [
{ key: "q_radio", type: "radio", value: "Server Canonical Value", option_id: "opt_radio" },
{ key: "q_check", type: "checkbox", value: ["Server Val 1", "Server Val 2"], option_id: ["opt_check1", "opt_check2"] },
],
},
isLoading: false,
});
let capturedValues: any = {};
function HydrationTestComponent() {
const { getAnswerValue } = useQuestionAnswers();
capturedValues.radio = getAnswerValue({ id: "q_radio" } as any);
capturedValues.check = getAnswerValue({ id: "q_check" } as any);
return <div />;
}
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test_slug" questions={[]}>
<HydrationTestComponent />
</QuestionAnswersProvider>
</QueryClientProvider>
);
// Give it a moment to reconcile useEffect in QuestionAnswersProvider
await waitFor(() => {
expect(capturedValues.radio).toBe("opt_radio");
expect(capturedValues.check).toEqual(["opt_check1", "opt_check2"]);
});
});
it("should respect bq.is_required over bq.required for required state", () => {
const mockSchema = {
sections: [
{
id: "sec1",
title: "Section",
order: 1,
icon: "user-circle",
is_required: true,
estimated_minutes: 5,
cards: [
{
id: "card1",
title: "Card",
order: 1,
questions: [
{ id: "q1", title: "Q1", type: "text", order: 1, required: false, is_required: true, is_visible: true, ui_config: {}, options: [] },
]
}
]
}
],
progress: { sections_progress: {} }
} as any;
const frontendItems = convertSchemaToFrontendItems(mockSchema, "en");
const question = frontendItems[0].questions[0];
expect(question.baseRequired).toBe(false);
expect(question.required).toBe(true);
});
});

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

@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { getCountryList, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries"; import { getCountryList, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; 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";
@ -10,7 +10,6 @@ import { LoadingThreeDot } from "./loading-three-dot";
type QuestionBirthplaceProps = { type QuestionBirthplaceProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
}; };
@ -78,12 +77,11 @@ function parseValue(rawValue: unknown): { country: string; city: string } {
export function QuestionBirthplace({ export function QuestionBirthplace({
question, question,
questionIndex,
disabled, disabled,
}: QuestionBirthplaceProps) { }: QuestionBirthplaceProps) {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers();
const rawValue = getAnswerValue(question, questionIndex);
const rawValue = getAnswerValue(question);
const initial = parseValue(rawValue); const initial = parseValue(rawValue);
const [selectedCountry, setSelectedCountry] = useState(initial.country); const [selectedCountry, setSelectedCountry] = useState(initial.country);
@ -95,9 +93,7 @@ export function QuestionBirthplace({
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const isResidence =
question.title.toLowerCase().includes("residence") ||
question.title.includes("سکونت");
const isResidence = question.ui_config?.enable_geoip === true;
const [mode, setMode] = useState<"auto" | "manual">("auto"); const [mode, setMode] = useState<"auto" | "manual">("auto");
const [isDetecting, setIsDetecting] = useState(false); const [isDetecting, setIsDetecting] = useState(false);
@ -106,7 +102,7 @@ export function QuestionBirthplace({
const updateAnswers = (country: string, city: string) => { const updateAnswers = (country: string, city: string) => {
const formatted = const formatted =
city && country ? `${city}, ${country}` : city || country || null; city && country ? `${city}, ${country}` : city || country || null;
setAnswerValue(question, questionIndex, formatted);
setAnswerValue(question, formatted);
}; };
// GeoIP detection logic // GeoIP detection logic

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

@ -1,22 +1,20 @@
"use client"; "use client";
import { IoInformation } from "react-icons/io5"; import { IoInformation } from "react-icons/io5";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useQuestionAnswer } from "./question-answer-storage"; import { useQuestionAnswer } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
type QuestionButtonProps = { type QuestionButtonProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
}; };
export function QuestionButton({ export function QuestionButton({
question, question,
questionIndex,
disabled, disabled,
}: QuestionButtonProps) { }: QuestionButtonProps) {
const { setValue, value } = useQuestionAnswer(question, questionIndex);
const { setValue, value } = useQuestionAnswer(question);
const isAnswered = value === true; const isAnswered = value === true;
return ( return (

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

@ -9,7 +9,7 @@ import {
IoPerson, IoPerson,
IoSchool, IoSchool,
} from "react-icons/io5"; } from "react-icons/io5";
import type { QuestionCardIcon, QuestionListItem } from "@/data/question-data";
import type { QuestionCardIcon, QuestionListItem } from "@/lib/schema-adapter";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";

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

@ -1,23 +1,21 @@
"use client"; "use client";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
type QuestionCheckboxProps = { type QuestionCheckboxProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
}; };
export function QuestionCheckbox({ export function QuestionCheckbox({
question, question,
questionIndex,
disabled, disabled,
}: QuestionCheckboxProps) { }: QuestionCheckboxProps) {
const options = question.extras.options || [];
const options = question.options || [];
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const rawValue = getAnswerValue(question, questionIndex);
const rawValue = getAnswerValue(question);
const value = Array.isArray(rawValue) const value = Array.isArray(rawValue)
? rawValue ? rawValue
@ -29,40 +27,21 @@ export function QuestionCheckbox({
return null; return null;
} }
const toggleOption = (option: string) => {
const toggleOption = (optionId: string) => {
let nextValue: string[]; let nextValue: string[];
if (value.includes(option)) {
nextValue = value.filter((v) => v !== option);
if (value.includes(optionId)) {
nextValue = value.filter((v) => v !== optionId);
} else { } else {
nextValue = [...value, option];
}
const exclusiveOption = options.find(
(o) =>
o.includes("مهم نیست") ||
o.includes("Doesn't matter") ||
o.includes("No children") ||
o.includes("فرزندی ندارم.") ||
o.includes("مسئولیت مستمری ندارم") ||
o.includes("do not have any ongoing responsibility"),
);
if (exclusiveOption) {
if (option === exclusiveOption) {
nextValue = [exclusiveOption];
} else if (nextValue.includes(exclusiveOption)) {
nextValue = nextValue.filter((v) => v !== exclusiveOption);
}
nextValue = [...value, optionId];
} }
setAnswerValue( setAnswerValue(
question,
questionIndex,
nextValue.length > 0 ? nextValue : null,
question, nextValue.length > 0 ? nextValue : null,
); );
}; };
const isShortOptions = const isShortOptions =
options.length <= 4 && options.every((opt) => opt.length <= 15);
options.length <= 4 && options.every((opt) => opt.label.length <= 15);
return ( return (
<div <div
@ -81,12 +60,12 @@ export function QuestionCheckbox({
} }
> >
{options.map((option) => { {options.map((option) => {
const optionId = `checkbox-${questionIndex}-${option}`;
const isSelected = value.includes(option);
const optionId = `checkbox-${question.id}-${option.id}`;
const isSelected = value.includes(option.id);
return ( return (
<label <label
key={option}
key={option.id}
htmlFor={optionId} htmlFor={optionId}
className={[ className={[
"cursor-pointer rounded-[16px] font-semibold text-[15px] transition-all duration-200 active:scale-[0.99] flex items-center gap-3", "cursor-pointer rounded-[16px] font-semibold text-[15px] transition-all duration-200 active:scale-[0.99] flex items-center gap-3",
@ -103,7 +82,7 @@ export function QuestionCheckbox({
id={optionId} id={optionId}
checked={isSelected} checked={isSelected}
disabled={disabled} disabled={disabled}
onChange={() => toggleOption(option)}
onChange={() => toggleOption(option.id)}
className="sr-only" className="sr-only"
/> />
{!isShortOptions && ( {!isShortOptions && (
@ -132,7 +111,7 @@ export function QuestionCheckbox({
)} )}
</div> </div>
)} )}
<span className="flex-1">{option}</span>
<span className="flex-1">{option.label}</span>
</label> </label>
); );
})} })}

38
src/components/Componentes/question-date.tsx

@ -1,30 +1,29 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; 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";
type QuestionDateProps = { type QuestionDateProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
}; };
const MONTH_VALUES = [
"01",
"02",
"03",
"04",
"05",
"06",
"07",
"08",
"09",
"10",
"11",
"12",
const MONTHS = [
{ value: "01", label: "01 - January" },
{ value: "02", label: "02 - February" },
{ value: "03", label: "03 - March" },
{ value: "04", label: "04 - April" },
{ value: "05", label: "05 - May" },
{ value: "06", label: "06 - June" },
{ value: "07", label: "07 - July" },
{ value: "08", label: "08 - August" },
{ value: "09", label: "09 - September" },
{ value: "10", label: "10 - October" },
{ value: "11", label: "11 - November" },
{ value: "12", label: "12 - December" },
]; ];
const DAYS = Array.from({ length: 31 }, (_, i) => { const DAYS = Array.from({ length: 31 }, (_, i) => {
@ -42,12 +41,11 @@ const YEARS = Array.from({ length: 80 }, (_, i) =>
export function QuestionDate({ export function QuestionDate({
question, question,
questionIndex,
disabled, disabled,
}: QuestionDateProps) { }: QuestionDateProps) {
const { locale } = useI18n(); const { locale } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const value = getAnswerValue(question);
const dateValue = typeof value === "string" ? value : ""; const dateValue = typeof value === "string" ? value : "";
const months = useMemo(() => { const months = useMemo(() => {
@ -123,12 +121,10 @@ export function QuestionDate({
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( setAnswerValue(
question,
questionIndex,
`${y}-${formattedMonth}-${formattedDay}`,
question, `${y}-${formattedMonth}-${formattedDay}`,
); );
} else { } else {
setAnswerValue(question, questionIndex, "");
setAnswerValue(question, "");
} }
}; };

188
src/components/Componentes/question-dropdown.tsx

@ -2,33 +2,29 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ExplanationUiFont } from "./explanation-ui-font"; import { ExplanationUiFont } from "./explanation-ui-font";
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 "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; 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";
type QuestionDropdownProps = { type QuestionDropdownProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
}; };
export function QuestionDropdown({ export function QuestionDropdown({
question, question,
questionIndex,
disabled, disabled,
}: QuestionDropdownProps) { }: QuestionDropdownProps) {
const { locale, dictionary: t } = useI18n();
const { dictionary: t } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const rawValue = getAnswerValue(question, questionIndex);
const rawValue = getAnswerValue(question);
const isMulti = const isMulti =
Array.isArray(rawValue) || Array.isArray(rawValue) ||
question.type === "checkbox" || question.type === "checkbox" ||
(question.extras?.range && question.extras.range[1] > 1); (question.extras?.range && question.extras.range[1] > 1);
const selectedList = Array.isArray(rawValue) const selectedList = Array.isArray(rawValue)
? rawValue ? rawValue
: typeof rawValue === "string" && rawValue : typeof rawValue === "string" && rawValue
@ -100,154 +96,49 @@ export function QuestionDropdown({
} }
}, [isOpen]); }, [isOpen]);
const isPersonalityTraits =
question.title === "Your Personality Traits" ||
question.title === "ویژگی‌های شخصیتی خودتان";
const isHobbies =
question.title === "Your Hobbies and Main Interests" ||
question.title === "سرگرمی‌ها و علایق اصلی";
const isSmokingSubstances =
question.title === "Red Lines for Smoking, Alcohol, and Substances" ||
question.title === "خط قرمزهای مربوط به دخانیات، الکل و مواد در همسر آینده";
const isOtherQuestion =
isPersonalityTraits || isHobbies || isSmokingSubstances;
const otherOptionName = t["Other"] || "Other";
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);
}
if (isOtherQuestion) {
options = [...options, otherOptionName];
}
const options = question.options || [];
const showSearch = (() => { const showSearch = (() => {
if (question.extras.noSearch) return false;
const titleLower = question.title.toLowerCase();
return (
isCountryDropdown ||
isNationalityDropdown ||
isLanguageDropdown ||
titleLower.includes("country") ||
titleLower.includes("کشور") ||
titleLower.includes("city") ||
titleLower.includes("شهر") ||
titleLower.includes("currency") ||
titleLower.includes("ارز") ||
titleLower.includes("nationality") ||
titleLower.includes("ملیت") ||
titleLower.includes("language") ||
titleLower.includes("زبان") ||
options.length > 10
);
if (question.extras?.noSearch) return false;
return options.length > 10;
})(); })();
const filteredOptions = options.filter((option) => const filteredOptions = options.filter((option) =>
option.toLowerCase().includes(searchQuery.toLowerCase()),
option.label.toLowerCase().includes(searchQuery.toLowerCase()),
); );
const getCleanLabel = (val: string) => {
const base = val.split(" - ")[0];
if (isOtherQuestion) {
if (base === otherOptionName || base.startsWith(`${otherOptionName}: `)) {
return otherOptionName;
}
}
return base;
const getCleanLabel = (optId: string) => {
const opt = options.find((o) => o.id === optId);
if (!opt) return optId;
return opt.label.split(" - ")[0];
}; };
const displayLabel = isMulti const displayLabel = isMulti
? selectedList.length > 0 ? selectedList.length > 0
? selectedList.map(getCleanLabel).join(", ") ? selectedList.map(getCleanLabel).join(", ")
: question.extras.placeHolder || "Select"
: question.extras?.placeHolder || "Select"
: singleValue : singleValue
? getCleanLabel(singleValue) ? getCleanLabel(singleValue)
: question.extras.placeHolder || "Select";
: question.extras?.placeHolder || "Select";
const hasSelectedValue = isMulti const hasSelectedValue = isMulti
? selectedList.length > 0 ? selectedList.length > 0
: Boolean(singleValue); : Boolean(singleValue);
const otherValueIndex = selectedList.findIndex(
(v) => v === otherOptionName || v.startsWith(`${otherOptionName}: `),
);
const isOtherSelected = otherValueIndex !== -1;
const currentOtherText = isOtherSelected
? selectedList[otherValueIndex].startsWith(`${otherOptionName}: `)
? selectedList[otherValueIndex].slice(otherOptionName.length + 2)
: ""
: "";
const toggleMultiOption = (option: string) => {
const isOtherOpt = isOtherQuestion && option === otherOptionName;
const toggleMultiOption = (optionId: string) => {
let nextValue: string[]; let nextValue: string[];
if (isOtherQuestion && isOtherOpt) {
const hasOther = selectedList.some(
(v) => v === otherOptionName || v.startsWith(`${otherOptionName}: `),
);
if (hasOther) {
nextValue = selectedList.filter(
(v) => v !== otherOptionName && !v.startsWith(`${otherOptionName}: `),
);
} else {
nextValue = [...selectedList, otherOptionName];
}
if (selectedList.includes(optionId)) {
nextValue = selectedList.filter((v) => v !== optionId);
} else { } else {
if (selectedList.includes(option)) {
nextValue = selectedList.filter((v) => v !== option);
} else {
nextValue = [...selectedList, option];
}
nextValue = [...selectedList, optionId];
} }
setAnswerValue( setAnswerValue(
question,
questionIndex,
nextValue.length > 0 ? nextValue : null,
question, nextValue.length > 0 ? nextValue : null,
); );
}; };
const handleOtherTextChange = (text: string) => {
if (otherValueIndex !== -1) {
const nextValue = [...selectedList];
if (text.trim() === "") {
nextValue[otherValueIndex] = otherOptionName;
} else {
nextValue[otherValueIndex] = `${otherOptionName}: ${text}`;
}
setAnswerValue(question, questionIndex, nextValue);
}
};
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@ -299,29 +190,6 @@ export function QuestionDropdown({
</svg> </svg>
</button> </button>
{/* RENDER TEXT FIELD IF OTHER IS SELECTED */}
{isOtherQuestion && isOtherSelected ? (
<div className="mt-1">
<input
type="text"
value={currentOtherText}
onChange={(e) => handleOtherTextChange(e.target.value)}
placeholder={
isPersonalityTraits
? (t["Write other options..."] ?? "Write other options...")
: isSmokingSubstances
? locale === "fa"
? "خط قرمزهای دیگر را بنویسید..."
: "Write other red lines..."
: locale === "fa"
? "سرگرمی‌ها یا علایق دیگر را بنویسید..."
: "Write other hobbies or interests..."
}
className="h-[48px] w-full rounded-[14px] border border-[#D0D5DD] bg-white px-4 text-[14px] font-medium text-[#181818] outline-none transition-all placeholder:text-[#98A2B3] hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F]"
/>
</div>
) : null}
{/* Dropdown Options Panel */} {/* Dropdown Options Panel */}
{isOpen && ( {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"> <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">
@ -383,20 +251,18 @@ export function QuestionDropdown({
{filteredOptions.length > 0 ? ( {filteredOptions.length > 0 ? (
filteredOptions.map((option) => { filteredOptions.map((option) => {
const isSelected = isMulti const isSelected = isMulti
? isOtherQuestion && option === otherOptionName
? isOtherSelected
: selectedList.includes(option)
: singleValue === option;
? selectedList.includes(option.id)
: singleValue === option.id;
return ( return (
<button <button
key={option}
key={option.id}
type="button" type="button"
onClick={() => { onClick={() => {
if (isMulti) { if (isMulti) {
toggleMultiOption(option);
toggleMultiOption(option.id);
} else { } else {
setAnswerValue(question, questionIndex, option);
setAnswerValue(question, option.id);
setIsOpen(false); setIsOpen(false);
} }
}} }}
@ -426,9 +292,9 @@ export function QuestionDropdown({
)} )}
<span className="text-[15px] text-[#181818] leading-tight flex-1"> <span className="text-[15px] text-[#181818] leading-tight flex-1">
{option.includes(" - ") ? (
{option.label.includes(" - ") ? (
(() => { (() => {
const parts = option.split(" - ");
const parts = option.label.split(" - ");
const title = parts[0]; const title = parts[0];
const description = parts.slice(1).join(" - "); const description = parts.slice(1).join(" - ");
return ( return (
@ -443,7 +309,7 @@ export function QuestionDropdown({
); );
})() })()
) : ( ) : (
<span className="font-bold">{option}</span>
<span className="font-bold">{option.label}</span>
)} )}
</span> </span>
</button> </button>

18
src/components/Componentes/question-exit-navigation-button.tsx

@ -25,28 +25,32 @@ export function QuestionExitNavigationButton({
const { locale } = useI18n(); const { locale } = useI18n();
const { flushAnswers } = useQuestionAnswers(); const { flushAnswers } = useQuestionAnswers();
const [isNavigating, setIsNavigating] = useState(false);
return ( return (
<NavigationButton <NavigationButton
{...props} {...props}
onClick={(event) => {
disabled={props.disabled || isNavigating}
onClick={async (event) => {
props.onClick?.(event); props.onClick?.(event);
if (event.defaultPrevented) {
if (event.defaultPrevented || isNavigating) {
return; return;
} }
event.preventDefault(); event.preventDefault();
setIsNavigating(true);
try { try {
markFirstEntryCompleted(); markFirstEntryCompleted();
void flushAnswers({ force: true });
await flushAnswers({ force: true });
} catch { } catch {
// ignore // ignore
} finally {
triggerSilentReload(queryClient);
const target = localizePath(exitHref || "/questions-list", locale);
router.push(target);
} }
triggerSilentReload(queryClient);
const target = localizePath(exitHref || "/questions-list", locale);
router.push(target);
}} }}
/> />
); );

20
src/components/Componentes/question-file.tsx

@ -2,7 +2,7 @@
import Image from "next/image"; import Image from "next/image";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
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";
import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions";
@ -12,7 +12,6 @@ import { LoadingSkeleton } from "./loading-skeleton";
type QuestionFileProps = { type QuestionFileProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
}; };
@ -59,11 +58,10 @@ function isImageFile(
export function QuestionFile({ export function QuestionFile({
question, question,
questionIndex,
disabled, disabled,
}: QuestionFileProps) { }: QuestionFileProps) {
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const storedValue = getAnswerValue(question, questionIndex);
const storedValue = getAnswerValue(question);
const initialFileName = const initialFileName =
typeof storedValue === "string" && storedValue.trim().length > 0 typeof storedValue === "string" && storedValue.trim().length > 0
@ -94,7 +92,7 @@ export function QuestionFile({
const uploadTmpMediaMutation = useUploadTmpMediaMutation({ const uploadTmpMediaMutation = useUploadTmpMediaMutation({
onSuccess: (response) => { onSuccess: (response) => {
if (response.path) { if (response.path) {
setAnswerValue(question, questionIndex, response.path);
setAnswerValue(question, response.path);
} }
}, },
onError: (error) => { onError: (error) => {
@ -120,7 +118,7 @@ export function QuestionFile({
const fileName = event.data.files[0].name ?? null; const fileName = event.data.files[0].name ?? null;
setSelectedFileName(fileName); setSelectedFileName(fileName);
if (fileName) { if (fileName) {
setAnswerValue(question, questionIndex, fileName);
setAnswerValue(question, fileName);
} }
} }
break; break;
@ -130,7 +128,7 @@ export function QuestionFile({
setIsFlutterPicking(false); setIsFlutterPicking(false);
const file = event.data?.files?.[0]; const file = event.data?.files?.[0];
if (file?.url) { if (file?.url) {
setAnswerValue(question, questionIndex, file.url);
setAnswerValue(question, file.url);
setSelectedFileName(file.name ?? "uploaded"); setSelectedFileName(file.name ?? "uploaded");
setFilePreviewUrl(file.url); setFilePreviewUrl(file.url);
} else if (file?.base64) { } else if (file?.base64) {
@ -161,7 +159,7 @@ export function QuestionFile({
return () => { return () => {
unsubscribe?.(); unsubscribe?.();
}; };
}, [question, questionIndex, setAnswerValue, uploadTmpMediaMutation]);
}, [question, setAnswerValue, uploadTmpMediaMutation]);
/** Handle file pick in Flutter WebView via upload_file action. */ /** Handle file pick in Flutter WebView via upload_file action. */
const handleFlutterPick = useCallback(() => { const handleFlutterPick = useCallback(() => {
@ -190,12 +188,12 @@ export function QuestionFile({
if (!file) { if (!file) {
setSelectedFileName(null); setSelectedFileName(null);
setFilePreviewUrl(null); setFilePreviewUrl(null);
setAnswerValue(question, questionIndex, null);
setAnswerValue(question, null);
return; return;
} }
setSelectedFileName(file.name); setSelectedFileName(file.name);
setAnswerValue(question, questionIndex, file.name);
setAnswerValue(question, file.name);
if (file.type.startsWith("image/")) { if (file.type.startsWith("image/")) {
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
@ -212,7 +210,7 @@ export function QuestionFile({
e.preventDefault(); e.preventDefault();
setSelectedFileName(null); setSelectedFileName(null);
setFilePreviewUrl(null); setFilePreviewUrl(null);
setAnswerValue(question, questionIndex, null);
setAnswerValue(question, null);
}; };
const inWebView = isInFlutterWebView(); const inWebView = isInFlutterWebView();

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

@ -1,14 +1,13 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; 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";
type QuestionNumberProps = { type QuestionNumberProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
derivedFromQuestion?: QuestionField; derivedFromQuestion?: QuestionField;
derivedFromQuestionIndex?: number; derivedFromQuestionIndex?: number;
@ -18,32 +17,30 @@ const NUMBER_INPUT_PATTERN = /^-?\d*\.?\d*$/;
export default function QuestionNumber({ export default function QuestionNumber({
question, question,
questionIndex,
disabled, disabled,
derivedFromQuestion, derivedFromQuestion,
derivedFromQuestionIndex, derivedFromQuestionIndex,
}: QuestionNumberProps) { }: QuestionNumberProps) {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const value = getAnswerValue(question);
const derivedValue = const derivedValue =
derivedFromQuestion && derivedFromQuestionIndex !== undefined derivedFromQuestion && derivedFromQuestionIndex !== undefined
? getAnswerValue(derivedFromQuestion, derivedFromQuestionIndex)
? getAnswerValue(derivedFromQuestion)
: null; : null;
useEffect(() => { useEffect(() => {
if (derivedFromQuestion && typeof derivedValue === "string") { if (derivedFromQuestion && typeof derivedValue === "string") {
const age = calculateAge(derivedValue); const age = calculateAge(derivedValue);
if (age !== String(value)) { if (age !== String(value)) {
setAnswerValue(question, questionIndex, age);
setAnswerValue(question, age);
} }
} }
}, [ }, [
derivedFromQuestion, derivedFromQuestion,
derivedValue, derivedValue,
question, question,
questionIndex,
setAnswerValue, setAnswerValue,
value, value,
]); ]);
@ -54,9 +51,9 @@ export default function QuestionNumber({
value.length > 0 && value.length > 0 &&
!NUMBER_INPUT_PATTERN.test(value) !NUMBER_INPUT_PATTERN.test(value)
) { ) {
setAnswerValue(question, questionIndex, null);
setAnswerValue(question, null);
} }
}, [question, questionIndex, setAnswerValue, value]);
}, [question, setAnswerValue, value]);
const [min, max] = question.extras.range; const [min, max] = question.extras.range;
@ -78,15 +75,14 @@ export default function QuestionNumber({
? rawInputValue ? rawInputValue
: ""; : "";
const isMonthlyIncome =
question.title === "Monthly Income" ||
question.title === "میزان درآمد ماهانه";
const isMonthlyIncome = question.ui_config?.currency_enabled === true;
const currencyStorageKey = question.ui_config?.currency_storage_key || "marriage:income:currency";
const countryName = useMemo(() => getCountryFromStorage(), []); const countryName = useMemo(() => getCountryFromStorage(), []);
const [currencyCode, setCurrencyCode] = useState(() => { const [currencyCode, setCurrencyCode] = useState(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("marriage:income:currency");
const stored = window.localStorage.getItem(currencyStorageKey);
if (stored) return stored; if (stored) return stored;
} }
return getCurrencyForCountry(countryName); return getCurrencyForCountry(countryName);
@ -99,7 +95,7 @@ export default function QuestionNumber({
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("marriage:income:currency");
const stored = window.localStorage.getItem(currencyStorageKey);
if (stored) { if (stored) {
setCurrencyCode(stored); setCurrencyCode(stored);
return; return;
@ -215,13 +211,11 @@ export default function QuestionNumber({
setLocalTextValue(finalFormatted); setLocalTextValue(finalFormatted);
if (cleanValue === "") { if (cleanValue === "") {
setAnswerValue(question, questionIndex, null);
setAnswerValue(question, null);
} else { } else {
const parsed = parseFloat(cleanValue); const parsed = parseFloat(cleanValue);
setAnswerValue( setAnswerValue(
question,
questionIndex,
Number.isNaN(parsed) ? cleanValue : parsed,
question, Number.isNaN(parsed) ? cleanValue : parsed,
); );
} }
}} }}
@ -330,7 +324,7 @@ export default function QuestionNumber({
setCurrencyCode(c.code); setCurrencyCode(c.code);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.localStorage.setItem( window.localStorage.setItem(
"marriage:income:currency",
currencyStorageKey,
c.code, c.code,
); );
} }
@ -398,13 +392,11 @@ export default function QuestionNumber({
} }
if (nextValue === "") { if (nextValue === "") {
setAnswerValue(question, questionIndex, null);
setAnswerValue(question, null);
} else { } else {
const parsed = parseFloat(nextValue); const parsed = parseFloat(nextValue);
setAnswerValue( setAnswerValue(
question,
questionIndex,
Number.isNaN(parsed) ? nextValue : parsed,
question, Number.isNaN(parsed) ? nextValue : parsed,
); );
} }
}} }}

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

@ -2,7 +2,7 @@
import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber"; import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber";
import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
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";
@ -11,7 +11,6 @@ import { useI18n } from "@/translations/provider";
type QuestionPhoneProps = { type QuestionPhoneProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
countryCode?: string; countryCode?: string;
disabled?: boolean; disabled?: boolean;
}; };
@ -224,13 +223,12 @@ function getNormalizedPhoneValue(codeValue: string, phoneValue: string) {
export function QuestionPhone({ export function QuestionPhone({
question, question,
questionIndex,
countryCode = "+44", countryCode = "+44",
disabled, disabled,
}: QuestionPhoneProps) { }: QuestionPhoneProps) {
const { locale } = useI18n(); const { locale } = useI18n();
const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue, isLoading } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const value = getAnswerValue(question);
const defaultCodeValue = countryCode.trim() || "+44"; const defaultCodeValue = countryCode.trim() || "+44";
const getCachedOrSavedCode = useCallback((): string | null => { const getCachedOrSavedCode = useCallback((): string | null => {
@ -548,7 +546,7 @@ export function QuestionPhone({
: null; : null;
lastCommittedValueRef.current = nextValue; lastCommittedValueRef.current = nextValue;
setAnswerValue(question, questionIndex, nextValue);
setAnswerValue(question, nextValue);
}; };
const handleSelectCountryCode = (selectedCode: string) => { const handleSelectCountryCode = (selectedCode: string) => {

18
src/components/Componentes/question-photo.tsx

@ -2,7 +2,7 @@
import Image from "next/image"; import Image from "next/image";
import { type ReactNode, useCallback, useEffect, useId, useState } from "react"; import { type ReactNode, useCallback, useEffect, useId, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
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";
import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions"; import { isInFlutterWebView, uploadFile } from "@/lib/webview-actions";
@ -12,14 +12,12 @@ import { LoadingSkeleton } from "./loading-skeleton";
type QuestionPhotoProps = { type QuestionPhotoProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
description?: ReactNode; description?: ReactNode;
disabled?: boolean; disabled?: boolean;
}; };
export function QuestionPhoto({ export function QuestionPhoto({
question, question,
questionIndex,
description, description,
disabled, disabled,
}: QuestionPhotoProps) { }: QuestionPhotoProps) {
@ -34,12 +32,12 @@ export function QuestionPhoto({
const descriptionContent = description ?? question.description; const descriptionContent = description ?? question.description;
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const storedValue = getAnswerValue(question, questionIndex);
const storedValue = getAnswerValue(question);
const uploadTmpMediaMutation = useUploadTmpMediaMutation({ const uploadTmpMediaMutation = useUploadTmpMediaMutation({
onSuccess: (response) => { onSuccess: (response) => {
if (response.path) { if (response.path) {
setAnswerValue(question, questionIndex, response.path);
setAnswerValue(question, response.path);
} }
}, },
onError: (error) => { onError: (error) => {
@ -64,19 +62,19 @@ export function QuestionPhoto({
if (event.data?.files?.[0]?.base64) { if (event.data?.files?.[0]?.base64) {
const b64 = event.data.files[0].base64; const b64 = event.data.files[0].base64;
setLocalPreviewUrl(b64); setLocalPreviewUrl(b64);
setAnswerValue(question, questionIndex, b64);
setAnswerValue(question, b64);
} }
break; break;
case "completed": { case "completed": {
setIsFlutterPicking(false); setIsFlutterPicking(false);
const file = event.data?.files?.[0]; const file = event.data?.files?.[0];
if (file?.url) { if (file?.url) {
setAnswerValue(question, questionIndex, file.url);
setAnswerValue(question, file.url);
setLocalPreviewUrl(file.url); setLocalPreviewUrl(file.url);
} else if (file?.base64) { } else if (file?.base64) {
const b64 = file.base64; const b64 = file.base64;
setLocalPreviewUrl(b64); setLocalPreviewUrl(b64);
setAnswerValue(question, questionIndex, b64);
setAnswerValue(question, b64);
fetch(b64) fetch(b64)
.then((res) => res.blob()) .then((res) => res.blob())
.then((blob) => { .then((blob) => {
@ -98,7 +96,7 @@ export function QuestionPhoto({
return () => { return () => {
unsubscribe?.(); unsubscribe?.();
}; };
}, [question, questionIndex, setAnswerValue, uploadTmpMediaMutation]);
}, [question, setAnswerValue, uploadTmpMediaMutation]);
const handleFlutterPick = useCallback(() => { const handleFlutterPick = useCallback(() => {
const extensions = (question.extras?.options ?? []).map((o) => const extensions = (question.extras?.options ?? []).map((o) =>
@ -126,7 +124,7 @@ export function QuestionPhoto({
// Create synchronous object URL for instant preview & immediate state update // Create synchronous object URL for instant preview & immediate state update
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
setLocalPreviewUrl(objectUrl); setLocalPreviewUrl(objectUrl);
setAnswerValue(question, questionIndex, objectUrl);
setAnswerValue(question, objectUrl);
// Trigger background upload // Trigger background upload
uploadTmpMediaMutation.mutate(file); uploadTmpMediaMutation.mutate(file);

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

@ -1,24 +1,22 @@
"use client"; "use client";
import { ExplanationUiFont } from "./explanation-ui-font"; import { ExplanationUiFont } from "./explanation-ui-font";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
type QuestionRadioProps = { type QuestionRadioProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
}; };
export function QuestionRadio({ export function QuestionRadio({
question, question,
questionIndex,
disabled, disabled,
}: QuestionRadioProps) { }: QuestionRadioProps) {
const options = question.extras.options || [];
const options = question.options || [];
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const value = getAnswerValue(question);
if (options.length === 0) { if (options.length === 0) {
return null; return null;
@ -26,7 +24,7 @@ export function QuestionRadio({
// Render horizontally if all options are short (e.g. Single, Divorced, Widowed) // Render horizontally if all options are short (e.g. Single, Divorced, Widowed)
const isShortOptions = const isShortOptions =
options.length <= 4 && options.every((opt) => opt.length <= 15);
options.length <= 4 && options.every((opt) => opt.label.length <= 15);
return ( return (
<div <div
@ -45,12 +43,12 @@ export function QuestionRadio({
} }
> >
{options.map((option) => { {options.map((option) => {
const optionId = `question-${questionIndex}-${option}`;
const isSelected = String(value) === option;
const optionId = `question-${question.id}-${option.id}`;
const isSelected = String(value) === option.id;
return ( return (
<label <label
key={option}
key={option.id}
htmlFor={optionId} htmlFor={optionId}
className={[ className={[
"cursor-pointer rounded-[16px] font-semibold text-[15px] transition-all duration-200 active:scale-[0.99] flex items-center gap-3", "cursor-pointer rounded-[16px] font-semibold text-[15px] transition-all duration-200 active:scale-[0.99] flex items-center gap-3",
@ -65,11 +63,11 @@ export function QuestionRadio({
<input <input
type="radio" type="radio"
id={optionId} id={optionId}
name={`question-${questionIndex}`}
value={option}
name={`question-${question.id}`}
value={option.id}
checked={isSelected} checked={isSelected}
disabled={disabled} disabled={disabled}
onChange={() => setAnswerValue(question, questionIndex, option)}
onChange={() => setAnswerValue(question, option.id)}
className="sr-only" className="sr-only"
/> />
{!isShortOptions && ( {!isShortOptions && (
@ -86,9 +84,9 @@ export function QuestionRadio({
)} )}
</div> </div>
)} )}
{option.includes(" - ") ? (
{option.label.includes(" - ") ? (
(() => { (() => {
const parts = option.split(" - ");
const parts = option.label.split(" - ");
const title = parts[0]; const title = parts[0];
const description = parts.slice(1).join(" - "); const description = parts.slice(1).join(" - ");
return ( return (
@ -105,7 +103,7 @@ export function QuestionRadio({
); );
})() })()
) : ( ) : (
<span className="flex-1">{option}</span>
<span className="flex-1">{option.label}</span>
)} )}
</label> </label>
); );

29
src/components/Componentes/question-renderer.tsx

@ -1,6 +1,6 @@
"use client"; "use client";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import QuestionBirthplace from "./question-birthplace"; import QuestionBirthplace from "./question-birthplace";
import QuestionButton from "./question-button"; import QuestionButton from "./question-button";
import QuestionCheckbox from "./question-checkbox"; import QuestionCheckbox from "./question-checkbox";
@ -17,22 +17,17 @@ import QuestionTextarea from "./question-textarea";
type QuestionRendererProps = { type QuestionRendererProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
dobQuestion?: QuestionField; dobQuestion?: QuestionField;
dobQuestionIndex?: number;
}; };
export function QuestionRenderer({ export function QuestionRenderer({
question, question,
questionIndex,
disabled, disabled,
dobQuestion, dobQuestion,
dobQuestionIndex,
}: QuestionRendererProps) { }: QuestionRendererProps) {
const enTitle = (question.englishTitle || question.title).toLowerCase();
const compactTextHeight = const compactTextHeight =
enTitle.includes("email") || enTitle.includes("duration")
question.type === "email" || question.ui_config?.compact === true
? "h-[54px]" ? "h-[54px]"
: undefined; : undefined;
@ -41,7 +36,6 @@ export function QuestionRenderer({
return ( return (
<QuestionBirthplace <QuestionBirthplace
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -49,7 +43,6 @@ export function QuestionRenderer({
return ( return (
<QuestionButton <QuestionButton
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -57,7 +50,6 @@ export function QuestionRenderer({
return ( return (
<QuestionCheckbox <QuestionCheckbox
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -65,7 +57,6 @@ export function QuestionRenderer({
return ( return (
<QuestionDate <QuestionDate
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -73,7 +64,6 @@ export function QuestionRenderer({
return ( return (
<QuestionDropdown <QuestionDropdown
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -81,22 +71,18 @@ export function QuestionRenderer({
return ( return (
<QuestionFile <QuestionFile
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
case "number": case "number":
if ( if (
question.title === "Age" &&
dobQuestion &&
dobQuestionIndex !== undefined
question.ui_config?.derivedFromDob === true &&
dobQuestion
) { ) {
return ( return (
<QuestionNumber <QuestionNumber
question={question} question={question}
questionIndex={questionIndex}
derivedFromQuestion={dobQuestion} derivedFromQuestion={dobQuestion}
derivedFromQuestionIndex={dobQuestionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -105,7 +91,6 @@ export function QuestionRenderer({
return ( return (
<QuestionNumber <QuestionNumber
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -113,7 +98,6 @@ export function QuestionRenderer({
return ( return (
<QuestionPhone <QuestionPhone
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -121,7 +105,6 @@ export function QuestionRenderer({
return ( return (
<QuestionPhoto <QuestionPhoto
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -129,7 +112,6 @@ export function QuestionRenderer({
return ( return (
<QuestionRadio <QuestionRadio
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -138,7 +120,6 @@ export function QuestionRenderer({
return ( return (
<QuestionSlider <QuestionSlider
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );
@ -146,7 +127,6 @@ export function QuestionRenderer({
return ( return (
<QuestionText <QuestionText
question={question} question={question}
questionIndex={questionIndex}
heightClassName={compactTextHeight} heightClassName={compactTextHeight}
disabled={disabled} disabled={disabled}
/> />
@ -155,7 +135,6 @@ export function QuestionRenderer({
return ( return (
<QuestionTextarea <QuestionTextarea
question={question} question={question}
questionIndex={questionIndex}
disabled={disabled} disabled={disabled}
/> />
); );

22
src/components/Componentes/question-section-flow.tsx

@ -12,10 +12,8 @@ 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 type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import NoticeBox from "./notice-box"; import NoticeBox from "./notice-box";
import { getStoredAge } from "./progress-helper";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import AnswerPaceSheet from "@/app/questions-list/[slug]/answer-pace-sheet"; import AnswerPaceSheet from "@/app/questions-list/[slug]/answer-pace-sheet";
import { FixToTheEnd } from "./fix-to-the-end"; import { FixToTheEnd } from "./fix-to-the-end";
import Button from "./button"; import Button from "./button";
@ -56,7 +54,7 @@ function SectionFlowContent({
void flushAnswers({ force: true }); void flushAnswers({ force: true });
}, [flushAnswers]); }, [flushAnswers]);
const handleSubmit = useCallback(() => {
const handleSubmit = useCallback(async () => {
if (isSubmitting) { if (isSubmitting) {
return; return;
} }
@ -64,14 +62,14 @@ function SectionFlowContent({
try { try {
markFirstEntryCompleted(); markFirstEntryCompleted();
void flushAnswers({ force: true });
await flushAnswers({ force: true });
} catch { } catch {
// ignore // ignore
} finally {
triggerSilentReload(queryClient);
const target = localizePath(exitHref || "/questions-list", locale);
router.push(target);
} }
triggerSilentReload(queryClient);
const target = localizePath(exitHref || "/questions-list", locale);
router.push(target);
}, [exitHref, flushAnswers, locale, router, isSubmitting, queryClient]); }, [exitHref, flushAnswers, locale, router, isSubmitting, queryClient]);
const markOptionalQuestionsPassed = useCallback( const markOptionalQuestionsPassed = useCallback(
@ -85,13 +83,9 @@ function SectionFlowContent({
[markQuestionPassed, optionalQuestionIndexes], [markQuestionPassed, optionalQuestionIndexes],
); );
const { data: profile } = useMarriageProfileQuery();
const isFemale = profile?.gender === "female";
const age = getStoredAge();
const activeQuestion = questions?.[activeQuestionIndex]; const activeQuestion = questions?.[activeQuestionIndex];
const showNotice = activeQuestion?.showGuardianNotice; const showNotice = activeQuestion?.showGuardianNotice;
const isUnder27 =
age !== null ? isFemale && age < 27 : (activeQuestion?.required ?? false);
const isUnder27 = activeQuestion?.required ?? false;
return ( return (
<> <>

14
src/components/Componentes/question-slider.tsx

@ -1,27 +1,25 @@
"use client"; "use client";
import { useLayoutEffect, useRef, useState } from "react"; import { useLayoutEffect, useRef, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; 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";
type QuestionSliderProps = { type QuestionSliderProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
disabled?: boolean; disabled?: boolean;
}; };
export function QuestionSlider({ export function QuestionSlider({
question, question,
questionIndex,
disabled, disabled,
}: QuestionSliderProps) { }: QuestionSliderProps) {
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const [min, max] = question.extras.range; const [min, max] = question.extras.range;
const initialValue = Math.round((min + max) / 2); const initialValue = Math.round((min + max) / 2);
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const storedValue = getAnswerValue(question, questionIndex);
const storedValue = getAnswerValue(question);
const isDesiredAgeRange = false; const isDesiredAgeRange = false;
@ -115,12 +113,12 @@ export function QuestionSlider({
if (isDesiredAgeRange) { if (isDesiredAgeRange) {
const handleFromChange = (newFrom: number) => { const handleFromChange = (newFrom: number) => {
const val = Math.min(newFrom, toVal); const val = Math.min(newFrom, toVal);
setAnswerValue(question, questionIndex, `${val}-${toVal}`);
setAnswerValue(question, `${val}-${toVal}`);
}; };
const handleToChange = (newTo: number) => { const handleToChange = (newTo: number) => {
const val = Math.max(newTo, fromVal); const val = Math.max(newTo, fromVal);
setAnswerValue(question, questionIndex, `${fromVal}-${val}`);
setAnswerValue(question, `${fromVal}-${val}`);
}; };
const progressFrom = const progressFrom =
@ -246,9 +244,7 @@ export function QuestionSlider({
value={value} value={value}
onChange={(event) => onChange={(event) =>
setAnswerValue( setAnswerValue(
question,
questionIndex,
Number(event.target.value),
question, Number(event.target.value),
) )
} }
disabled={disabled} disabled={disabled}

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

@ -1,14 +1,13 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; 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";
type QuestionTextProps = { type QuestionTextProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
description?: string; description?: string;
disabled?: boolean; disabled?: boolean;
heightClassName?: string; heightClassName?: string;
@ -22,14 +21,13 @@ function toEnglishDigits(str: string): string {
export default function QuestionText({ export default function QuestionText({
question, question,
questionIndex,
description, description,
disabled, disabled,
heightClassName: _heightClassName, heightClassName: _heightClassName,
}: QuestionTextProps) { }: QuestionTextProps) {
const { dictionary: t } = useI18n();
const { dictionary: t, locale } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const value = getAnswerValue(question);
const isMuted = value === "-"; const isMuted = value === "-";
const [localValue, setLocalValue] = useState( const [localValue, setLocalValue] = useState(
@ -41,9 +39,7 @@ export default function QuestionText({
setLocalValue(isMuted ? "" : String(value ?? "")); setLocalValue(isMuted ? "" : String(value ?? ""));
}, [value, isMuted]); }, [value, isMuted]);
const enTitleLower = (question.englishTitle || question.title).toLowerCase();
const isNumericQuestion =
question.type === "number" || enTitleLower.includes("duration");
const isNumericQuestion = question.type === "number" || question.validation?.format === "number";
const handleChange = (val: string) => { const handleChange = (val: string) => {
let nextVal = val; let nextVal = val;
@ -58,7 +54,7 @@ export default function QuestionText({
} }
debounceTimerRef.current = setTimeout(() => { debounceTimerRef.current = setTimeout(() => {
setAnswerValue(question, questionIndex, nextVal);
setAnswerValue(question, nextVal);
}, 300); }, 300);
}; };
@ -66,13 +62,11 @@ export default function QuestionText({
if (debounceTimerRef.current) { if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current); clearTimeout(debounceTimerRef.current);
} }
setAnswerValue(question, questionIndex, localValue);
setAnswerValue(question, localValue);
}; };
const stringValue = localValue.trim(); const stringValue = localValue.trim();
const isEmailQuestion = (question.englishTitle || question.title)
.toLowerCase()
.includes("email");
const isEmailQuestion = question.type === "email" || question.validation?.format === "email";
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isValidEmail = !isEmailQuestion || emailRegex.test(stringValue); const isValidEmail = !isEmailQuestion || emailRegex.test(stringValue);
@ -83,9 +77,7 @@ export default function QuestionText({
? isValidEmail || stringValue.length === 0 ? isValidEmail || stringValue.length === 0
: isValidEmail && stringValue.length > 0; : isValidEmail && stringValue.length > 0;
const isMarjaQuestion =
(question.englishTitle || question.title) ===
"Marja' al-Taqlid (Religious Authority)";
const isMarjaQuestion = question.ui_config?.showNotPriorityCheckbox === true;
if (isEmailQuestion) { if (isEmailQuestion) {
return ( return (
@ -119,9 +111,10 @@ export default function QuestionText({
</div> </div>
{showInvalidState ? ( {showInvalidState ? (
<span className="block group-10 font-semibold text-[#F2465F]"> <span className="block group-10 font-semibold text-[#F2465F]">
{/[\u0600-\u06FF]/.test(question.title)
? "یک آدرس ایمیل معتبر وارد کنید."
: "Enter a valid email address."}
{question.validation?.errorMessage ||
(locale === "fa"
? "یک آدرس ایمیل معتبر وارد کنید."
: "Enter a valid email address.")}
</span> </span>
) : null} ) : null}
{description ? ( {description ? (
@ -172,9 +165,9 @@ export default function QuestionText({
clearTimeout(debounceTimerRef.current); clearTimeout(debounceTimerRef.current);
} }
if (e.target.checked) { if (e.target.checked) {
setAnswerValue(question, questionIndex, "-");
setAnswerValue(question, "-");
} else { } else {
setAnswerValue(question, questionIndex, "");
setAnswerValue(question, "");
} }
}} }}
className="h-[18px] w-[18px] shrink-0 rounded border-[#D0D5DD] text-[#F2465F] focus:ring-[#F2465F] accent-[#F2465F] cursor-pointer" className="h-[18px] w-[18px] shrink-0 rounded border-[#D0D5DD] text-[#F2465F] focus:ring-[#F2465F] accent-[#F2465F] cursor-pointer"

10
src/components/Componentes/question-textarea.tsx

@ -1,27 +1,25 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; 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";
type QuestionTextareaProps = { type QuestionTextareaProps = {
question: QuestionField; question: QuestionField;
questionIndex: number;
description?: string; description?: string;
disabled?: boolean; disabled?: boolean;
}; };
export function QuestionTextarea({ export function QuestionTextarea({
question, question,
questionIndex,
description, description,
disabled, disabled,
}: QuestionTextareaProps) { }: QuestionTextareaProps) {
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const value = getAnswerValue(question);
const [localValue, setLocalValue] = useState(String(value ?? "")); const [localValue, setLocalValue] = useState(String(value ?? ""));
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null); const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
@ -39,7 +37,7 @@ export function QuestionTextarea({
} }
debounceTimerRef.current = setTimeout(() => { debounceTimerRef.current = setTimeout(() => {
setAnswerValue(question, questionIndex, val);
setAnswerValue(question, val);
}, 300); }, 300);
}; };
@ -47,7 +45,7 @@ export function QuestionTextarea({
if (debounceTimerRef.current) { if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current); clearTimeout(debounceTimerRef.current);
} }
setAnswerValue(question, questionIndex, localValue);
setAnswerValue(question, localValue);
}; };
const stringValue = localValue.trim(); const stringValue = localValue.trim();

49
src/components/Componentes/question-title.tsx

@ -3,9 +3,8 @@
import { useState } from "react"; import { useState } from "react";
import { IoEyeOff } from "react-icons/io5"; import { IoEyeOff } from "react-icons/io5";
import HelpModal from "./help-modal"; import HelpModal from "./help-modal";
import type { QuestionField } from "@/data/question-data";
import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import pathMap from "@/translations/path_to_english.json";
type QuestionTitleProps = { type QuestionTitleProps = {
question: QuestionField; question: QuestionField;
@ -16,32 +15,18 @@ export function QuestionTitle({ question, className }: QuestionTitleProps) {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const [isHelpOpen, setIsHelpOpen] = useState(false); const [isHelpOpen, setIsHelpOpen] = useState(false);
const isMonthlyIncome =
question.title === "Monthly Income" ||
question.title === "میزان درآمد ماهانه";
let suffix = "";
if (question.ui_config?.suffix) {
suffix = ` ${question.ui_config.suffix}`;
} else if (question.ui_config?.suffix_i18n) {
suffix = ` ${question.ui_config.suffix_i18n[locale] || question.ui_config.suffix_i18n.en || ""}`;
} else if (question.ui_config?.showApproximateSuffix) {
suffix = locale === "fa" ? " (تقریبی)" : " (approximately)";
} else if (question.ui_config?.showYearsSuffix) {
suffix = locale === "fa" ? " (سال)" : " (Years)";
}
const isPreviousMarriageDuration =
question.title === "Previous Marriage Duration" ||
question.title === "مدت ازدواج یا عقد قبلی" ||
(question.englishTitle || question.title)
.toLowerCase()
.includes("marriage duration");
const alreadyHasUnit =
question.title.includes("Years") ||
question.title.includes("سال");
const titleText =
question.title +
(isMonthlyIncome
? locale === "fa"
? " (تقریبی)"
: " (approximately)"
: isPreviousMarriageDuration && !alreadyHasUnit
? locale === "fa"
? " (سال)"
: " (Years)"
: "");
const titleText = question.title + suffix;
const words = titleText.split(" "); const words = titleText.split(" ");
const lastWord = words[words.length - 1]; const lastWord = words[words.length - 1];
const remainingTitle = words.slice(0, -1).join(" "); const remainingTitle = words.slice(0, -1).join(" ");
@ -93,15 +78,7 @@ export function QuestionTitle({ question, className }: QuestionTitleProps) {
<HelpModal <HelpModal
isOpen={isHelpOpen} isOpen={isHelpOpen}
onClose={() => setIsHelpOpen(false)} onClose={() => setIsHelpOpen(false)}
description={(() => {
const fullPath = "questions." + question.tooltip;
const englishVal = (pathMap as Record<string, string>)[
fullPath
];
return englishVal
? (t as any)[englishVal] || englishVal
: question.tooltip;
})()}
description={question.tooltip}
/> />
</> </>
) : null} ) : null}

78
src/components/Componentes/required-steps-card.tsx

@ -2,16 +2,10 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { IoAlert, IoCheckmark } from "react-icons/io5"; import { IoAlert, IoCheckmark } from "react-icons/io5";
import { getLocalSectionProgress, getStoredAge } from "./progress-helper";
import {
getQuestionListItems,
isQuestionListItemVisibleForProfile,
type QuestionListItem,
} from "@/data/question-data";
import { toFrontendSlug } from "@/data/section-slug-map";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionsQuery } from "@/hooks/marriage/use-sections";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import type { QuestionListItem } from "@/lib/schema-adapter";
type RequiredStepsCardProps = { type RequiredStepsCardProps = {
items?: QuestionListItem[]; items?: QuestionListItem[];
@ -39,73 +33,19 @@ export default function RequiredStepsCard({
progressBySlug, progressBySlug,
}: RequiredStepsCardProps = {}) { }: RequiredStepsCardProps = {}) {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const { data: profile } = useMarriageProfileQuery();
const { data: sections } = useMarriageSectionsQuery();
const { data: schema } = useFormSchemaQuery("profile", locale);
const questionListItems = useMemo( const questionListItems = useMemo(
() =>
items ??
getQuestionListItems(locale).filter((item) =>
isQuestionListItemVisibleForProfile(item, {
gender: profile?.gender,
}),
),
[items, locale, profile?.gender],
() => items ?? convertSchemaToFrontendItems(schema, locale),
[items, schema, locale],
); );
const steps: RequiredStep[] = useMemo(() => { const steps: RequiredStep[] = useMemo(() => {
const age = getStoredAge();
type SectionType = NonNullable<typeof sections>[number];
const sectionMap = new Map<string, SectionType>();
sections?.forEach((s) => {
sectionMap.set(toFrontendSlug(s.slug), s);
sectionMap.set(s.slug, s);
});
return questionListItems.map((item) => { return questionListItems.map((item) => {
const localProgress = getLocalSectionProgress(item, profile, age);
const section = sectionMap.get(item.slug);
let progress = item.progress;
let progress = 0;
if (localProgress !== null) {
progress = localProgress;
} else if (
progressBySlug &&
typeof progressBySlug.get(item.slug) === "number"
) {
if (progressBySlug && typeof progressBySlug.get(item.slug) === "number") {
progress = progressBySlug.get(item.slug) ?? 0; progress = progressBySlug.get(item.slug) ?? 0;
} else if (item.slug === "family_marital_history" && sections) {
const fbSec = sections.find((s) => s.slug === "family_background");
const mhSec = sections.find((s) => s.slug === "marital_history");
if (fbSec || mhSec) {
const fbTotal = fbSec?.total_steps ?? 6;
const fbCurrent = fbSec
? Math.round((fbSec.completion_percent / 100) * fbTotal)
: 0;
const mhTotal = mhSec?.total_steps ?? 6;
const mhCurrent = mhSec
? Math.round((mhSec.completion_percent / 100) * mhTotal)
: 0;
progress =
fbTotal + mhTotal > 0
? Math.max(
0,
Math.min(
100,
Math.round(
((fbCurrent + mhCurrent) / (fbTotal + mhTotal)) * 100,
),
),
)
: 0;
}
} else if (section) {
progress = Math.max(
0,
Math.min(100, Math.round(section.completion_percent)),
);
} else {
progress = item.progress;
} }
return { return {
@ -114,7 +54,7 @@ export default function RequiredStepsCard({
progress, progress,
}; };
}); });
}, [questionListItems, progressBySlug, sections, profile]);
}, [questionListItems, progressBySlug]);
const { completed, total } = getRequiredStepStats(steps); const { completed, total } = getRequiredStepStats(steps);
const completion = total > 0 ? Math.round((completed / total) * 100) : 0; const completion = total > 0 ? Math.round((completed / total) * 100) : 0;

235
src/components/Componentes/schema-question-flow.integration.test.tsx

@ -0,0 +1,235 @@
import React from "react";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QuestionAnswersProvider, useQuestionAnswers } from "./question-answer-storage";
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter";
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useMarriageSectionDataQuery, useUpdateMarriageSectionDataMutation } from "@/hooks/marriage/use-section-data";
import { QuestionRadio } from "./question-radio";
import { QuestionCheckbox } from "./question-checkbox";
import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client";
vi.mock("@/hooks/marriage/use-form-schema", () => ({
useFormSchemaQuery: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: vi.fn(),
}));
vi.mock("@/hooks/marriage/use-section-data", () => ({
useMarriageSectionDataQuery: vi.fn(),
useUpdateMarriageSectionDataMutation: vi.fn(),
}));
export const replaceMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: (...args: any[]) => replaceMock(...args),
back: vi.fn(),
}),
}));
const mockSchema = {
form_id: "profile",
version: 1,
answers: {
"q_radio": { value: "A", option_id: "opt_a" },
"q_check": { value: ["B", "C"], option_id: ["opt_b", "opt_c"] }
},
sections: [
{
id: "sec1",
title: "Section",
order: 1,
icon: "user-circle",
is_required: true,
estimated_minutes: 5,
cards: [
{
id: "card1",
title: "Card",
order: 2, // Messed up order
questions: [
{
id: "q_child", title: "Child Q", type: "text", order: 20,
required: false, is_required: true, is_visible: false,
ui_config: {}, options: []
},
{
id: "q_radio", title: "Radio Q", type: "radio", order: 5,
required: true, is_required: true, is_visible: true,
ui_config: {},
options: [
{ id: "opt_a_dummy", value: "A_DUMMY", label: "Option A Dummy", order: 2 },
{ id: "opt_a", value: "A", label: "Option A Canonical", order: 1 }
]
},
{
id: "q_check", title: "Check Q", type: "checkbox", order: 10,
required: false, is_required: false, is_visible: true,
ui_config: {},
options: [
{ id: "opt_c", value: "C", label: "Option C", order: 2 },
{ id: "opt_b", value: "B", label: "Option B", order: 1 }
]
}
]
},
{
id: "card2",
title: "Card 2",
order: 1, // Card 2 should come first
questions: [
{ id: "q_first", title: "First Q", type: "text", order: 1, required: true, is_visible: true, ui_config: {}, options: [] },
]
}
]
}
],
progress: { sections_progress: {} }
};
describe("Schema Question Flow Integration", () => {
let capturedPayload: any = null;
beforeEach(() => {
capturedPayload = null;
const updateMutateAsync = vi.fn(async (payload) => {
capturedPayload = payload;
return payload;
});
(useUpdateMarriageSectionDataMutation as any).mockReturnValue({
mutateAsync: updateMutateAsync,
isPending: false,
});
(useMarriageProfileQuery as any).mockReturnValue({
data: { can_edit_profile: true },
});
(useMarriageSectionDataQuery as any).mockReturnValue({
data: {
slug: "sec1",
data: [
{ key: "q_radio", type: "radio", value: "A", option_id: "opt_a" },
{ key: "q_check", type: "checkbox", value: ["B", "C"], option_id: ["opt_b", "opt_c"] }
]
},
isLoading: false,
});
(useFormSchemaQuery as any).mockReturnValue({
data: mockSchema,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
});
});
afterEach(() => {
cleanup();
});
it("should enforce ordering, correctly hydrate canonical values via options, and hide invisible children", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
// Use convertSchemaToFrontendItems directly (no mock)
const frontendItems = convertSchemaToFrontendItems(mockSchema, "en");
// Sort logic validation
expect(frontendItems[0].questions[0].id).toBe("q_first");
expect(frontendItems[0].questions[1].id).toBe("q_radio");
expect(frontendItems[0].questions[2].id).toBe("q_check");
expect(frontendItems[0].questions[3].id).toBe("q_child");
// Validate options sort
expect(frontendItems[0].questions[1].options[0].id).toBe("opt_a");
render(
<QueryClientProvider client={queryClient}>
<QuestionDetailClient
itemSlug="sec1"
closeLabel="Close"
continueLabel="Continue"
description="Desc"
informationLabel="Info"
questionsListHref="/list"
title="Title"
/>
</QueryClientProvider>
);
// Wait for hydration and rendering
await waitFor(() => {
// Radio hydration
const radioInput = screen.getByLabelText("Option A Canonical") as HTMLInputElement;
expect(radioInput.checked).toBe(true);
// Checkbox hydration
const checkB = screen.getByLabelText("Option B") as HTMLInputElement;
const checkC = screen.getByLabelText("Option C") as HTMLInputElement;
expect(checkB.checked).toBe(true);
expect(checkC.checked).toBe(true);
// Child should not be rendered
expect(screen.queryByText("Child Q")).toBeNull();
// q_child is required=false but is_required=true, if it was visible it should show *.
// Let's modify schema dynamically to test child visibility and requirement
});
// Interact to fire payload
const radioDummy = screen.getByLabelText("Option A Dummy") as HTMLInputElement;
fireEvent.click(radioDummy);
// Uncheck Option B and Check Option C (Wait, they are both checked by default from hydration)
const checkB = screen.getByLabelText("Option B") as HTMLInputElement;
fireEvent.click(checkB); // Should now be unchecked
const submitBtn = screen.getByText("Continue"); // Form submit button
fireEvent.click(submitBtn);
await waitFor(() => {
expect(capturedPayload).not.toBeNull();
const radioPayload = capturedPayload.fields.find((f: any) => f.key === "q_radio");
expect(radioPayload.option_id).toBe("opt_a_dummy");
const checkPayload = capturedPayload.fields.find((f: any) => f.key === "q_check");
expect(checkPayload.option_id).toEqual(["opt_c"]); // Only C is checked now
});
});
it("should redirect and not render static questions on schema failure", () => {
replaceMock.mockClear();
(useFormSchemaQuery as any).mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: new Error("Network Error"),
refetch: vi.fn(),
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<QuestionDetailClient
itemSlug="sec1"
closeLabel="Close"
continueLabel="Continue"
description="Desc"
informationLabel="Info"
questionsListHref="/list"
title="Title"
/>
</QueryClientProvider>
);
// Should redirect to questions list
expect(replaceMock).toHaveBeenCalledWith("/list");
// Static text should not exist
expect(screen.queryByText("First Q")).toBeNull();
});
});

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

@ -0,0 +1,265 @@
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import SliderPage from './slider-page';
const mockMutateAsync = vi.fn();
const mockGetMarriageProfile = vi.fn();
const mockSetQueryData = vi.fn();
const mockReplace = vi.fn();
const mockRemoveItem = vi.spyOn(Storage.prototype, 'removeItem');
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
vi.mock('@/hooks/marriage/use-profile-basic', () => ({
useUpdateMarriageProfileBasicMutation: () => ({
mutateAsync: mockMutateAsync,
isPending: false,
}),
}));
vi.mock('@/hooks/marriage/use-profile-main', () => ({
getMarriageProfile: (...args: any[]) => mockGetMarriageProfile(...args),
useMarriageProfileQuery: () => ({ data: undefined, refetch: vi.fn() }),
}));
vi.mock('@/hooks/marriage/query-keys', () => ({
marriageQueryKeys: {
profile: () => ['marriage', 'profile'],
},
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
back: vi.fn(),
}),
}));
vi.mock('@/translations/provider', () => ({
useI18n: () => ({
locale: 'en',
dictionary: {
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again.",
"Accept & Continue": "Accept & Continue",
},
}),
}));
vi.mock('@/translations/config', () => ({
localizePath: (path: string, locale: string) => path,
}));
describe('SliderPage', () => {
let queryClient: QueryClient;
beforeEach(() => {
vi.resetAllMocks();
queryClient = new QueryClient();
vi.spyOn(queryClient, 'setQueryData');
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
const navigateToFinalSlide = async () => {
render(
<QueryClientProvider client={queryClient}>
<SliderPage />
</QueryClientProvider>
);
const dots = screen.getAllByRole('button', { name: /go to slide/i });
fireEvent.click(dots[4]); // Go directly to slide 5
return await screen.findByText('Finish');
};
it('a. Success: PATCH ok, GET ok -> replace', async () => {
const finishBtn = await navigateToFinalSlide();
const initialRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length;
let resolveGet: any;
mockMutateAsync.mockResolvedValueOnce({
status: 'pending_info',
gender: 'female',
is_registering_for_self: true
});
mockGetMarriageProfile.mockReturnValueOnce(new Promise(resolve => {
resolveGet = resolve;
}));
fireEvent.click(finishBtn);
// Wait for PATCH to resolve and GET to be called
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled();
expect(mockGetMarriageProfile).toHaveBeenCalled();
});
// Assert navigation and localStorage clear did not happen before GET resolves
expect(mockReplace).not.toHaveBeenCalled();
const currentRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length;
expect(currentRemoveCount).toBe(initialRemoveCount);
// Resolve GET
resolveGet({
status: 'pending_info',
gender: 'female',
is_registering_for_self: true
});
await waitFor(() => {
expect(queryClient.setQueryData).toHaveBeenCalled();
const finalRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length;
expect(finalRemoveCount).toBe(initialRemoveCount + 1);
expect(mockReplace).toHaveBeenCalledWith('/questions-list/personal_info');
});
});
it('b. PATCH failure: no nav, Error UI, Retry works', async () => {
const finishBtn = await navigateToFinalSlide();
const initialRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length;
mockMutateAsync.mockRejectedValueOnce(new Error('Network Error'));
fireEvent.click(finishBtn);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Failed to update profile basic details. Please try again.');
});
expect(mockReplace).not.toHaveBeenCalled();
const currentRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length;
expect(currentRemoveCount).toBe(initialRemoveCount);
// Retry succeeds
mockMutateAsync.mockResolvedValueOnce({
status: 'pending_info',
gender: 'female',
is_registering_for_self: true
});
mockGetMarriageProfile.mockResolvedValueOnce({
status: 'pending_info',
gender: 'female',
is_registering_for_self: true
});
const retryBtn = screen.getByText('Finish');
fireEvent.click(retryBtn);
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/questions-list/personal_info');
});
const finalRemoveCount = mockRemoveItem.mock.calls.filter(call => call[0] === 'marriage-slider-answers').length;
expect(finalRemoveCount).toBe(initialRemoveCount + 1);
});
it('c. Slow request: Promise pending -> no redirect', async () => {
const finishBtn = await navigateToFinalSlide();
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] });
let resolvePatch: any;
mockMutateAsync.mockReturnValueOnce(new Promise(resolve => {
resolvePatch = resolve;
}));
mockGetMarriageProfile.mockResolvedValueOnce({
status: 'pending_info',
gender: 'female',
is_registering_for_self: true
});
fireEvent.click(finishBtn);
await vi.advanceTimersByTimeAsync(6000);
expect(mockReplace).not.toHaveBeenCalled();
resolvePatch({
status: 'pending_info',
gender: 'female',
is_registering_for_self: true
});
vi.useRealTimers();
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/questions-list/personal_info');
});
});
it('d. Double-check failure: GET returns pending_onboarding -> Error UI', async () => {
const finishBtn = await navigateToFinalSlide();
mockMutateAsync.mockResolvedValueOnce({
status: 'pending_info',
gender: 'female',
is_registering_for_self: true
});
mockGetMarriageProfile.mockResolvedValueOnce({
status: 'pending_onboarding',
gender: 'female',
is_registering_for_self: true
});
fireEvent.click(finishBtn);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Failed to update profile basic details. Please try again.');
});
expect(mockReplace).not.toHaveBeenCalled();
});
it('e. Double click: only 1 request', async () => {
const finishBtn = await navigateToFinalSlide();
let resolvePatch: any;
mockMutateAsync.mockReturnValueOnce(new Promise(resolve => {
resolvePatch = resolve;
}));
mockGetMarriageProfile.mockResolvedValueOnce({
status: 'pending_info',
gender: 'female',
is_registering_for_self: true
});
fireEvent.click(finishBtn);
fireEvent.click(finishBtn);
fireEvent.click(finishBtn);
expect(mockMutateAsync).toHaveBeenCalledTimes(1);
resolvePatch({ status: 'pending_info', gender: 'female', is_registering_for_self: true });
await waitFor(() => {
expect(mockReplace).toHaveBeenCalled();
});
});
it('f. Double-check failure: GET returns waiting -> Error UI', async () => {
const finishBtn = await navigateToFinalSlide();
mockMutateAsync.mockResolvedValueOnce({
status: 'pending_info',
gender: 'female',
is_registering_for_self: true
});
mockGetMarriageProfile.mockResolvedValueOnce({
status: 'waiting',
gender: 'female',
is_registering_for_self: true
});
fireEvent.click(finishBtn);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Failed to update profile basic details. Please try again.');
});
expect(mockReplace).not.toHaveBeenCalled();
});
});

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

@ -3,6 +3,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { TouchEvent } from "react"; import type { TouchEvent } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useUpdateMarriageProfileBasicMutation } from "@/hooks/marriage/use-profile-basic"; import { useUpdateMarriageProfileBasicMutation } from "@/hooks/marriage/use-profile-basic";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
@ -29,6 +30,10 @@ export default function SliderPage() {
const [hasReadRules, setHasReadRules] = useState(false); const [hasReadRules, setHasReadRules] = useState(false);
const [touchStartX, setTouchStartX] = useState<number | null>(null); const [touchStartX, setTouchStartX] = useState<number | null>(null);
const updateProfileBasicMutation = useUpdateMarriageProfileBasicMutation(); const updateProfileBasicMutation = useUpdateMarriageProfileBasicMutation();
const queryClient = useQueryClient();
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { dictionary: t } = useI18n();
const hasFinalNotice = true; const hasFinalNotice = true;
const maxSlideIndex = FINAL_SLIDE_COUNT - 1; const maxSlideIndex = FINAL_SLIDE_COUNT - 1;
const displaySlideCount = FINAL_SLIDE_COUNT; const displaySlideCount = FINAL_SLIDE_COUNT;
@ -54,36 +59,51 @@ export default function SliderPage() {
}; };
const completeSlider = async () => { const completeSlider = async () => {
const navigateAway = () => {
if (typeof window !== "undefined") {
window.localStorage.removeItem(SLIDER_ANSWERS_STORAGE_KEY);
// Use hard navigation instead of router.push() to ensure the
// destination page loads with a fresh auth state. SPA (soft)
// navigation sometimes leaves stale query-cache / token state
// that causes the profile query to return 401 and the page to
// stay stuck on the loading spinner.
window.location.href = localizePath(
"/questions-list/personal_info",
locale,
);
return;
}
router.push(localizePath("/questions-list/personal_info", locale));
};
if (isSubmitting) return;
setIsSubmitting(true);
setSubmitError(null);
// Safety timeout — navigate after 5 seconds even if the request hangs
const timeout = setTimeout(navigateAway, 5000);
const genderPayload = selectedGender === "man" ? "male" : "female";
const isRegisteringPayload = selectedRegistration === "self";
try { try {
await updateProfileBasicMutation.mutateAsync({
gender: selectedGender === "man" ? "male" : "female",
is_registering_for_self: selectedRegistration === "self",
const patchResponse = await updateProfileBasicMutation.mutateAsync({
gender: genderPayload,
is_registering_for_self: isRegisteringPayload,
}); });
if (
patchResponse.status === "pending_onboarding" ||
patchResponse.gender !== genderPayload ||
patchResponse.is_registering_for_self !== isRegisteringPayload
) {
throw new Error("Invalid PATCH response");
}
const { getMarriageProfile } = await import("@/hooks/marriage/use-profile-main");
const freshProfile = await getMarriageProfile();
if (
freshProfile.status !== "pending_info" ||
freshProfile.gender !== genderPayload ||
freshProfile.is_registering_for_self !== isRegisteringPayload
) {
throw new Error("Invalid GET double-check response");
}
const { marriageQueryKeys } = await import("@/hooks/marriage/query-keys");
queryClient.setQueryData(marriageQueryKeys.profile(), freshProfile);
if (typeof window !== "undefined") {
window.localStorage.removeItem(SLIDER_ANSWERS_STORAGE_KEY);
}
router.replace(localizePath("/questions-list/personal_info", locale));
} catch (error) { } catch (error) {
console.warn("Failed to update profile basic details:", error);
console.error("Failed to complete onboarding:", error);
setSubmitError(t["Failed to update profile basic details. Please try again."] || "Failed to update profile basic details. Please try again.");
} finally { } finally {
clearTimeout(timeout);
navigateAway();
setIsSubmitting(false);
} }
}; };
@ -117,7 +137,16 @@ export default function SliderPage() {
<NavigationButton <NavigationButton
icon="back" icon="back"
iconLabel="Close slider" iconLabel="Close slider"
onClick={() => router.back()}
onClick={() => {
if (typeof window !== "undefined") {
try {
window.sessionStorage.setItem("skip_intro_redirect", "true");
} catch (e) {
console.warn("sessionStorage is not accessible:", e);
}
}
router.back();
}}
/> />
<nav <nav
aria-label={`Slide ${activeSlide + 1} of ${displaySlideCount}`} aria-label={`Slide ${activeSlide + 1} of ${displaySlideCount}`}
@ -184,11 +213,18 @@ export default function SliderPage() {
disabled={!hasReadRules} disabled={!hasReadRules}
/> />
) : activeSlide === maxSlideIndex ? ( ) : activeSlide === maxSlideIndex ? (
<SliderFinalActions
onBack={goToPreviousSlide}
isFinishing={updateProfileBasicMutation.isPending}
onFinish={completeSlider}
/>
<div className="flex flex-col gap-3">
{submitError && (
<div role="alert" aria-live="assertive" className="text-sm text-red-500 bg-red-50 p-3 rounded-xl border border-red-200">
{submitError}
</div>
)}
<SliderFinalActions
onBack={goToPreviousSlide}
isFinishing={isSubmitting}
onFinish={completeSlider}
/>
</div>
) : ( ) : (
<SliderStepActions <SliderStepActions
onBack={goToPreviousSlide} onBack={goToPreviousSlide}

17
src/components/Componentes/slider-slide-one.tsx

@ -442,9 +442,24 @@ export function SliderSlideOneActions({
onAccept, onAccept,
disabled, disabled,
}: SliderSlideOneActionsProps) { }: SliderSlideOneActionsProps) {
const handleBack = () => {
if (typeof window !== "undefined") {
try {
window.sessionStorage.setItem("skip_intro_redirect", "true");
} catch (e) {
console.warn("sessionStorage is not accessible:", e);
}
}
};
return ( return (
<div className="flex items-center gap-3 [&>a]:flex-1 [&>button]:flex-1"> <div className="flex items-center gap-3 [&>a]:flex-1 [&>button]:flex-1">
<Button variant="outlined" arrowDirection="left" href="/intro">
<Button
variant="outlined"
arrowDirection="left"
href="/intro"
onClick={handleBack}
>
Back Back
</Button> </Button>
<Button <Button

1
src/components/Componentes/test-questions-flow.tsx

@ -13,6 +13,7 @@ import StickyHeader from "./sticky-header";
import TestLoadingScreen from "./test-loading-screen"; import TestLoadingScreen from "./test-loading-screen";
export type QuestionOption = { export type QuestionOption = {
id: string;
label: string; label: string;
value: string | number; value: string | number;
}; };

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

@ -0,0 +1,114 @@
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QuestionAnswersProvider } from "./question-answer-storage";
import { QuestionBirthplace } from "./question-birthplace";
import QuestionNumber from "./question-number";
import QuestionText from "./question-text";
vi.mock("@/translations/provider", () => ({
useI18n: vi.fn(() => ({ locale: "fa", dictionary: {} })),
}));
describe("UI Config based behavior", () => {
afterEach(() => {
cleanup();
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
it("should trigger GeoIP only when ui_config.enable_geoip is true", () => {
// With totally random title but ui_config.enable_geoip = true
const qWithGeo = { id: "q1", title: "Random Title Here", type: "birthplace", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: {}, options: [], ui_config: { enable_geoip: true } } as any;
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qWithGeo]}>
<QuestionBirthplace question={qWithGeo} />
</QuestionAnswersProvider>
</QueryClientProvider>
);
// Auto button is present when GeoIP is active
expect(screen.getByText("خودکار")).toBeDefined();
// With title "residence" but no ui_config
const qWithoutGeo = { id: "q2", title: "residence test", type: "birthplace", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: {}, options: [], ui_config: {} } as any;
rerender(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qWithoutGeo]}>
<QuestionBirthplace question={qWithoutGeo} />
</QuestionAnswersProvider>
</QueryClientProvider>
);
expect(screen.queryByText("خودکار")).toBeNull();
});
it("should trigger currency behavior only when ui_config.currency_enabled is true", () => {
// Title is random, but currency_enabled is true
const qWithCurrency = { id: "q1", title: "Random Income", type: "number", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", range: [0, 0] }, options: [], ui_config: { currency_enabled: true } } as any;
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qWithCurrency]}>
<QuestionNumber question={qWithCurrency} />
</QuestionAnswersProvider>
</QueryClientProvider>
);
// Dropdown arrow should be rendered in currency mode (path is in SVG)
expect(screen.getByRole("img", { name: "Dropdown chevron" })).toBeDefined();
// Title is "Monthly Income", but currency_enabled is false
const qWithoutCurrency = { id: "q2", title: "Monthly Income", type: "number", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "", range: [0, 0] }, options: [], ui_config: {} } as any;
rerender(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qWithoutCurrency]}>
<QuestionNumber question={qWithoutCurrency} />
</QuestionAnswersProvider>
</QueryClientProvider>
);
expect(screen.queryByRole("img", { name: "Dropdown chevron" })).toBeNull();
});
it("should render error message from validation.errorMessage or fallback to locale", async () => {
// Title is random, type is email, custom error message
const qCustomError = { id: "q1", title: "Random Email", type: "email", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "" }, options: [], validation: { errorMessage: "Custom Backend Error" } } as any;
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qCustomError]}>
<QuestionText question={qCustomError} />
</QuestionAnswersProvider>
</QueryClientProvider>
);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: 'invalid_email' } });
await waitFor(() => {
expect(screen.getByText("Custom Backend Error")).toBeDefined();
});
const qFallbackError = { id: "q2", title: "Random Email", type: "email", order: 1, required: true, isVisible: true, description: "", tooltip: "", extras: { placeHolder: "" }, options: [], validation: {} } as any;
rerender(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[qFallbackError]}>
<QuestionText question={qFallbackError} />
</QuestionAnswersProvider>
</QueryClientProvider>
);
const input2 = screen.getByRole("textbox");
fireEvent.change(input2, { target: { value: 'invalid_email_again' } });
await waitFor(() => {
expect(screen.getByText("یک آدرس ایمیل معتبر وارد کنید.")).toBeDefined();
});
});
});

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

183
src/data/glasser-fallback.ts

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

345
src/data/question-data.ts

@ -1,345 +0,0 @@
import enQuestions from "@/data/questions/en.json";
import faQuestions from "@/data/questions/fa.json";
import type { MarriageGender } from "@/hooks/marriage/types";
import { defaultLocale, type Locale } from "@/translations/config";
import { getDictionary } from "@/translations/dictionaries";
export const bookingTerms = [
"You will be contacted by your consultant.",
"The call may start 10-15 minutes earlier or later than scheduled.",
"Make sure you are available and in a quiet place at least 10 minutes before the session.",
] as const;
export type QuestionCardIcon =
| "profile"
| "education"
| "details"
| "checklist"
| "contact"
| "family_marital";
type QuestionExtras = {
placeHolder: string;
range: [number, number];
options: string[];
noSearch?: boolean;
};
type QuestionAudienceRule = {
genders?: MarriageGender[];
maxAge?: number;
minAge?: number;
};
export type QuestionLogic = {
dependsOn: {
title: string;
values: string[];
};
};
export type QuestionField = {
title: string;
type: string;
required: boolean;
private?: boolean;
description: string;
tooltip: string;
extras: QuestionExtras;
audience?: QuestionAudienceRule;
requiredWhen?: QuestionAudienceRule;
logic?: QuestionLogic;
showGuardianNotice?: boolean;
originalSlug?: string;
originalIndex?: number;
englishTitle?: string;
};
export type QuestionListItem = {
slug: string;
title: string;
estimate: string;
progress: number;
icon: QuestionCardIcon;
required?: boolean;
note?: string;
showInfoBadge?: boolean;
summary: string;
checkpoints: readonly string[];
tooltip: string;
questions: readonly QuestionField[];
audience?: QuestionAudienceRule;
};
type RawQuestionListItem = {
title: string;
icon: string;
slug: string;
required?: boolean;
estimateTime: string;
tooltip: string;
progress: number;
description: string;
questions: QuestionField[];
audience?: QuestionAudienceRule;
};
const iconMap: Record<string, QuestionCardIcon> = {
"user-circle": "profile",
school: "education",
"heart-handshake": "details",
"file-text": "contact",
"layout-grid": "checklist",
};
const questionsByLocale: Record<string, RawQuestionListItem[]> = {
en: enQuestions as RawQuestionListItem[],
fa: faQuestions as RawQuestionListItem[],
};
function mapQuestionListItem(item: RawQuestionListItem): QuestionListItem {
return {
slug: item.slug,
title: item.title,
estimate: item.estimateTime,
progress: item.progress,
icon: iconMap[item.icon] ?? "details",
required: item.required,
showInfoBadge: Boolean(item.tooltip),
summary: item.description,
checkpoints: item.questions.map((question) => question.title),
tooltip: item.tooltip,
questions: item.questions,
};
}
export function getQuestionListItems(locale: Locale = defaultLocale) {
const rawItems =
questionsByLocale[locale] ??
questionsByLocale[defaultLocale] ??
questionsByLocale.en ??
[];
const enItems = questionsByLocale.en || [];
const mappedRawItems = rawItems.map((item) => {
const enItem = enItems.find((e) => e.slug === item.slug);
if (!enItem) return item;
return {
...item,
questions: item.questions.map((q, idx) => {
const enQ = enItem.questions[idx];
return {
...q,
englishTitle: enQ ? enQ.title : q.title,
};
}),
};
});
const items = mappedRawItems.map(mapQuestionListItem);
const fbIndex = items.findIndex((item) => item.slug === "family_background");
const mhIndex = items.findIndex(
(item) => item.slug === "marital_history_children",
);
if (fbIndex !== -1 && mhIndex !== -1) {
const fbItem = items[fbIndex];
const mhItem = items[mhIndex];
const dict = getDictionary(locale);
const mergedTitle =
(dict as any)["Family Background, Marital Status, and Children"] ||
"Family Background, Marital Status, and Children";
const mergedEstimate = (dict as any)["20 minutes"] || "20 minutes";
const fbQuestions = fbItem.questions.map((q, idx) => ({
...q,
originalSlug: "family_background",
originalIndex: idx,
}));
const mhQuestions = mhItem.questions.map((q, idx) => ({
...q,
originalSlug: "marital_history_children",
originalIndex: idx,
}));
const finalQuestions = [...fbQuestions, ...mhQuestions];
const mergedItem: QuestionListItem = {
slug: "family_marital_history",
title: mergedTitle,
estimate: mergedEstimate,
progress: 0,
icon: "family_marital",
required: fbItem.required || mhItem.required,
showInfoBadge: fbItem.showInfoBadge || mhItem.showInfoBadge,
summary: `${fbItem.summary}\n\n${mhItem.summary}`,
checkpoints: [...fbItem.checkpoints, ...mhItem.checkpoints],
tooltip: fbItem.tooltip || mhItem.tooltip,
questions: finalQuestions,
};
const newItems = [...items];
newItems[fbIndex] = mergedItem;
newItems.splice(mhIndex, 1);
// Reorder items: bring future_spouse_criteria and identity_verification above the tests
const personalityIndex = newItems.findIndex(
(item) => item.slug === "personality_test",
);
const spouseItem = newItems.find(
(item) => item.slug === "future_spouse_criteria",
);
const identityItem = newItems.find(
(item) => item.slug === "identity_verification",
);
if (personalityIndex !== -1 && (spouseItem || identityItem)) {
const filtered = newItems.filter(
(item) =>
item.slug !== "future_spouse_criteria" &&
item.slug !== "identity_verification",
);
const insertAt = filtered.findIndex(
(item) => item.slug === "personality_test",
);
if (insertAt !== -1) {
const result = [...filtered];
const itemsToInsert = [];
if (spouseItem) itemsToInsert.push(spouseItem);
if (identityItem) itemsToInsert.push(identityItem);
result.splice(insertAt, 0, ...itemsToInsert);
return result;
}
}
return newItems;
}
// Also handle reordering if fbIndex/mhIndex are not found (fallback)
const personalityIndex = items.findIndex(
(item) => item.slug === "personality_test",
);
const spouseItem = items.find(
(item) => item.slug === "future_spouse_criteria",
);
const identityItem = items.find(
(item) => item.slug === "identity_verification",
);
if (personalityIndex !== -1 && (spouseItem || identityItem)) {
const filtered = items.filter(
(item) =>
item.slug !== "future_spouse_criteria" &&
item.slug !== "identity_verification",
);
const insertAt = filtered.findIndex(
(item) => item.slug === "personality_test",
);
if (insertAt !== -1) {
const result = [...filtered];
const itemsToInsert = [];
if (spouseItem) itemsToInsert.push(spouseItem);
if (identityItem) itemsToInsert.push(identityItem);
result.splice(insertAt, 0, ...itemsToInsert);
return result;
}
}
return items;
}
export function getQuestionListItemBySlug(
slug: string,
locale: Locale = defaultLocale,
) {
return getQuestionListItems(locale).find((item) => item.slug === slug);
}
function matchesAudienceRule(
rule: QuestionAudienceRule | undefined,
profile: {
age?: number | null;
gender?: MarriageGender | null;
},
) {
if (!rule) {
return true;
}
if (
rule.genders?.length &&
profile.gender &&
!rule.genders.includes(profile.gender)
) {
return false;
}
if (
typeof rule.minAge === "number" &&
profile.age != null &&
profile.age < rule.minAge
) {
return false;
}
if (
typeof rule.maxAge === "number" &&
profile.age != null &&
profile.age > rule.maxAge
) {
return false;
}
return true;
}
export function isQuestionListItemVisibleForProfile(
item: QuestionListItem,
profile: {
age?: number | null;
gender?: MarriageGender | null;
},
) {
return matchesAudienceRule(item.audience, profile);
}
export function isQuestionVisibleForProfile(
question: QuestionField,
profile: {
age?: number | null;
gender?: MarriageGender | null;
},
) {
return matchesAudienceRule(question.audience, profile);
}
export function isQuestionRequiredForProfile(
question: QuestionField,
profile: {
age?: number | null;
gender?: MarriageGender | null;
},
) {
return (
question.required ||
(Boolean(question.requiredWhen) &&
matchesAudienceRule(question.requiredWhen, profile))
);
}
export function getRequiredQuestionsCount(
slug: string,
profile: {
age?: number | null;
gender?: MarriageGender | null;
},
locale: Locale = defaultLocale,
) {
const item = getQuestionListItemBySlug(slug, locale);
if (!item) return 0;
return item.questions.filter((q) => isQuestionRequiredForProfile(q, profile))
.length;
}

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

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

47
src/data/section-slug-map.ts

@ -1,47 +0,0 @@
/**
* Maps backend section slugs to their corresponding frontend question list slugs.
* Some backend sections aggregate multiple frontend question categories under a
* single slug, so these two naming systems don't always match 1-to-1.
*/
export const BACKEND_TO_FRONTEND_SLUG_MAP: Record<string, string> = {
personal_identity: "personal_info",
contact_residence: "contact_residence_family_communication",
appearance_health: "appearance_health_activity",
education_career: "education_career_economic_status",
family_background: "family_marital_history",
marital_history: "family_marital_history",
beliefs_lifestyle: "beliefs_lifestyle_boundaries",
spouse_criteria: "future_spouse_criteria",
documents_verification: "identity_verification",
};
/**
* Reverse map: frontend slug backend slug.
* Built automatically from BACKEND_TO_FRONTEND_SLUG_MAP.
*/
export const FRONTEND_TO_BACKEND_SLUG_MAP: Record<string, string> =
Object.fromEntries(
Object.entries(BACKEND_TO_FRONTEND_SLUG_MAP).map(([backend, frontend]) => [
frontend,
backend,
]),
);
/**
* Resolve a frontend question-list slug to the backend section slug
* the API expects. Falls back to the original slug when no mapping exists.
*/
export function toBackendSlug(frontendSlug: string): string {
if (frontendSlug === "marital_history_children") {
return "marital_history";
}
return FRONTEND_TO_BACKEND_SLUG_MAP[frontendSlug] ?? frontendSlug;
}
/**
* Resolve a backend section slug to the frontend question-list slug
* used in the JSON data. Falls back to the original slug.
*/
export function toFrontendSlug(backendSlug: string): string {
return BACKEND_TO_FRONTEND_SLUG_MAP[backendSlug] ?? backendSlug;
}

4
src/hooks/marriage/types.ts

@ -44,6 +44,7 @@ export type MarriageField = {
label: string; label: string;
type: string; type: string;
value: MarriageFieldValue; value: MarriageFieldValue;
option_id?: string | string[];
private?: boolean; private?: boolean;
}; };
@ -202,7 +203,7 @@ export type CattellQuestion = {
question_number: number; question_number: number;
text: string; text: string;
item_type?: string; item_type?: string;
options?: string[];
options: { id: string; label: string; value: string | number }[];
}; };
export type CattellQuestionsResponse = { export type CattellQuestionsResponse = {
@ -237,6 +238,7 @@ export type GlasserQuestion = {
text: string; text: string;
factor?: string; factor?: string;
factor_code: string; factor_code: string;
options: { id: string; label: string; value: string | number }[];
}; };
export type GlasserQuestionsResponse = { export type GlasserQuestionsResponse = {

9
src/hooks/marriage/use-form-schema.ts

@ -9,6 +9,7 @@ export interface FormOption {
id: string; id: string;
value: string; value: string;
label: string; label: string;
order: number;
} }
export interface FormQuestion { export interface FormQuestion {
@ -19,17 +20,20 @@ export interface FormQuestion {
tooltip: string; tooltip: string;
placeholder: string; placeholder: string;
required: boolean; required: boolean;
is_required?: boolean;
show_guardian_notice: boolean; show_guardian_notice: boolean;
validation: Record<string, any>; validation: Record<string, any>;
ui_config: Record<string, any>; ui_config: Record<string, any>;
logic: Record<string, any> | null; logic: Record<string, any> | null;
is_visible: boolean; is_visible: boolean;
order: number;
options: FormOption[]; options: FormOption[];
} }
export interface FormCard { export interface FormCard {
id: string; id: string;
title: string; title: string;
order: number;
questions: FormQuestion[]; questions: FormQuestion[];
} }
@ -38,6 +42,7 @@ export interface FormSection {
title: string; title: string;
icon: string; icon: string;
is_required: boolean; is_required: boolean;
order: number;
estimated_minutes: number; estimated_minutes: number;
cards: FormCard[]; cards: FormCard[];
} }
@ -52,7 +57,7 @@ export interface FormSchemaResponse {
form_id: string; form_id: string;
version: number; version: number;
sections: FormSection[]; sections: FormSection[];
answers: Record<string, { value: any; option_id: string }>;
answers: Record<string, { value: any; option_id?: string | string[] }>;
progress: { progress: {
current_step: number; current_step: number;
total_steps: number; total_steps: number;
@ -89,7 +94,7 @@ export interface SaveAnswersPayload {
answers: Array<{ answers: Array<{
question_id: string; question_id: string;
value: any; value: any;
option_id?: string;
option_id?: string | string[];
}>; }>;
} }

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

@ -1,8 +1,6 @@
"use client"; "use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getQuestionListItemBySlug } from "@/data/question-data";
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";
@ -14,42 +12,7 @@ import type {
import type { FormSchemaResponse } from "./use-form-schema"; import type { FormSchemaResponse } from "./use-form-schema";
function hashString(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash.toString(36);
}
function slugifyQuestionTitle(title: string) {
const slug = title
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
return slug || `field_${hashString(title)}`;
}
function getQuestionFieldKey(
title: string,
index: number,
englishTitle?: string,
) {
const finalTitle = englishTitle || title;
return `q${index + 1}_${slugifyQuestionTitle(finalTitle)}`;
}
function hasQuestionAnswerValue(value: unknown) {
if (value === null || value === undefined) {
return false;
}
if (typeof value === "string") {
return value.trim().length > 0;
}
return true;
}
export async function getMarriageSectionData( export async function getMarriageSectionData(
slug: string, slug: string,
@ -63,32 +26,11 @@ export async function getMarriageSectionData(
}; };
const lang = getClientCookie("HABIB_LANGUAGE") || getClientCookie("habib_language") || "en"; const lang = getClientCookie("HABIB_LANGUAGE") || getClientCookie("habib_language") || "en";
if (slug === "family_marital_history") {
const [fbData, mhData] = await Promise.all([
getMarriageSectionData("family_background"),
getMarriageSectionData("marital_history_children"),
]);
return {
slug: "family_marital_history",
data: [...(fbData.data || []), ...(mhData.data || [])],
current_step: fbData.current_step + mhData.current_step,
total_steps: fbData.total_steps + mhData.total_steps,
completion_percent:
fbData.total_steps + mhData.total_steps > 0
? ((fbData.current_step + mhData.current_step) /
(fbData.total_steps + mhData.total_steps)) *
100
: 0,
updated_at: fbData.updated_at || mhData.updated_at,
};
}
const backendSlug = toBackendSlug(slug);
const { data } = await http.get<any>( const { data } = await http.get<any>(
`/api/marriage/forms/profile/?lang=${lang}` `/api/marriage/forms/profile/?lang=${lang}`
); );
const sec = data.sections.find((s: any) => s.id === backendSlug);
const sec = data.sections.find((s: any) => s.id === slug);
if (!sec) { if (!sec) {
return { return {
slug, slug,
@ -114,7 +56,7 @@ export async function getMarriageSectionData(
}); });
}); });
const prog = data.progress.sections_progress[backendSlug] || {
const prog = data.progress.sections_progress[slug] || {
current_step: 0, current_step: 0,
total_steps: 0, total_steps: 0,
completion_percent: 0.0, completion_percent: 0.0,
@ -134,134 +76,6 @@ export async function updateMarriageSectionData(
slug: string, slug: string,
payload: UpdateMarriageSectionDataPayload, payload: UpdateMarriageSectionDataPayload,
) { ) {
if (slug === "family_marital_history") {
const mergedItemEn = getQuestionListItemBySlug(
"family_marital_history",
"en",
);
const mergedItemFa = getQuestionListItemBySlug(
"family_marital_history",
"fa",
);
const fbQuestionsEn =
mergedItemEn?.questions.filter(
(q) => q.originalSlug === "family_background",
) || [];
const mhQuestionsEn =
mergedItemEn?.questions.filter(
(q) => q.originalSlug === "marital_history_children",
) || [];
const fbQuestionsFa =
mergedItemFa?.questions.filter(
(q) => q.originalSlug === "family_background",
) || [];
const mhQuestionsFa =
mergedItemFa?.questions.filter(
(q) => q.originalSlug === "marital_history_children",
) || [];
const fbKeys = new Set([
...fbQuestionsEn.map((q, idx) =>
getQuestionFieldKey(q.title, q.originalIndex ?? idx, q.englishTitle),
),
...fbQuestionsFa.map((q, idx) =>
getQuestionFieldKey(q.title, q.originalIndex ?? idx, q.englishTitle),
),
]);
const mhKeys = new Set([
...mhQuestionsEn.map((q, idx) =>
getQuestionFieldKey(q.title, q.originalIndex ?? idx, q.englishTitle),
),
...mhQuestionsFa.map((q, idx) =>
getQuestionFieldKey(q.title, q.originalIndex ?? idx, q.englishTitle),
),
]);
const fbFields = payload.fields.filter((f) => fbKeys.has(f.key));
const mhFields = payload.fields.filter((f) => mhKeys.has(f.key));
const fbRequiredQuestions = fbQuestionsEn.filter(
(q) => q.required && !q.logic?.dependsOn,
);
const mhRequiredQuestions = mhQuestionsEn.filter(
(q) => q.required && !q.logic?.dependsOn,
);
const fbRequiredCount = fbRequiredQuestions.length;
const mhRequiredCount = mhRequiredQuestions.length;
const fbCurrentStep = fbQuestionsEn.filter((q, idx) => {
if (!q.required || q.logic?.dependsOn) return false;
const keyEn = getQuestionFieldKey(
q.title,
q.originalIndex ?? idx,
q.englishTitle,
);
const keyFa = getQuestionFieldKey(
fbQuestionsFa[idx]?.title || q.title,
fbQuestionsFa[idx]?.originalIndex ?? q.originalIndex ?? idx,
fbQuestionsFa[idx]?.englishTitle || q.englishTitle,
);
const field = fbFields.find((f) => f.key === keyEn || f.key === keyFa);
return field && hasQuestionAnswerValue(field.value);
}).length;
const mhCurrentStep = mhQuestionsEn.filter((q, idx) => {
if (!q.required || q.logic?.dependsOn) return false;
const keyEn = getQuestionFieldKey(
q.title,
q.originalIndex ?? idx,
q.englishTitle,
);
const keyFa = getQuestionFieldKey(
mhQuestionsFa[idx]?.title || q.title,
mhQuestionsFa[idx]?.originalIndex ?? q.originalIndex ?? idx,
mhQuestionsFa[idx]?.englishTitle || q.englishTitle,
);
const field = mhFields.find((f) => f.key === keyEn || f.key === keyFa);
return field && hasQuestionAnswerValue(field.value);
}).length;
const answersPayload = payload.fields.map((f) => ({
question_id: f.key,
value: f.value,
option_id: (f as any).option_id || undefined,
}));
const { data } = await http.put<FormSchemaResponse>(
`/api/marriage/forms/profile/answers/`,
{
answers: answersPayload,
}
);
const fbProg = data.progress.sections_progress["family_background"] || {
current_step: 0,
total_steps: 0,
completion_percent: 0.0,
};
const mhProg = data.progress.sections_progress["marital_history"] || {
current_step: 0,
total_steps: 0,
completion_percent: 0.0,
};
return {
slug: "family_marital_history",
data: payload.fields,
current_step: fbProg.current_step + mhProg.current_step,
total_steps: fbProg.total_steps + mhProg.total_steps,
completion_percent:
fbProg.total_steps + mhProg.total_steps > 0
? ((fbProg.current_step + mhProg.current_step) /
(fbProg.total_steps + mhProg.total_steps)) *
100
: 0,
updated_at: new Date().toISOString(),
};
}
const answersPayload = payload.fields.map((f) => ({ const answersPayload = payload.fields.map((f) => ({
question_id: f.key, question_id: f.key,
value: f.value, value: f.value,
@ -275,8 +89,7 @@ export async function updateMarriageSectionData(
} }
); );
const backendSlug = toBackendSlug(slug);
const prog = data.progress.sections_progress[backendSlug] || {
const prog = data.progress.sections_progress[slug] || {
current_step: 0, current_step: 0,
total_steps: 0, total_steps: 0,
completion_percent: 0.0, completion_percent: 0.0,
@ -329,6 +142,9 @@ export function useUpdateMarriageSectionDataMutation(
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: marriageQueryKeys.sectionData(slug), queryKey: marriageQueryKeys.sectionData(slug),
}), }),
queryClient.invalidateQueries({
queryKey: ["marriage", "form-schema"],
}),
]); ]);
await options?.onSuccess?.(data, variables, onMutateResult, context); await options?.onSuccess?.(data, variables, onMutateResult, context);
}, },

27
src/lib/auth-bridge.ts

@ -49,7 +49,8 @@ class AuthBridge {
const coinsCookie = const coinsCookie =
getClientCookie(COINS_COOKIE_NAME) ?? getClientCookie("habib_coins"); getClientCookie(COINS_COOKIE_NAME) ?? getClientCookie("habib_coins");
if (!token) {
if (!token || token === "NO_TOKEN" || token.trim() === "") {
this.token = null;
return false; return false;
} }
@ -232,12 +233,12 @@ class AuthBridge {
getClientCookie("habib_token") ?? getClientCookie("habib_token") ??
sessionToken; sessionToken;
// Treat "NO_TOKEN" the same as having no cookie at all — fall through
// to the default token instead of returning null.
const effectiveCookie = const effectiveCookie =
cookieToken && cookieToken !== "NO_TOKEN" ? cookieToken : null;
cookieToken && cookieToken !== "NO_TOKEN" && cookieToken.trim() !== ""
? cookieToken
: null;
if (this.token) {
if (this.token && this.token !== "NO_TOKEN" && this.token.trim() !== "") {
return this.token; return this.token;
} }
@ -245,10 +246,16 @@ class AuthBridge {
return effectiveCookie; return effectiveCookie;
} }
return (
process.env.NEXT_PUBLIC_DEFAULT_TOKEN ??
"f3a7543b44ef0a713d1ee0d4f7866b3825cf1308"
);
const isDevelopment = process.env.NODE_ENV !== "production";
if (
isDevelopment &&
process.env.NEXT_PUBLIC_DEFAULT_TOKEN &&
process.env.NEXT_PUBLIC_DEFAULT_TOKEN !== "NO_TOKEN"
) {
return process.env.NEXT_PUBLIC_DEFAULT_TOKEN;
}
return null;
} }
public getCoins(): number { public getCoins(): number {
@ -257,7 +264,7 @@ class AuthBridge {
public isAuthenticated(): boolean { public isAuthenticated(): boolean {
const token = this.getToken(); const token = this.getToken();
return !!token && token !== "NO_TOKEN";
return !!token && token !== "NO_TOKEN" && token.trim() !== "";
} }
} }

88
src/lib/get-submit-path.test.ts

@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getSubmitPath } from './get-submit-path';
import * as firstEntryHelper from './first-entry-helper';
import * as matchStartGrace from './match-start-grace';
import type { MarriageProfileResponse } from '@/hooks/marriage/types';
vi.mock('./first-entry-helper', () => ({
isFirstEntryCompleted: vi.fn(),
}));
vi.mock('./match-start-grace', () => ({
clearLegacyMatchSubmittedFlag: vi.fn(),
isWithinMatchStartGrace: vi.fn(),
}));
describe('getSubmitPath', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(firstEntryHelper.isFirstEntryCompleted).mockReturnValue(true);
vi.mocked(matchStartGrace.isWithinMatchStartGrace).mockReturnValue(false);
});
const baseProfile: MarriageProfileResponse = {
status: 'pending_onboarding',
id: 1,
gender: 'male',
is_registering_for_self: true,
};
it('pending_onboarding returns /terms', () => {
expect(getSubmitPath({ ...baseProfile, status: 'pending_onboarding' })).toBe('/terms');
});
it('waiting returns /finding-match', () => {
expect(getSubmitPath({ ...baseProfile, status: 'waiting' })).toBe('/finding-match');
});
it('pending_info returns questions path', () => {
expect(getSubmitPath({ ...baseProfile, status: 'pending_info' })).toBe('/questions-list');
vi.mocked(firstEntryHelper.isFirstEntryCompleted).mockReturnValue(false);
expect(getSubmitPath({ ...baseProfile, status: 'pending_info' })).toBe('/questions-list/personal_info');
});
it('matched returns /request-accepted', () => {
expect(getSubmitPath({ ...baseProfile, status: 'matched' })).toBe('/request-accepted');
});
describe('active_case priority', () => {
it('returns /new-match when status="pending_info" but active_case is introduced and action pending', () => {
const profileWithCase = {
...baseProfile,
status: 'pending_info' as const,
active_case: {
status: 'introduced' as const,
my_action: 'pending' as const,
},
};
// Type assertion added because the mock profile object is lacking many properties of the full MarriageProfileResponse,
// but it contains everything needed for the logic being tested.
expect(getSubmitPath(profileWithCase as any)).toBe('/new-match');
});
it('returns /request-accepted when status="pending_info" but active_case is payment_done', () => {
const profileWithCase = {
...baseProfile,
status: 'pending_info' as const,
active_case: {
status: 'payment_done' as const,
my_action: 'pending' as const,
},
};
expect(getSubmitPath(profileWithCase as any)).toBe('/request-accepted');
});
it('returns /request-sent when status="waiting" but active_case is male_accepted and action done', () => {
const profileWithCase = {
...baseProfile,
status: 'waiting' as const,
active_case: {
status: 'male_accepted' as const,
my_action: 'done' as const,
},
};
expect(getSubmitPath(profileWithCase as any)).toBe('/request-sent');
});
});
});

144
src/lib/schema-adapter.ts

@ -0,0 +1,144 @@
import type {
FormSchemaResponse,
FormSection,
FormQuestion,
} from "@/hooks/marriage/use-form-schema";
import { defaultLocale, type Locale } from "@/translations/config";
export type QuestionCardIcon =
| "profile"
| "education"
| "details"
| "checklist"
| "contact"
| "family_marital";
export type QuestionExtras = {
placeHolder: string;
range: [number, number];
options: string[];
noSearch?: boolean;
};
export type QuestionAudienceRule = {
genders?: string[];
maxAge?: number;
minAge?: number;
};
export type QuestionField = {
id: string;
title: string;
type: string;
order: number;
required: boolean;
baseRequired: boolean;
isVisible: boolean;
private?: boolean;
description: string;
tooltip: string;
validation?: any;
ui_config?: any;
extras: QuestionExtras;
audience?: QuestionAudienceRule;
requiredWhen?: QuestionAudienceRule;
showGuardianNotice?: boolean;
options: { id: string; value: string | number; label: string; order: number }[];
};
export type QuestionListItem = {
slug: string;
title: string;
estimate: string;
progress: number;
icon: QuestionCardIcon;
required: boolean;
showInfoBadge?: boolean;
summary: string;
checkpoints: string[];
tooltip?: string;
note?: string;
questions: QuestionField[];
};
const iconMap: Record<string, QuestionCardIcon> = {
"user-circle": "profile",
school: "education",
"heart-handshake": "details",
"file-text": "contact",
"layout-grid": "checklist",
};
export function mapBackendQuestionToFrontend(bq: FormQuestion, index: number): QuestionField {
return {
id: bq.id,
title: bq.title || "Untitled",
type: bq.type,
order: bq.order !== undefined ? bq.order : index,
required: bq.is_required !== undefined ? bq.is_required : bq.required,
baseRequired: bq.required,
isVisible: bq.is_visible,
private: bq.ui_config?.private,
validation: bq.validation,
ui_config: bq.ui_config,
description: bq.description || "",
tooltip: bq.tooltip || "",
extras: {
placeHolder: bq.placeholder || "",
options: bq.options?.map((o) => o.label) || [],
range: bq.ui_config?.range || [0, 0],
noSearch: bq.ui_config?.noSearch,
},
showGuardianNotice: bq.show_guardian_notice,
options: [...(bq.options || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
};
}
export function mapBackendSectionToFrontend(
section: FormSection,
progress: number
): QuestionListItem {
const allQuestions: QuestionField[] = [];
let index = 0;
const cards = [...(section.cards || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
cards.forEach((card) => {
const questions = [...(card.questions || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
questions.forEach((q) => {
const fq = mapBackendQuestionToFrontend(q, index);
allQuestions.push(fq);
index++;
});
});
return {
slug: section.id,
title: section.title,
estimate: section.estimated_minutes ? `${section.estimated_minutes} min` : "5 min",
progress: progress,
icon: iconMap[section.icon] ?? "details",
required: section.is_required,
showInfoBadge: false,
summary: "",
checkpoints: allQuestions.map((q) => q.title),
tooltip: "",
questions: allQuestions,
};
}
export function convertSchemaToFrontendItems(
schema: FormSchemaResponse | undefined,
locale: Locale = defaultLocale
): QuestionListItem[] {
if (!schema) return [];
const rawItems = schema.sections.map((sec) => {
const progInfo = schema.progress?.sections_progress?.[sec.id];
const progress = progInfo ? progInfo.completion_percent : 0;
return mapBackendSectionToFrontend(sec, progress);
});
return rawItems.sort((a, b) => (schema.sections.find(s => s.id === a.slug)?.order ?? 0) - (schema.sections.find(s => s.id === b.slug)?.order ?? 0));
}

5
src/translations/locales/ar.json

@ -753,5 +753,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/az.json

@ -753,5 +753,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/bn.json

@ -753,5 +753,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/da.json

@ -753,5 +753,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/de.json

@ -753,5 +753,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

6
src/translations/locales/en.json

@ -760,7 +760,6 @@
"⚠️ This section is completely confidential and is only used for matching and review by advisors.": "⚠️ This section is completely confidential and is only used for matching and review by advisors.", "⚠️ This section is completely confidential and is only used for matching and review by advisors.": "⚠️ This section is completely confidential and is only used for matching and review by advisors.",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Marriage advisors": "Marriage advisors", "Marriage advisors": "Marriage advisors",
"Dr. Hasti Masoudi": "Dr. Hasti Masoudi",
"Specialist in clinical psychology": "Specialist in clinical psychology", "Specialist in clinical psychology": "Specialist in clinical psychology",
"Personal Development": "Personal Development", "Personal Development": "Personal Development",
"Family Counselor": "Family Counselor", "Family Counselor": "Family Counselor",
@ -804,5 +803,6 @@
"Improving Mutual Understanding": "Improving Mutual Understanding", "Improving Mutual Understanding": "Improving Mutual Understanding",
"Helps individuals better understand their own needs as well as their partner’s motivations, preferences, and emotional priorities.": "Helps individuals better understand their own needs as well as their partner’s motivations, preferences, and emotional priorities.", "Helps individuals better understand their own needs as well as their partner’s motivations, preferences, and emotional priorities.": "Helps individuals better understand their own needs as well as their partner’s motivations, preferences, and emotional priorities.",
"Supporting More Suitable Match Recommendations": "Supporting More Suitable Match Recommendations", "Supporting More Suitable Match Recommendations": "Supporting More Suitable Match Recommendations",
"Combining needs and personality assessments with interviews and other marriage criteria can help make partner recommendations more personalized and well-matched.": "Combining needs and personality assessments with interviews and other marriage criteria can help make partner recommendations more personalized and well-matched."
}
"Combining needs and personality assessments with interviews and other marriage criteria can help make partner recommendations more personalized and well-matched.": "Combining needs and personality assessments with interviews and other marriage criteria can help make partner recommendations more personalized and well-matched.",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/es.json

@ -753,5 +753,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

6
src/translations/locales/fa.json

@ -760,7 +760,6 @@
"⚠️ This section is completely confidential and is only used for matching and review by advisors.": "⚠️ این بخش کاملاً محرمانه است و فقط برای مچینگ و بررسی کارشناسان استفاده میشود.", "⚠️ This section is completely confidential and is only used for matching and review by advisors.": "⚠️ این بخش کاملاً محرمانه است و فقط برای مچینگ و بررسی کارشناسان استفاده میشود.",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Marriage advisors": "مشاوران ازدواج", "Marriage advisors": "مشاوران ازدواج",
"Dr. Hasti Masoudi": "دکتر هستی مسعودی",
"Specialist in clinical psychology": "متخصص روانشناسی بالینی", "Specialist in clinical psychology": "متخصص روانشناسی بالینی",
"Personal Development": "رشد فردی", "Personal Development": "رشد فردی",
"Family Counselor": "مشاور خانواده", "Family Counselor": "مشاور خانواده",
@ -804,5 +803,6 @@
"Improving Mutual Understanding": "بهبود درک متقابل", "Improving Mutual Understanding": "بهبود درک متقابل",
"Helps individuals better understand their own needs as well as their partner’s motivations, preferences, and emotional priorities.": "به افراد کمک می‌کند تا نیازهای خود و همچنین انگیزه‌ها، ترجیحات و اولویت‌های عاطفی شریک زندگی خود را بهتر درک کنند.", "Helps individuals better understand their own needs as well as their partner’s motivations, preferences, and emotional priorities.": "به افراد کمک می‌کند تا نیازهای خود و همچنین انگیزه‌ها، ترجیحات و اولویت‌های عاطفی شریک زندگی خود را بهتر درک کنند.",
"Supporting More Suitable Match Recommendations": "پشتیبانی از پیشنهادهای همسریابی مناسب‌تر", "Supporting More Suitable Match Recommendations": "پشتیبانی از پیشنهادهای همسریابی مناسب‌تر",
"Combining needs and personality assessments with interviews and other marriage criteria can help make partner recommendations more personalized and well-matched.": "ترکیب ارزیابی نیازها و شخصیت با مصاحبه‌ها و سایر معیارهای ازدواج می‌تواند به شخصی‌سازی و هماهنگی بیشتر پیشنهادهای شریک زندگی کمک کند."
}
"Combining needs and personality assessments with interviews and other marriage criteria can help make partner recommendations more personalized and well-matched.": "ترکیب ارزیابی نیازها و شخصیت با مصاحبه‌ها و سایر معیارهای ازدواج می‌تواند به شخصی‌سازی و هماهنگی بیشتر پیشنهادهای شریک زندگی کمک کند.",
"Failed to update profile basic details. Please try again.": "به‌روزرسانی اطلاعات اولیه پروفایل ناموفق بود. لطفاً دوباره تلاش کنید."
}

5
src/translations/locales/fr.json

@ -753,5 +753,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/gu.json

@ -753,5 +753,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/ha.json

@ -753,5 +753,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/he.json

@ -279,5 +279,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/hi.json

@ -753,5 +753,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/id.json

@ -279,5 +279,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/ks.json

@ -279,5 +279,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/pt.json

@ -279,5 +279,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/ru.json

@ -758,5 +758,6 @@
"{completed} of {total} required steps completed": "Выполнено {completed} из {total} требуемых шагов", "{completed} of {total} required steps completed": "Выполнено {completed} из {total} требуемых шагов",
"{days} days remaining of your subscription.": "{days} дней до конца вашей подписки.", "{days} days remaining of your subscription.": "{days} дней до конца вашей подписки.",
"⚠️ This section is completely confidential and is only used for matching and review by advisors.": "⚠️ Этот раздел полностью конфиденциальен и используется только консультантами для сопоставления и проверки.", "⚠️ This section is completely confidential and is only used for matching and review by advisors.": "⚠️ Этот раздел полностью конфиденциальен и используется только консультантами для сопоставления и проверки.",
"Please select the reason for cancellation:": "Пожалуйста, выберите причину отмены:"
}
"Please select the reason for cancellation:": "Пожалуйста, выберите причину отмены:",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/sw.json

@ -279,5 +279,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/tg.json

@ -279,5 +279,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/tr.json

@ -279,5 +279,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/ul.json

@ -279,5 +279,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/ur.json

@ -279,5 +279,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/uz.json

@ -279,5 +279,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}

5
src/translations/locales/zh.json

@ -753,5 +753,6 @@
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet", "We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
"Please select the reason for cancellation:": "Please select the reason for cancellation:", "Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Swipe to continue": "Swipe to continue", "Swipe to continue": "Swipe to continue",
"Swipe to confirm cancellation": "Swipe to confirm cancellation"
}
"Swipe to confirm cancellation": "Swipe to confirm cancellation",
"Failed to update profile basic details. Please try again.": "Failed to update profile basic details. Please try again."
}
Loading…
Cancel
Save