Compare commits

...

12 Commits
master ... Dev

  1. 1
      api_questions.json
  2. 1
      api_questions_marital.json
  3. 234
      conditional-rules.js
  4. 77
      public/assets/images/diamond-color.svg
  5. 5
      src/app/api/proxy/route.ts
  6. 617
      src/app/new-match/new-match-client.tsx
  7. 88
      src/app/questions-list/[slug]/question-detail-client.tsx
  8. 58
      src/app/questions-list/questions-list-client.tsx
  9. 23
      src/app/questions-list/sections-request.tsx
  10. 49
      src/app/request-accepted/request-accepted-client.tsx
  11. 31
      src/app/request-sent/request-sent-client.tsx
  12. 48
      src/components/Componentes/advisor-actions-card.tsx
  13. 111
      src/components/Componentes/information-sheet.tsx
  14. 6
      src/components/Componentes/navigation-button.tsx
  15. 18
      src/components/Componentes/page-header.tsx
  16. 8
      src/components/Componentes/question-answer-storage.tsx
  17. 224
      src/components/Componentes/question-file.test.tsx
  18. 50
      src/components/Componentes/question-sheet.test.tsx
  19. 96
      src/components/Componentes/question-sheet.tsx
  20. 19
      src/components/Componentes/swipe-button.tsx
  21. 67
      src/components/Componentes/test-completed-sheet.test.tsx
  22. 61
      src/components/Componentes/test-completed-sheet.tsx
  23. 92
      src/components/Componentes/test-exit-sheet.test.tsx
  24. 78
      src/components/Componentes/test-exit-sheet.tsx
  25. 154
      src/components/Componentes/test-questions-flow.tsx
  26. 2
      src/hooks/marriage/use-form-schema.ts
  27. 25
      src/lib/auth-bridge.ts
  28. 639
      src/lib/conditional-rules.test.ts
  29. 41
      src/lib/conditional-rules.ts
  30. 66
      src/lib/schema-adapter.ts
  31. 7
      src/translations/locales/ar.json
  32. 7
      src/translations/locales/az.json
  33. 7
      src/translations/locales/bn.json
  34. 7
      src/translations/locales/da.json
  35. 7
      src/translations/locales/de.json
  36. 21
      src/translations/locales/en.json
  37. 7
      src/translations/locales/es.json
  38. 20
      src/translations/locales/fa.json
  39. 7
      src/translations/locales/fr.json
  40. 7
      src/translations/locales/gu.json
  41. 7
      src/translations/locales/ha.json
  42. 7
      src/translations/locales/he.json
  43. 7
      src/translations/locales/hi.json
  44. 7
      src/translations/locales/id.json
  45. 7
      src/translations/locales/ks.json
  46. 7
      src/translations/locales/pt.json
  47. 7
      src/translations/locales/ru.json
  48. 7
      src/translations/locales/sw.json
  49. 7
      src/translations/locales/tg.json
  50. 7
      src/translations/locales/tr.json
  51. 7
      src/translations/locales/ul.json
  52. 7
      src/translations/locales/ur.json
  53. 7
      src/translations/locales/uz.json
  54. 7
      src/translations/locales/zh.json
  55. 47
      test-all.js
  56. 29
      test-cond.js
  57. 32
      test-cond2.js
  58. 36
      test-cond3.js
  59. 26
      test-empty.js

1
api_questions.json
File diff suppressed because it is too large
View File

1
api_questions_marital.json
File diff suppressed because it is too large
View File

234
conditional-rules.js

@ -0,0 +1,234 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.canonicalRule = canonicalRule;
exports.isAnswerPresent = isAnswerPresent;
exports.matchesAudience = matchesAudience;
exports.ruleMatches = ruleMatches;
exports.isQuestionVisible = isQuestionVisible;
exports.isQuestionRequired = isQuestionRequired;
function canonicalRule(rule) {
if (!rule || typeof rule !== "object") {
return null;
}
// If wrapped in dependsOn (legacy)
if (rule.dependsOn && !rule.parent_question_id) {
return {
dependsOn: rule.dependsOn,
};
}
var operator = ["any_of", "all_of", "equals", "exists"].includes(rule.operator)
? rule.operator
: "any_of";
var optionIds = [];
if (Array.isArray(rule.trigger_option_ids)) {
optionIds = rule.trigger_option_ids.map(String);
}
else if (rule.trigger_option_ids !== undefined && rule.trigger_option_ids !== null) {
optionIds = [String(rule.trigger_option_ids)];
}
var result = {
parent_question_id: rule.parent_question_id || rule.parentQuestionId,
trigger_option_ids: optionIds,
operator: operator,
clear_answer_when_hidden: Boolean(rule.clear_answer_when_hidden),
};
if (rule.audience && typeof rule.audience === "object") {
result.audience = rule.audience;
}
if (Array.isArray(rule.conditions)) {
result.conditions = rule.conditions
.map(canonicalRule)
.filter(function (c) { return c !== null; });
result.conditions_operator =
rule.conditions_operator === "any_of" ? "any_of" : "all_of";
result.root_operator =
rule.root_operator === "any_of" ? "any_of" : "all_of";
}
return result;
}
function isAnswerPresent(answer) {
if (answer === undefined || answer === null) {
return false;
}
var val = typeof answer === "object" && "value" in answer ? answer.value : answer;
if (val === undefined || val === null) {
return false;
}
if (typeof val === "string") {
return val.trim().length > 0;
}
if (Array.isArray(val)) {
return val.length > 0;
}
if (typeof val === "object") {
return Object.keys(val).length > 0;
}
return true;
}
function getSelectedOptionTokens(answer) {
var tokens = new Set();
if (!answer)
return tokens;
var rawOptionId = typeof answer === "object" && "option_id" in answer
? answer.option_id
: undefined;
var rawValue = typeof answer === "object" && "value" in answer ? answer.value : answer;
var addToken = function (item) {
if (item === undefined || item === null)
return;
var str = String(item).trim();
if (!str)
return;
tokens.add(str.toLowerCase());
// If it has a dot prefix like "sec.q.opt", also add the suffix "opt"
var lastDot = str.lastIndexOf(".");
if (lastDot !== -1 && lastDot < str.length - 1) {
tokens.add(str.slice(lastDot + 1).toLowerCase());
}
};
if (Array.isArray(rawOptionId)) {
rawOptionId.forEach(addToken);
}
else if (rawOptionId !== undefined) {
addToken(rawOptionId);
}
if (Array.isArray(rawValue)) {
rawValue.forEach(addToken);
}
else if (rawValue !== undefined) {
addToken(rawValue);
}
return tokens;
}
function matchesAudience(audience, context) {
if (!audience || typeof audience !== "object") {
return true;
}
if (audience.genders && audience.genders.length > 0) {
if (!context || !context.gender || !audience.genders.map(function(g) { return g.toLowerCase(); }).includes(context.gender.toLowerCase())) {
return false;
}
}
if (audience.minAge !== undefined && (context === null || context === void 0 ? void 0 : context.age) !== undefined && context.age !== null) {
if (context.age < audience.minAge) {
return false;
}
}
if (audience.maxAge !== undefined && (context === null || context === void 0 ? void 0 : context.age) !== undefined && context.age !== null) {
if (context.age > audience.maxAge) {
return false;
}
}
return true;
}
function ruleMatches(rawRule, answers, context) {
var rule = canonicalRule(rawRule);
if (!rule) {
return true;
}
if (rule.audience && !matchesAudience(rule.audience, context)) {
return false;
}
// Handle legacy dependsOn
if (rule.dependsOn && rule.dependsOn.key) {
var parentId_1 = rule.dependsOn.key;
var answer = answers[parentId_1];
if (!isAnswerPresent(answer)) {
return false;
}
var expectedValues = (rule.dependsOn.values || []).map(function (v) {
return String(v).toLowerCase().trim();
});
var actualTokens = getSelectedOptionTokens(answer);
var hasMatch = expectedValues.some(function (v) { return actualTokens.has(v); });
return hasMatch;
}
var mainMatches = true;
var parentId = rule.parent_question_id;
if (parentId) {
var answer = answers[parentId];
var operator = rule.operator || "any_of";
if (operator === "exists") {
mainMatches = isAnswerPresent(answer);
}
else if (!isAnswerPresent(answer)) {
mainMatches = false;
}
else {
var actualTokens_1 = getSelectedOptionTokens(answer);
var expectedIds = (rule.trigger_option_ids || []).map(function (id) {
return String(id).toLowerCase().trim();
});
var isTokenMatched = function (expectedId) {
if (actualTokens_1.has(expectedId))
return true;
var lastDot = expectedId.lastIndexOf(".");
if (lastDot !== -1 && lastDot < expectedId.length - 1) {
var suffix = expectedId.slice(lastDot + 1);
if (actualTokens_1.has(suffix))
return true;
}
return false;
};
if (operator === "all_of") {
mainMatches = expectedIds.length > 0 && expectedIds.every(isTokenMatched);
}
else if (operator === "equals") {
mainMatches =
expectedIds.length > 0 &&
expectedIds.every(isTokenMatched) &&
actualTokens_1.size <= expectedIds.length * 2;
}
else {
// default: "any_of"
mainMatches = expectedIds.some(isTokenMatched);
}
}
}
if (rule.conditions && rule.conditions.length > 0) {
var subMatches = rule.conditions.map(function (cond) {
return ruleMatches(cond, answers, context);
});
var condOperator = rule.conditions_operator || "all_of";
var conditionsResult = condOperator === "any_of"
? subMatches.some(Boolean)
: subMatches.every(Boolean);
if (!parentId) {
return conditionsResult;
}
var rootOp = rule.root_operator || "all_of";
return rootOp === "any_of"
? mainMatches || conditionsResult
: mainMatches && conditionsResult;
}
return parentId ? mainMatches : Boolean(!rule.audience || matchesAudience(rule.audience, context));
}
function isQuestionVisible(question, answers, context) {
// 1. Audience check
if (question.audience && !matchesAudience(question.audience, context)) {
return false;
}
// 2. Canonical visibility / conditional rule
var rule = question.visibility ||
question.conditionalRule ||
question.logic;
if (rule) {
return ruleMatches(rule, answers, context);
}
if (question.isVisible !== undefined) {
return question.isVisible;
}
return true;
}
function isQuestionRequired(question, answers, context) {
if (!isQuestionVisible(question, answers, context)) {
return false;
}
if (question.requiredWhen) {
if (question.requiredWhen.genders || question.requiredWhen.minAge !== undefined || question.requiredWhen.maxAge !== undefined) {
return matchesAudience(question.requiredWhen, context);
}
return ruleMatches(question.requiredWhen, answers, context);
}
return Boolean(question.baseRequired !== undefined ? question.baseRequired : question.required);
}

77
public/assets/images/diamond-color.svg

@ -0,0 +1,77 @@
<svg width="64" height="64" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Facet Gradients matching the exact vibrant spectrum -->
<linearGradient id="g_top_left" x1="6" y1="42" x2="22" y2="18" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#00C2FF"/>
<stop offset="100%" stop-color="#6366F1"/>
</linearGradient>
<linearGradient id="g_top_mid_left" x1="22" y1="18" x2="32" y2="42" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#8B5CF6"/>
<stop offset="100%" stop-color="#D946EF"/>
</linearGradient>
<linearGradient id="g_top_center" x1="32" y1="42" x2="68" y2="18" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#C084FC"/>
<stop offset="50%" stop-color="#F472B6"/>
<stop offset="100%" stop-color="#FDA4AF"/>
</linearGradient>
<linearGradient id="g_top_mid_right" x1="50" y1="18" x2="78" y2="42" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#F43F5E"/>
<stop offset="100%" stop-color="#FB7185"/>
</linearGradient>
<linearGradient id="g_top_right" x1="68" y1="42" x2="94" y2="42" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#FB7185"/>
<stop offset="100%" stop-color="#FB923C"/>
</linearGradient>
<linearGradient id="g_bot_left" x1="6" y1="42" x2="50" y2="88" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#00BAFF"/>
<stop offset="60%" stop-color="#3B82F6"/>
<stop offset="100%" stop-color="#6366F1"/>
</linearGradient>
<linearGradient id="g_bot_mid_left" x1="32" y1="42" x2="50" y2="88" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#6366F1"/>
<stop offset="50%" stop-color="#8B5CF6"/>
<stop offset="100%" stop-color="#A855F7"/>
</linearGradient>
<linearGradient id="g_bot_mid_right" x1="50" y1="42" x2="50" y2="88" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#EC4899"/>
<stop offset="60%" stop-color="#D946EF"/>
<stop offset="100%" stop-color="#A855F7"/>
</linearGradient>
<linearGradient id="g_bot_right" x1="94" y1="42" x2="50" y2="88" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#F43F5E"/>
<stop offset="50%" stop-color="#FB7185"/>
<stop offset="100%" stop-color="#FB923C"/>
</linearGradient>
<filter id="subtle_glow" x="0" y="0" width="100" height="100" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs>
<g stroke="white" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round">
<!-- Top Row Facets -->
<polygon points="22,18 6,42 32,42" fill="url(#g_top_left)" />
<polygon points="22,18 50,18 32,42" fill="url(#g_top_mid_left)" />
<polygon points="32,42 50,18 68,42" fill="url(#g_top_center)" />
<polygon points="50,18 78,18 68,42" fill="url(#g_top_mid_right)" />
<polygon points="78,18 68,42 94,42" fill="url(#g_top_right)" />
<!-- Bottom Row Facets -->
<polygon points="6,42 32,42 50,88" fill="url(#g_bot_left)" />
<polygon points="32,42 50,42 50,88" fill="url(#g_bot_mid_left)" />
<polygon points="50,42 68,42 50,88" fill="url(#g_bot_mid_right)" />
<polygon points="68,42 94,42 50,88" fill="url(#g_bot_right)" />
</g>
<!-- Outer highlight border for extra crispness -->
<polygon points="22,18 78,18 94,42 50,88 6,42" fill="none" stroke="white" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round" />
</svg>

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

@ -108,7 +108,10 @@ function getCookieValue(cookieHeader: string, name: string) {
function getRequestHeaders(request: NextRequest, targetUrl: URL) {
const headers = new Headers();
const authKey = process.env.NEXT_PUBLIC_AUTH_KEY;
const authKey =
process.env.NEXT_PUBLIC_AUTH_KEY ||
process.env.NEXT_PUBLIC_DEFAULT_TOKEN ||
process.env.DEFAULT_TOKEN;
const cookieHeader = request.headers.get("cookie") ?? "";
for (const header of REQUEST_HEADERS_TO_FORWARD) {

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

@ -5,7 +5,17 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { FaLock } from "react-icons/fa6";
import {
FaCalendarDays,
FaLocationDot,
FaGraduationCap,
FaBookOpen,
FaBriefcase,
FaStar,
FaLock,
} from "react-icons/fa6";
import { LuEye } from "react-icons/lu";
import { IoHeartOutline } from "react-icons/io5";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import MarriageAdvisorsOverlay, {
useMarriageAdvisorsOverlay,
@ -16,6 +26,7 @@ import MatchProfileOverlay, {
import PageHeader from "@/components/Componentes/page-header";
import { PageBackground } from "@/components/Componentes/page-background";
import InformationSheet from "@/components/Componentes/information-sheet";
import SwipeButton from "@/components/Componentes/swipe-button";
import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton";
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot";
import { DiscountWidget } from "@/components/Componentes/discount-widget";
@ -56,6 +67,24 @@ const fieldCandidateMatchers = {
"q1_full_name",
],
age: ["age", "date_of_birth", "birth_date", "dob", "birth_year"],
currentCountry: [
"country_of_current_residence",
"current_country",
"residence_country",
"country",
"current_residence_country",
],
currentCity: [
"city_state_of_current_residence",
"city_of_current_residence",
"current_city",
"residence_city",
"city",
"city_state",
"state_city",
"state",
"province",
],
residence: [
"current_residence",
"residence",
@ -65,8 +94,7 @@ const fieldCandidateMatchers = {
"residence_city",
"city",
"country",
"birth_city",
"birthplace",
"current_country",
],
educationLevel: [
"highest_level_of_education",
@ -79,38 +107,21 @@ const fieldCandidateMatchers = {
"study_field",
"study",
],
job: [
jobTitle: [
"job_title",
"job_title_and_description",
"job_position",
"employment_status",
"job",
"occupation",
"profession",
"career",
"employment_status",
"work",
],
hobbies: [
"your_hobbies_and_main_interests",
"hobbies_and_interests",
"hobbies",
"interests",
"your_personality_traits",
"personality_traits",
],
maritalStatus: [
"current_marital_status",
"marital_status",
"maritalstatus",
"relationship_status",
],
cityPreference: [
"willingness_to_relocate",
"city_preference",
"citypreference",
"preferred_city",
"preferred_location",
"future_residence",
"residence_preference_after_marriage",
],
} as const;
@ -225,29 +236,28 @@ function useMatchSummaryDisplay(
const fields = matchSummary?.public_info ?? [];
const usedIndexes = new Set<number>();
// 1. Name: Combine first_name and last_name if available, or find general name
// 1. Name: Show only first_name (do not show last_name)
let displayName: string | null = null;
const firstNameIdx = fields.findIndex(
(f) =>
f.key === "personal_identity.first_name" ||
f.key?.endsWith(".first_name"),
f.key?.endsWith(".first_name") ||
f.key === "first_name",
);
const lastNameIdx = fields.findIndex(
(f) =>
f.key === "personal_identity.last_name" ||
f.key?.endsWith(".last_name"),
f.key?.endsWith(".last_name") ||
f.key === "last_name",
);
if (firstNameIdx !== -1 && fields[firstNameIdx].value) {
usedIndexes.add(firstNameIdx);
const firstName = formatFieldValue(fields[firstNameIdx].value);
if (lastNameIdx !== -1 && fields[lastNameIdx].value) {
if (lastNameIdx !== -1) {
usedIndexes.add(lastNameIdx);
const lastName = formatFieldValue(fields[lastNameIdx].value);
displayName = `${firstName} ${lastName}`.trim();
} else {
displayName = firstName;
}
if (firstNameIdx !== -1 && fields[firstNameIdx].value) {
usedIndexes.add(firstNameIdx);
displayName = formatFieldValue(fields[firstNameIdx].value);
} else {
const nameField = pickField(
fields,
@ -256,7 +266,8 @@ function useMatchSummaryDisplay(
t,
);
if (nameField) {
displayName = nameField.value;
const rawName = String(nameField.value).trim();
displayName = rawName.split(/\s+/)[0] || rawName;
}
}
@ -288,107 +299,193 @@ function useMatchSummaryDisplay(
age = pickField(fields, fieldCandidateMatchers.age, usedIndexes, t);
}
// 3. Country / City / Residence
const residence = pickField(
// 3. Country of Current Residence
let currentCountry = pickField(
fields,
fieldCandidateMatchers.residence,
fieldCandidateMatchers.currentCountry,
usedIndexes,
t,
);
// 4. Highest level of education
const educationLevel = pickField(
// 4. City / State of Current Residence
let currentCity = pickField(
fields,
fieldCandidateMatchers.educationLevel,
fieldCandidateMatchers.currentCity,
usedIndexes,
t,
);
// 5. Field of study
const fieldOfStudy = pickField(
fields,
fieldCandidateMatchers.fieldOfStudy,
usedIndexes,
t,
// If either country or city was not matched as a standalone field, check composite residence
if (!currentCountry || !currentCity) {
const residenceIdx = fields.findIndex(
(f, idx) =>
!usedIndexes.has(idx) &&
matchesCandidate(f, fieldCandidateMatchers.residence),
);
// 6. Job / Occupation
const job = pickField(
if (residenceIdx !== -1) {
const residenceField = fields[residenceIdx];
if (
typeof residenceField.value === "object" &&
residenceField.value !== null &&
!Array.isArray(residenceField.value)
) {
const valObj = residenceField.value as {
country?: string;
city?: string;
state?: string;
};
if (valObj.country && !currentCountry) {
currentCountry = {
id: `${residenceField.key}.country`,
label:
(t &&
(t["Current Country of Residence"] ||
t["Country of Residence"] ||
t["Country"])) ||
"Country of Residence",
value: formatOptionValue(valObj.country, t) || valObj.country,
};
}
const cityVal = [valObj.city, valObj.state].filter(Boolean).join(", ");
if (cityVal && !currentCity) {
currentCity = {
id: `${residenceField.key}.city`,
label:
(t &&
(t["Current City / State of Residence"] ||
t["City / State of Residence"] ||
t["City"])) ||
"City / State of Residence",
value: formatOptionValue(cityVal, t) || cityVal,
};
}
usedIndexes.add(residenceIdx);
} else if (!currentCountry && !currentCity) {
const disp = toDisplayField(residenceField, t);
if (disp) {
usedIndexes.add(residenceIdx);
currentCountry = disp;
}
}
}
}
// 5. Highest level of education
const educationLevel = pickField(
fields,
fieldCandidateMatchers.job,
fieldCandidateMatchers.educationLevel,
usedIndexes,
t,
);
// 7. Hobbies & Interests
const hobbies = pickField(
// 6. Field of study
const fieldOfStudy = pickField(
fields,
fieldCandidateMatchers.hobbies,
fieldCandidateMatchers.fieldOfStudy,
usedIndexes,
t,
);
// 8. Marital Status
const maritalStatus = pickField(
// 7. Job Title
const jobTitle = pickField(
fields,
fieldCandidateMatchers.maritalStatus,
fieldCandidateMatchers.jobTitle,
usedIndexes,
t,
);
// 9. City Preference / Relocation
const cityPreference = pickField(
// 8. Hobbies & Main Interests
const hobbies = pickField(
fields,
fieldCandidateMatchers.cityPreference,
fieldCandidateMatchers.hobbies,
usedIndexes,
t,
);
const extraFields = fields
.filter((_, index) => !usedIndexes.has(index))
.map((f) => toDisplayField(f, t))
.filter((field): field is DisplayField => Boolean(field))
.slice(0, 4);
if (typeof window !== "undefined") {
console.log(
"🔍 [useMatchSummaryDisplay] computed 8 fields output:",
{
displayName,
age,
residence,
educationLevel,
fieldOfStudy,
job,
hobbies,
maritalStatus,
cityPreference,
extraFieldsCount: extraFields.length,
},
);
}
return {
name: displayName,
age,
residence,
currentCountry,
currentCity,
educationLevel,
fieldOfStudy,
job,
jobTitle,
hobbies,
maritalStatus,
cityPreference,
name: displayName,
extraFields,
};
}, [matchSummary, t]);
}
function FieldLine({ field }: { field: DisplayField }) {
function ProfileInfoItem({
icon,
field,
}: {
icon: React.ReactNode;
field: DisplayField;
}) {
return (
<p className="break-words text-[11px] leading-[1.65] font-medium text-white">
<span className="font-semibold">{field.label}: </span>
<span>{field.value}</span>
</p>
<div
className="
flex
items-center
gap-3
rounded-[14px]
border
border-[#FCE2E6]
bg-white
px-3.5
py-2.5
min-h-[52px]
"
>
<div
className="
flex
h-[38px]
w-[38px]
shrink-0
items-center
justify-center
rounded-full
bg-[#FFF0F2]
text-[#F0445B]
text-[16px]
"
>
{icon}
</div>
<div className="flex min-w-0 flex-1 flex-col text-start">
<span
className="
text-[12px]
leading-tight
font-bold
text-[#1E293B]
truncate
"
>
{field.label}
</span>
<span
title={typeof field.value === "string" ? field.value : undefined}
className="
mt-0.5
text-[12px]
leading-[1.35]
font-semibold
text-[#F0445B]
line-clamp-2
overflow-hidden
text-ellipsis
break-words
"
>
{field.value}
</span>
</div>
</div>
);
}
@ -401,6 +498,7 @@ export default function NewMatchClient() {
const { isProfileOpen, openProfile, closeProfile } = useMatchProfileOverlay();
const { data: profile, isError, isLoading } = useMarriageProfileQuery();
const [isPaymentSheetOpen, setIsPaymentSheetOpen] = useState(false);
const [isDeclineConfirmOpen, setIsDeclineConfirmOpen] = useState(false);
const [paymentError, setPaymentError] = useState<string | null>(null);
const [isInsufficientCoins, setIsInsufficientCoins] = useState(false);
@ -468,6 +566,16 @@ export default function NewMatchClient() {
}
};
const openDeclineConfirm = () => {
setIsPaymentSheetOpen(false);
setIsDeclineConfirmOpen(true);
};
const cancelDeclineConfirm = () => {
setIsDeclineConfirmOpen(false);
setIsPaymentSheetOpen(true);
};
useEffect(() => {
console.log("🔍 [NewMatchClient] Current React Query Profile State:", {
isLoading,
@ -522,57 +630,59 @@ export default function NewMatchClient() {
<PageBackground />
<main
style={{
paddingBottom: `${profile?.can_edit_profile === false ? 76 + bottom : 16 + bottom}px`,
paddingTop: `${Math.max(12, top + 4)}px`,
}}
className="-mx-[17px] flex h-dvh flex-col overflow-hidden px-4 text-center"
className="-mx-[17px] flex min-h-dvh flex-col px-[18px] text-center"
>
<div className="shrink-0">
<div className="shrink-0 mb-1">
<PageHeader profile={profile} />
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain flex flex-col justify-between py-1">
<div
style={{
paddingBottom: `${profile?.can_edit_profile === false ? 84 + bottom : 20 + bottom}px`,
}}
className="min-h-0 flex-1 flex flex-col gap-3.5 py-1"
>
{/* 1. Header Section Skeleton */}
<section className="flex flex-col items-center shrink-0">
<LoadingSkeleton className="h-[60px] w-[60px] rounded-full" />
<LoadingSkeleton className="h-6 w-[220px] mt-2.5 sm:mt-3" />
<LoadingSkeleton className="h-4 w-[280px] mt-2" />
<LoadingSkeleton className="h-4 w-[240px] mt-1" />
<LoadingSkeleton className="h-[56px] w-[56px] rounded-full" />
<LoadingSkeleton className="h-6 w-[200px] mt-2.5" />
<LoadingSkeleton className="h-3.5 w-[260px] mt-1.5" />
</section>
{/* 2. Match Card Skeleton */}
<section className="w-full rounded-[15px] border border-white/80 bg-white px-[17px] pt-[16px] pb-[16px] shadow-[0_18px_45px_rgba(15,23,42,0.06)] flex flex-col items-center shrink-0">
<section className="w-full rounded-[16px] border border-white/80 bg-white px-4 py-4 shadow-none flex flex-col items-center shrink-0">
{/* Name line */}
<LoadingSkeleton className="h-5 w-[140px]" />
<LoadingSkeleton className="h-4 w-[130px]" />
{/* Subtitle / Details lines */}
<div className="mt-2 w-full flex flex-col items-center gap-2 min-h-[40px]">
<div className="mt-2.5 w-full flex flex-col items-center gap-2 min-h-[40px]">
<LoadingSkeleton className="h-3 w-[180px]" />
<LoadingSkeleton className="h-3 w-[150px]" />
</div>
{/* Button skeleton */}
<LoadingSkeleton className="mt-3 h-[40px] w-full rounded-[10px]" />
<LoadingSkeleton className="mt-3.5 h-[40px] w-full rounded-[10px]" />
</section>
{/* 3. Advisor Card Skeleton */}
<section className="w-full shrink-0">
<div className="rounded-[13px] border border-white/80 bg-white px-3 py-3.5 text-left shadow-[0_18px_45px_rgba(15,23,42,0.06)] backdrop-blur-sm">
<LoadingSkeleton className="h-[16px] w-[140px]" />
<div className="mt-2 space-y-1">
<LoadingSkeleton className="h-[10px] w-[260px]" />
<LoadingSkeleton className="h-[10px] w-[180px]" />
<div className="rounded-[14px] border border-white/80 bg-white/75 px-4 py-3.5 text-left shadow-none backdrop-blur-sm">
<LoadingSkeleton className="h-4 w-[120px]" />
<div className="mt-1.5 space-y-1">
<LoadingSkeleton className="h-2.5 w-[240px]" />
<LoadingSkeleton className="h-2.5 w-[160px]" />
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<div className="mt-3.5 flex items-center justify-between gap-3">
<div className="flex items-center pl-1">
<LoadingSkeleton className="h-[30px] w-[30px] rounded-full border-2 border-white" />
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" />
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" />
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" />
<LoadingSkeleton className="h-[28px] w-[28px] rounded-full border-2 border-white" />
<LoadingSkeleton className="-ml-1.5 h-[28px] w-[28px] rounded-full border-2 border-white" />
<LoadingSkeleton className="-ml-1.5 h-[28px] w-[28px] rounded-full border-2 border-white" />
</div>
<LoadingSkeleton className="h-[38px] w-[120px] rounded-[9px]" />
<LoadingSkeleton className="h-[36px] w-[100px] rounded-[9px]" />
</div>
</div>
</section>
@ -582,16 +692,6 @@ export default function NewMatchClient() {
);
}
const pairedPersonalFields = [
matchDisplay.age,
matchDisplay.residence,
].filter((field): field is DisplayField => Boolean(field));
const pairedEduFields = [
matchDisplay.educationLevel,
matchDisplay.fieldOfStudy,
].filter((field): field is DisplayField => Boolean(field));
const isFemaleProfile = profile?.gender === "female";
const matchHeadingTitle = isFemaleProfile
? t["New Marriage Proposal"]
@ -617,33 +717,37 @@ export default function NewMatchClient() {
<main
style={{
paddingBottom: `${profile?.can_edit_profile === false ? 76 + bottom : 16 + bottom}px`,
paddingTop: `${Math.max(12, top + 4)}px`,
}}
className="-mx-[17px] flex h-dvh flex-col overflow-hidden px-4 text-center"
className="-mx-[17px] flex min-h-dvh flex-col px-[18px] text-center"
>
<div className="shrink-0">
<div className="shrink-0 mb-1">
<PageHeader profile={profile} />
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain flex flex-col justify-between py-1">
<div
style={{
paddingBottom: `${profile?.can_edit_profile === false ? 90 + bottom : 28 + bottom}px`,
}}
className="min-h-0 flex-1 flex flex-col gap-4 py-2"
>
{/* 1. Header Section */}
<section className="flex flex-col items-center shrink-0">
<div
aria-hidden="true"
className="relative flex h-[60px] w-[60px] items-center justify-center rounded-full bg-[#FF4E67] shadow-[0_12px_28px_rgba(240,68,91,0.22)]"
className="relative flex h-[56px] w-[56px] items-center justify-center rounded-full bg-[#FF4E67] shadow-none"
>
<Image
src={"/assets/images/Ellipse 1210.svg"}
width={70}
height={70}
width={66}
height={66}
alt="notification"
/>
</div>
<h2 className="text-[22px] font-bold mt-2.5 sm:mt-3">
<h2 className="text-[20px] font-bold mt-2.5 text-[#171717] leading-tight">
{matchHeadingTitle}
</h2>
<p className="mt-2 max-w-[322px] text-[12px] leading-[1.35] font-semibold text-[#7C7C7C]">
<p className="mt-1.5 max-w-[320px] text-[12.5px] leading-[1.4] font-medium text-[#7C7C7C]">
{matchHeadingDescription}
</p>
</section>
@ -651,82 +755,137 @@ export default function NewMatchClient() {
{/* 2. Match Summary Card Section */}
<div className="w-full shrink-0">
{isLoading ? (
<section className="rounded-[15px] border border-white/80 bg-white px-[17px] pt-[16px] pb-[16px] shadow-[0_18px_45px_rgba(15,23,42,0.06)]">
<div className="flex flex-col items-center py-2">
{/* Name line */}
<LoadingSkeleton className="h-5 w-[140px]" />
<section className="relative overflow-hidden rounded-[24px] bg-[#FAFBFD] border border-[#F2E5E8] shadow-[0_4px_24px_rgba(0,0,0,0.04)] text-start">
<div className="relative h-[74px] w-full bg-[linear-gradient(180deg,#FCE2E6_0%,#FFD6DC_100%)]" />
{/* Subtitle / Details lines */}
<div className="mt-2 w-full flex flex-col items-center gap-2 min-h-[40px]">
<LoadingSkeleton className="h-3 w-[180px]" />
<LoadingSkeleton className="h-3 w-[150px]" />
<div className="relative -mt-[38px] mx-auto w-[72px] h-[72px] rounded-full p-[3px] bg-white shadow-[0_4px_12px_rgba(0,0,0,0.08)] flex items-center justify-center z-10">
<LoadingSkeleton className="w-full h-full rounded-full" />
</div>
{/* Button skeleton */}
<LoadingSkeleton className="mt-3 h-[40px] w-full rounded-[10px]" />
<div className="px-4 text-center mt-1.5 flex flex-col items-center">
<LoadingSkeleton className="h-4 w-[120px] rounded-md" />
<LoadingSkeleton className="h-1.5 w-[50px] mt-1.5 rounded-full" />
</div>
<div className="p-3.5 space-y-2">
{[1, 2, 3, 4, 5, 6, 7].map((i) => (
<div
key={i}
className="flex items-center gap-3 rounded-[14px] border border-[#FCE2E6] bg-white px-3.5 py-2.5 min-h-[52px]"
>
<LoadingSkeleton className="h-[38px] w-[38px] rounded-full shrink-0" />
<div className="flex-1 space-y-1.5">
<LoadingSkeleton className="h-3 w-[35%]" />
<LoadingSkeleton className="h-3 w-[60%]" />
</div>
</div>
))}
<LoadingSkeleton className="h-[46px] w-full rounded-[13px] mt-2.5" />
</div>
</section>
) : (
<section className="rounded-[15px] bg-[linear-gradient(180deg,#F0445B_0%,#F4556E_100%)] px-[17px] pt-[16px] pb-[16px] text-white shadow-[0_18px_38px_rgba(240,68,91,0.25)]">
<section className="relative overflow-hidden rounded-[24px] bg-[#FAFBFD] border border-[#F2E5E8] shadow-[0_4px_20px_rgba(240,68,91,0.06)] text-start">
{isError ? (
<p className="py-8 text-[13px] font-semibold">
<p className="py-8 text-center text-[13px] font-semibold text-[#8A8A8A]">
Unable to load match summary.
</p>
) : matchSummary ? (
<>
<h2 className="break-words text-[14px] leading-[1.4] font-bold">
<span>{matchDisplay.name}</span>
{/* Top Curved Banner */}
<div className="relative h-[74px] w-full overflow-hidden bg-[linear-gradient(180deg,#F0445B_0%,#F54B64_100%)]">
{/* Bottom Curve */}
<svg
className="absolute bottom-0 left-0 w-full h-3 text-[#FAFBFD]"
preserveAspectRatio="none"
viewBox="0 0 100 20"
fill="currentColor"
>
<path d="M0 20 C30 0 70 0 100 20 Z" />
</svg>
</div>
{/* Circular Overlapping Avatar */}
<div className="relative -mt-[38px] mx-auto w-[72px] h-[72px] rounded-full p-[3px] bg-white shadow-[0_4px_12px_rgba(0,0,0,0.08)] flex items-center justify-center z-10">
<div className="w-full h-full rounded-full overflow-hidden bg-[#F0445B] flex items-center justify-center">
<Image
src={
isMale
? "/assets/images/female_avatar.svg"
: "/assets/images/Avatar Image.png"
}
width={68}
height={68}
alt="candidate avatar"
className="w-full h-full object-cover"
/>
</div>
</div>
{/* Candidate Name & Heart Divider */}
<div className="px-4 text-center mt-1">
<h2 className="text-[18px] font-extrabold text-[#111827] tracking-tight leading-snug break-words">
{matchDisplay.name}
</h2>
<div className="mt-1.5 min-h-[40px] space-y-0.5 text-left">
{pairedPersonalFields.length ? (
<p className="break-words text-[11px] leading-[1.65] font-medium text-white">
{pairedPersonalFields.map((field, index) => (
<span key={field.id}>
{index > 0 ? (
<span className="opacity-75"> | </span>
) : null}
<span className="font-semibold">
{field.label}:{" "}
</span>
<span>{field.value}</span>
</span>
))}
</p>
) : null}
<div className="flex items-center justify-center gap-2 mt-1 mb-1.5">
<span className="h-[1px] w-8 bg-[#FFCCD3] rounded-full" />
<IoHeartOutline className="text-[#F0445B] text-[14px]" />
<span className="h-[1px] w-8 bg-[#FFCCD3] rounded-full" />
</div>
</div>
{pairedEduFields.length ? (
<p className="break-words text-[11px] leading-[1.65] font-medium text-white">
{pairedEduFields.map((field, index) => (
<span key={field.id}>
{index > 0 ? (
<span className="opacity-75"> | </span>
) : null}
<span className="font-semibold">
{field.label}:{" "}
</span>
<span>{field.value}</span>
</span>
))}
</p>
) : null}
{/* Info Items List */}
<div className="space-y-2 px-3.5 pt-0.5 pb-3.5">
{matchDisplay.age && (
<ProfileInfoItem
field={matchDisplay.age}
icon={<FaCalendarDays />}
/>
)}
{matchDisplay.job ? (
<FieldLine field={matchDisplay.job} />
) : null}
{matchDisplay.currentCountry && (
<ProfileInfoItem
field={matchDisplay.currentCountry}
icon={<FaLocationDot />}
/>
)}
{matchDisplay.hobbies ? (
<FieldLine field={matchDisplay.hobbies} />
) : null}
{matchDisplay.currentCity && (
<ProfileInfoItem
field={matchDisplay.currentCity}
icon={<FaLocationDot />}
/>
)}
{matchDisplay.maritalStatus ? (
<FieldLine field={matchDisplay.maritalStatus} />
) : null}
{matchDisplay.cityPreference ? (
<FieldLine field={matchDisplay.cityPreference} />
) : null}
</div>
{matchDisplay.educationLevel && (
<ProfileInfoItem
field={matchDisplay.educationLevel}
icon={<FaGraduationCap />}
/>
)}
{matchDisplay.fieldOfStudy && (
<ProfileInfoItem
field={matchDisplay.fieldOfStudy}
icon={<FaBookOpen />}
/>
)}
{matchDisplay.jobTitle && (
<ProfileInfoItem
field={matchDisplay.jobTitle}
icon={<FaBriefcase />}
/>
)}
{matchDisplay.hobbies && (
<ProfileInfoItem
field={matchDisplay.hobbies}
icon={<FaStar />}
/>
)}
{/* Button */}
<button
type="button"
onClick={() => {
@ -736,13 +895,33 @@ export default function NewMatchClient() {
openProfile();
}
}}
className="mt-3 inline-flex w-full items-center justify-center rounded-[10px] border-none bg-white h-[40px] text-[16px] font-semibold text-[#F0445B] no-underline shadow-none hover:bg-white/95 transition-colors cursor-pointer"
className="
mt-2.5
flex
h-[46px]
w-full
items-center
justify-center
gap-2
rounded-[13px]
bg-[linear-gradient(180deg,#F0445B_0%,#F4556E_100%)]
text-white
text-[14px]
font-bold
shadow-[0_4px_14px_rgba(240,68,91,0.25)]
active:scale-[0.98]
transition
cursor-pointer
border-none
"
>
{t["View more details"]}
<LuEye className="text-[18px] text-white" />
<span>{t["View more details"]}</span>
</button>
</div>
</>
) : (
<p className="py-8 text-[13px] font-semibold">
<p className="py-8 text-center text-[13px] font-semibold text-[#8A8A8A]">
No match summary is available yet.
</p>
)}
@ -771,7 +950,7 @@ export default function NewMatchClient() {
{profile?.can_edit_profile === false && (
<div
style={{ paddingBottom: `${16 + bottom}px` }}
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"
className="fixed inset-x-0 bottom-0 z-20 mx-auto w-full sm:max-w-[375px] bg-[#F9F8F8] px-[17px] pt-3 pb-[16px]"
>
<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"
@ -880,7 +1059,7 @@ export default function NewMatchClient() {
paymentMutation.isPending || respondMutation.isPending
}
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
onClick={handleDecline}
onClick={openDeclineConfirm}
>
<div className="inline-flex w-full items-center justify-center rounded-[11px] border border-[#747474] bg-transparent px-2 h-[52px] text-[16px] font-semibold text-[#747474] transition-opacity active:opacity-90 min-w-0">
{respondMutation.isPending ? (
@ -944,6 +1123,46 @@ export default function NewMatchClient() {
/>
)}
{isDeclineConfirmOpen && (
<InformationSheet
icon="warning"
title={
(t as any)["Are you sure?"] ||
(locale === "fa" ? "آیا مطمئن هستید؟" : "Are you sure?")
}
description={
<div className="flex flex-col gap-2 text-center mt-1 group-12 text-[#4D4D4D] leading-relaxed">
<p className="font-medium text-[14px]">
{t[
"Are you sure you've fully reviewed the profile and want to reject this profile?"
] ||
(locale === "fa"
? "آیا از رد این پیشنهاد مطمئن هستید؟ در صورت رد، این مورد دیگر در دسترس نخواهد بود."
: "Are you sure you want to decline this proposal? Once declined, this match will no longer be available.")}
</p>
</div>
}
buttons={({ close }) => (
<SwipeButton
disabled={respondMutation.isPending}
text={t["Decline"] || (locale === "fa" ? "رد پیشنهاد" : "Decline")}
cancelText={t["Cancel"] || (locale === "fa" ? "انصراف" : "Cancel")}
onCancel={() => {
close();
cancelDeclineConfirm();
}}
onSuccess={async () => {
await handleDecline();
close();
setIsDeclineConfirmOpen(false);
setIsPaymentSheetOpen(false);
}}
/>
)}
onClose={cancelDeclineConfirm}
/>
)}
<MarriageAdvisorsOverlay open={isAdvisorOpen} onClose={closeAdvisors} />
<MatchProfileOverlay open={isProfileOpen} onClose={closeProfile} />

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

@ -21,6 +21,7 @@ import { parseValue as parseBirthplaceValue } from "@/components/Componentes/que
import QuestionSectionFlow from "@/components/Componentes/question-section-flow";
import StickyHeader from "@/components/Componentes/sticky-header";
import TestIntroPage from "@/components/Componentes/test-intro-page";
import TestCompletedSheet from "@/components/Componentes/test-completed-sheet";
import TestQuestionsFlow, {
type TestQuestion,
} from "@/components/Componentes/test-questions-flow";
@ -137,13 +138,34 @@ function QuestionFlowWrapper({
return undefined;
}, [profile?.age, answers]);
const userContext = useMemo(
() => ({
gender: profile?.gender,
const userContext = useMemo(() => {
let gender = profile?.gender;
if (!gender) {
const genderAns =
answers["personal_identity.gender"] ||
answers["personal_info.gender"] ||
answers["gender"] ||
Object.entries(answers).find(([k]) => k.includes("gender"))?.[1];
const gVal =
typeof genderAns === "object" &&
genderAns !== null &&
"value" in genderAns
? genderAns.value
: genderAns;
if (typeof gVal === "string" && gVal) {
gender =
gVal.toLowerCase().includes("female") ||
gVal.toLowerCase().includes("woman") ||
gVal.toLowerCase().includes("زن")
? "female"
: "male";
}
}
return {
gender,
age: computedAge,
}),
[profile?.gender, computedAge],
);
};
}, [profile?.gender, answers, computedAge]);
const dynamicQuestions = useMemo(() => {
@ -286,10 +308,38 @@ export default function QuestionDetailClient({
const isGlasserSlug =
itemSlug === "glasser_5_needs_test" || itemSlug === "glasser_test";
const isAssessment = isCattellSlug || isGlasserSlug;
const [isCompletedSheetOpen, setIsCompletedSheetOpen] = useState(false);
const { data: overview, isLoading: isOverviewLoading, isError: isOverviewError, refetch: refetchOverview } = useFormOverviewQuery(
"profile",
locale,
);
const isAssessmentCompleted = useMemo(() => {
if (!isAssessment) return false;
if (
overview?.progress?.sections_progress?.[itemSlug]?.completion_percent ===
100
) {
return true;
}
const currentOverviewItem = overview?.sections?.find(
(s) => s.id === itemSlug,
);
if (currentOverviewItem?.progress?.completion_percent === 100) {
return true;
}
try {
const completionKey = getQuestionStorageKey(itemSlug, profileId);
if (completionKey && typeof window !== "undefined") {
const raw = window.localStorage.getItem(completionKey);
if (raw && JSON.parse(raw)?.completed === true) {
return true;
}
}
} catch {}
return false;
}, [isAssessment, overview, itemSlug, profileId]);
const { data: sectionResponse, isLoading: isSectionLoading, isError: isSectionError, refetch: refetchSection } =
useFormSectionQuery(
"profile",
@ -694,7 +744,10 @@ export default function QuestionDetailClient({
questions={activeTestQuestions}
closeLabel={closeLabel}
informationLabel={informationLabel}
onClose={() => setIsTestStarted(false)}
onClose={() => {
setIsTestStarted(false);
handleExit();
}}
onFinish={handleTestFinish}
draftStorageKey={getTestDraftStorageKey(item.slug, profileId)}
/>
@ -771,8 +824,18 @@ export default function QuestionDetailClient({
"All provided information is held in strict confidence and will not be disclosed to third parties. This data is utilized exclusively to ensure optimal matchmaking accuracy. Consequently, it is imperative that this assessment be completed with the utmost diligence, precision, and integrity."
]
}
startLabel={hasTestProgress ? t["Continue"] : t["Start"]}
startLabel={
isAssessmentCompleted
? (t as Record<string, string>)["Completed"] || (locale === "fa" ? "تکمیل شده" : "Completed")
: hasTestProgress
? t["Continue"]
: t["Start"]
}
onStart={() => {
if (isAssessmentCompleted) {
setIsCompletedSheetOpen(true);
return;
}
setIsTestStarted(true);
}}
>
@ -887,6 +950,15 @@ export default function QuestionDetailClient({
</TestIntroPage>
</div>
</main>
<TestCompletedSheet
isOpen={isCompletedSheetOpen}
title={item.title}
onClose={() => {
setIsCompletedSheetOpen(false);
handleExit();
}}
/>
</>
);
}

58
src/app/questions-list/questions-list-client.tsx

@ -56,13 +56,28 @@ import { fetchGeoCountryCode } from "@/components/Componentes/question-phone";
import SectionsRequest from "./sections-request";
import SectionOverlayHost from "@/components/Componentes/section-overlay-host";
import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client";
import TestCompletedSheet from "@/components/Componentes/test-completed-sheet";
export default function QuestionsListClient() {
const [isTermsSheetOpen, setIsTermsSheetOpen] = useState(false);
const [completedTestSheet, setCompletedTestSheet] = useState<{
isOpen: boolean;
title?: string;
}>({ isOpen: false, title: undefined });
// Hardware back on the root questions list = close the Flutter service.
// Unlike the old useCloseServiceOnBack, this does NOT push fake history
// entries. Flutter calls __habibHandleHardwareBack() and we return false
// (meaning "I didn't handle it — you should close").
useHardwareBackHandler(() => {
if (isTermsSheetOpen) {
setIsTermsSheetOpen(false);
return true;
}
if (completedTestSheet.isOpen) {
setCompletedTestSheet({ isOpen: false });
return true;
}
if (isOptionalInfoSheetOpen) {
setIsOptionalInfoSheetOpen(false);
return true; // Handled: closed the tips sheet
@ -235,6 +250,31 @@ export default function QuestionsListClient() {
return progressBySlug;
}, [overview, questionListItems, localAssessmentProgress]);
const isAssessmentSlug = useCallback(
(slug: string) =>
slug === "personality_test" ||
slug === "glasser_5_needs_test" ||
slug === "personality" ||
slug === "glasser" ||
slug === "cattell",
[],
);
const handleCardSelect = useCallback(
(item: QuestionListItem) => {
const progress = sectionProgressBySlug.get(item.slug) ?? 0;
if (isAssessmentSlug(item.slug) && progress >= 100) {
setCompletedTestSheet({
isOpen: true,
title: item.title,
});
return;
}
handleOpenSection(item.slug);
},
[handleOpenSection, isAssessmentSlug, sectionProgressBySlug],
);
const requiredQuestionListItems = useMemo(
() => questionListItems.filter((item) => Boolean(item.required)),
[questionListItems],
@ -784,7 +824,11 @@ export default function QuestionsListClient() {
className="text-left"
/>
) : null}
<SectionsRequest sections={overview?.sections} />
<SectionsRequest
sections={overview?.sections}
isOpen={isTermsSheetOpen}
onClose={() => setIsTermsSheetOpen(false)}
/>
{process.env.NODE_ENV === "development" ? <DevTapInstrumentation /> : null}
<PageBackground disabled />
@ -820,7 +864,9 @@ export default function QuestionsListClient() {
</h1>
<NavigationButton
icon="document"
iconLabel={t["Support"]}
iconLabel={t["terms & conditions"] || "Terms & Conditions"}
disableHelpModal={true}
onClick={() => setIsTermsSheetOpen(true)}
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]"
/>
</header>
@ -842,7 +888,7 @@ export default function QuestionsListClient() {
onInfoClick={(section) => setSelectedSection(section)}
onPrefetch={prefetchSection}
onNearViewport={prefetchSection}
onSelect={(item) => handleOpenSection(item.slug)}
onSelect={handleCardSelect}
/>
))}
</section>
@ -899,6 +945,12 @@ export default function QuestionsListClient() {
/>
) : null}
</SectionOverlayHost>
<TestCompletedSheet
isOpen={completedTestSheet.isOpen}
title={completedTestSheet.title}
onClose={() => setCompletedTestSheet({ isOpen: false })}
/>
</>
);
}

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

@ -5,6 +5,7 @@ import { IoClose } from "react-icons/io5";
import Button from "@/components/Componentes/button";
import InformationSheet from "@/components/Componentes/information-sheet";
import type { FormOverviewSection } from "@/hooks/marriage/use-form-schema";
import { useI18n } from "@/translations/provider";
const bookingTerms = [
"All provided information is held in strict confidence.",
@ -32,6 +33,7 @@ export default function SectionsRequest({
isOpen?: boolean;
onClose?: () => void;
}) {
const { dictionary: t } = useI18n();
const [hasSeenSheet, setHasSeenSheet] = useState(true);
const [isAutoOpenDismissed, setIsAutoOpenDismissed] = useState(false);
@ -62,8 +64,7 @@ export default function SectionsRequest({
const isAutoOpen =
Boolean(sections) && hasNoProgression && !hasSeenSheet && !isAutoOpenDismissed;
const isSheetOpen =
controlledIsOpen !== undefined ? controlledIsOpen : isAutoOpen;
const isSheetOpen = Boolean(controlledIsOpen) || isAutoOpen;
const handleClose = () => {
setIsAutoOpenDismissed(true);
@ -80,18 +81,21 @@ export default function SectionsRequest({
return null;
}
const termsTitle = t["terms & conditions"] || "Terms & Conditions";
const gotItLabel = t["Got it"] || "Got it";
return (
<InformationSheet
icon={null}
title={({ close }) => (
<span className="flex w-full items-start justify-between gap-3 text-left">
<span className="flex w-full items-start justify-between gap-3 text-start">
<span className="text-[14px] leading-5 font-bold tracking-normal text-[#8B8B8B]">
Terms &amp; Conditions
{termsTitle}
</span>
<button
type="button"
aria-label="Close terms and conditions"
className="-mt-0.5 flex size-6 shrink-0 items-center justify-center text-[#8F8F8F]"
className="-mt-0.5 flex size-6 shrink-0 items-center justify-center text-[#8F8F8F] cursor-pointer"
onClick={close}
>
<IoClose aria-hidden="true" className="text-[22px]" />
@ -99,20 +103,19 @@ export default function SectionsRequest({
</span>
)}
description={
<ul className="max-h-[60dvh] space-y-2 overflow-y-auto pl-4 text-left text-[12px] leading-[1.35] font-medium list-disc text-[#4C4C4C] marker:text-[#2B2B2B]">
<ul className="max-h-[60dvh] space-y-2 overflow-y-auto ps-4 text-start text-[12px] leading-[1.35] font-medium list-disc text-[#4C4C4C] marker:text-[#2B2B2B]">
{FIRST_ENTRY_TERMS.map((item, index) => (
<li key={`${index}-${item}`}>{item}</li>
<li key={`${index}-${item}`}>{(t as Record<string, string>)[item] || item}</li>
))}
</ul>
}
buttons={({ close }) => (
<Button className="rounded-[8px]" onClick={close}>
Got it
{gotItLabel}
</Button>
)}
onClose={handleClose}
className="text-left"
className="text-start"
/>
);
}

49
src/app/request-accepted/request-accepted-client.tsx

@ -261,12 +261,12 @@ export default function RequestAcceptedClient() {
? t["Contact info released"]
: t["Request approved!"];
const primaryActionText = isFemaleProfile
? t["No Contact Received"]
? t["No Contact"] || t["No Contact Received"] || "No Contact"
: t["View profile"];
const secondaryActionText = isFemaleProfile
? t["Contact Received"]
: caseStatus === "payment_done" || caseStatus === "contacted"
? t["View contact number"]
? t["Contact"]
: t["Pay and get contact"];
const contactInfoPhoneItems = getContactInfoPhoneItems(
contactInfoQuery.data?.contact_info,
@ -549,29 +549,34 @@ export default function RequestAcceptedClient() {
</div>
) : (
<>
<div className="relative isolate flex items-center justify-center">
{/* Illustration */}
<div className="relative mt-6 flex items-center justify-center">
{/* soft glow */}
<div className="absolute h-[115px] w-[115px] rounded-full bg-[#FF5C7D]/10 blur-xl" />
<div className="relative z-10">
<Image
src="/assets/images/Group 15978804fdasf68.svg"
alt={t["Request accepted"]}
width={131}
height={125}
priority
className="relative z-10"
/>
</div>
</div>
<h1 className="mt-11 group-16 leading-none font-black tracking-[0.02em] text-[#171717] uppercase">
<h1 className="mt-8 text-[20px] leading-none font-black tracking-[0.03em] text-[#171717] uppercase">
{titleText}
</h1>
{caseStatus === "contacted" ||
isFemaleContactConfirmed ||
(isFemaleProfile && contactStatusMutation.isPending) ? (
<div className="w-full border border-[#ECECEC] bg-white rounded-[15px] mt-6 px-4 py-4 text-center shadow-sm max-w-[315px] flex flex-col items-center justify-center min-h-[100px]">
<div className="w-full border border-[#E2E8F0] bg-white/90 backdrop-blur-sm rounded-[20px] mt-6 px-5 py-4 text-center shadow-sm max-w-[340px] flex flex-col items-center justify-center min-h-[90px]">
{isFemaleProfile && contactStatusMutation.isPending ? (
<LoadingThreeDot className="text-[#E03950]" />
) : (
<p className="text-[#555555] group-14 font-medium leading-relaxed">
<p className="text-[#475569] text-[14px] font-medium leading-relaxed">
{isFemaleProfile
? t[
"Thank you for your feedback. To complete the process, please submit the final outcome of this introduction/contact so that the final status can be determined. If the final status is not yet determined, you can stay in this state until it is finalized."
@ -583,7 +588,7 @@ export default function RequestAcceptedClient() {
)}
</div>
) : (
<p className="mt-4 max-w-[315px] group-12 leading-[1.45] font-semibold text-[#777777]">
<p className="mt-5 max-w-[340px] text-[15px] leading-[1.6] font-medium text-[#777777]">
{noContactReportedSuccess
? t[
"Thank you for your feedback. Our support team will investigate the matter and notify you of the result. Please wait patiently during the review; our support will contact you."
@ -602,14 +607,14 @@ export default function RequestAcceptedClient() {
{caseStatus === "contacted" ||
isFemaleContactConfirmed ||
(isFemaleProfile && contactStatusMutation.isPending) ? (
<div className="flex mt-8 w-full gap-3 justify-center">
<div className="flex mt-8 w-full gap-3 justify-center max-w-[350px] mx-auto">
{isFemaleProfile &&
contactStatusMutation.isPending ? null : isFemaleProfile ? (
<button
type="button"
onClick={() => setIsOutcomeSheetOpen(true)}
disabled={outcomeMutation.isPending}
className="w-full max-w-[315px] h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50"
className="w-full h-[50px] px-6 rounded-full bg-gradient-to-r from-[#FF6687] to-[#FF456C] text-white font-bold text-[15px] flex items-center justify-center shadow-sm transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50"
>
{outcomeMutation.isPending ? (
<LoadingThreeDot className="text-white" />
@ -622,7 +627,7 @@ export default function RequestAcceptedClient() {
<button
type="button"
onClick={() => openProfile()}
className="flex-1 h-[52px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#F5F5F5]"
className="flex-1 h-[50px] px-3 rounded-full border border-[#E2E8F0] bg-white/95 backdrop-blur-sm text-[#334155] font-bold text-[14px] shadow-sm flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#F8FAFC]"
>
{t["View Profile"]}
</button>
@ -631,7 +636,7 @@ export default function RequestAcceptedClient() {
type="button"
onClick={() => setIsOutcomeSheetOpen(true)}
disabled={outcomeMutation.isPending}
className="flex-1 h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50"
className="flex-1 h-[50px] px-3 rounded-full bg-gradient-to-r from-[#FF6687] to-[#FF456C] text-white font-bold text-[14px] flex items-center justify-center shadow-sm transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50"
>
{outcomeMutation.isPending ? (
<LoadingThreeDot className="text-white" />
@ -643,7 +648,7 @@ export default function RequestAcceptedClient() {
)}
</div>
) : (
<div className="flex mt-9 w-full justify-center gap-4 max-w-[315px] mx-auto">
<div className="flex mt-8 w-full justify-center gap-3.5 max-w-[360px] mx-auto">
{isFemaleProfile ? (
<button
type="button"
@ -652,7 +657,7 @@ export default function RequestAcceptedClient() {
contactStatusMutation.isPending ||
noContactReportedSuccess
}
className="flex-1 h-[44px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-all cursor-pointer hover:bg-[#F5F5F5] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white"
className="flex-1 min-h-[48px] px-3.5 py-2.5 rounded-full border border-[#E2E8F0] bg-white/95 backdrop-blur-sm text-[#475569] font-bold text-[13.5px] leading-tight shadow-sm flex items-center justify-center text-center whitespace-nowrap transition-all cursor-pointer hover:bg-[#F8FAFC] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
>
{contactStatusMutation.isPending ? (
<LoadingThreeDot />
@ -663,9 +668,9 @@ export default function RequestAcceptedClient() {
) : (
<Link
href={profileHref}
className="max-w-[212px] flex-1"
className="flex-1 max-w-[170px]"
>
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C] min-h-[38px] flex items-center justify-center">
<div className="w-full min-h-[48px] px-3.5 py-2.5 rounded-full border border-[#E2E8F0] bg-white/95 backdrop-blur-sm text-[#475569] font-bold text-[14px] leading-tight shadow-sm flex items-center justify-center text-center whitespace-nowrap transition-all hover:bg-[#F8FAFC] active:scale-[0.98]">
{primaryActionText}
</div>
</Link>
@ -679,8 +684,8 @@ export default function RequestAcceptedClient() {
disabled={paymentMutation.isPending}
className={
isFemaleProfile
? "flex-1 h-[44px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-md shadow-[#FE6F82]/30 transition-all cursor-pointer hover:opacity-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none"
: "max-w-[212px] flex-1 appearance-none border-0 bg-transparent p-0 text-left"
? "flex-1 min-h-[48px] px-3.5 py-2.5 rounded-full bg-gradient-to-r from-[#FF6687] to-[#FF456C] text-white font-bold text-[13.5px] leading-tight flex items-center justify-center text-center whitespace-nowrap shadow-sm transition-all cursor-pointer hover:opacity-95 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
: "flex-1 max-w-[170px] min-h-[48px] appearance-none border-0 bg-transparent p-0 text-center cursor-pointer transition-transform active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
}
>
{isFemaleProfile ? (
@ -690,9 +695,9 @@ export default function RequestAcceptedClient() {
secondaryActionText
)
) : (
<div className="bg-linear-180 from-[#FE6F82] to-[#E03950] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#fff] shadow-md shadow-[#F2596E]/60 flex items-center justify-center min-h-[38px]">
<div className="w-full h-full min-h-[48px] px-3.5 py-2.5 rounded-full bg-gradient-to-r from-[#FF6687] to-[#FF456C] text-white font-bold text-[14px] leading-tight flex items-center justify-center text-center whitespace-nowrap shadow-sm hover:opacity-95">
{paymentMutation.isPending ? (
<LoadingThreeDot />
<LoadingThreeDot className="text-white" />
) : (
secondaryActionText
)}
@ -706,8 +711,8 @@ export default function RequestAcceptedClient() {
!isFemaleContactConfirmed &&
!noContactReportedSuccess &&
!(isFemaleProfile && contactStatusMutation.isPending) ? (
<div className="border border-[#F0445B] bg-[#F0445B]/10 rounded-xl mt-4">
<p className="text-[#F0445B] group-12 font-semibold py-2.5 px-3.5 whitespace-pre-line">
<div className="border border-[#FDA4AF]/60 bg-[#FFF1F2]/80 backdrop-blur-sm rounded-[16px] mt-6 p-4 max-w-[360px] shadow-sm">
<p className="text-[#BE123C] text-[12px] font-medium leading-[1.65] whitespace-pre-line text-justify">
{
t[
"Please be informed that from the time of this introduction, you have 48 hours (2 days) to contact the person or their respected family to declare your readiness and begin the acquaintance process. At this stage, merely an initial call to announce your presence is sufficient, and planning for further steps (such as an in-person meeting) depends entirely on your subsequent mutual agreements. Since failing to make contact within the specified time might be considered socially disrespectful, if no action is taken within these 2 days, the introduced match will be removed according to the platform's rules. We also remind you that this issue may lead to restrictions such as delays in future introductions and financial penalties."

31
src/app/request-sent/request-sent-client.tsx

@ -67,10 +67,15 @@ export default function RequestSentClient() {
],
getAdvisor: t["Get Advisor"],
};
const isFemaleProfile = profile?.gender === "female";
const requestSentCopy = {
title: t["Request Sent"],
description:
t[
description: isFemaleProfile
? t[
"Your request has been sent. Once the gentleman reviews your request, you will be notified."
] ||
"درخواست شما ارسال شد. پس از بررسی درخواست شما توسط آقا، به شما اطلاع‌رسانی خواهد شد."
: t[
"Your request has been sent. Once the lady reviews your request, you will be notified."
],
matchProfile: t["View More Details"],
@ -87,33 +92,41 @@ export default function RequestSentClient() {
>
<PageHeader className="-mx-[6px]" profile={profile} />
<div className="flex flex-1 flex-col justify-between gap-20 pt-[109px]">
<div className="flex flex-1 flex-col justify-between gap-16 pt-8">
<section className="flex flex-col items-center">
<div className="relative isolate flex items-center justify-center">
{/* Illustration */}
<div className="relative mt-6 flex items-center justify-center">
{/* soft glow */}
<div className="absolute h-[115px] w-[115px] rounded-full bg-[#FF5C7D]/10 blur-xl" />
<div className="relative z-10">
<Image
src="/assets/images/Group 15978804fdasf68.svg"
alt="Request sent"
width={131}
height={125}
priority
className="relative z-10"
/>
</div>
</div>
<h1 className="mt-11 group-16 leading-none font-black tracking-[0.02em] text-[#171717] uppercase">
{/* Title */}
<h1 className="mt-8 text-[20px] leading-none font-black tracking-[0.03em] text-[#171717] uppercase">
{requestSentCopy.title}
</h1>
<p className="mt-4 max-w-[315px] group-12 leading-[1.45] font-semibold text-[#777777]">
{/* Description */}
<p className="mt-5 max-w-[330px] text-[15px] leading-[1.6] font-medium text-[#777777]">
{requestSentCopy.description}
</p>
{/* Button */}
<button
type="button"
onClick={openProfile}
className="mt-9 w-full max-w-[212px] cursor-pointer"
className="mt-12 w-full max-w-[300px] cursor-pointer transition-transform active:scale-[0.97]"
>
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C] transition-transform active:scale-[0.98]">
<div className="rounded-full bg-gradient-to-r from-[#FF6687] to-[#FF456C] px-6 py-4 text-[16px] font-bold text-white shadow-sm hover:opacity-95">
{requestSentCopy.matchProfile}
</div>
</button>

48
src/components/Componentes/advisor-actions-card.tsx

@ -1,7 +1,9 @@
"use client";
import Link from "next/link";
import { useMarriageAdvisorsQuery } from "@/hooks/marriage/use-marriage-advisors";
import Button from "./button";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import NetworkImage from "./network-image";
const FALLBACK_AVATAR = "/assets/images/Avatar Image.png";
@ -33,13 +35,14 @@ export function AdvisorActionsCard({
onGetAdvisor,
className,
}: AdvisorActionsCardProps) {
const { locale } = useI18n();
const { data, isLoading } = useMarriageAdvisorsQuery();
const advisors = data?.results ?? [];
// Derive real avatars from API response (pick first 3 with an avatar).
// Derive real avatars from API response (pick up to 4 with an avatar).
const realAvatars: AdvisorAvatar[] = advisors
.filter((a) => a.avatar_url)
.slice(0, 3)
.slice(0, 4)
.map((a) => ({
id: a.username,
src: a.avatar_url ?? FALLBACK_AVATAR,
@ -55,54 +58,63 @@ export function AdvisorActionsCard({
: fallbackExtraCount;
return (
<section className={["space-y-3", className].filter(Boolean).join(" ")}>
<div className="rounded-[13px] border border-white/80 bg-white/72 px-3 py-3.5 text-left shadow-[0_18px_45px_rgba(15,23,42,0.06)] backdrop-blur-sm">
<h2 className="group-16 leading-none font-bold text-[#1C1C1C]">
<section className={["w-full", className].filter(Boolean).join(" ")}>
<div className="rounded-[14px] border border-white/80 bg-white/75 px-4 py-3.5 text-left shadow-none backdrop-blur-sm">
<h2 className="text-[13.5px] leading-tight font-bold text-[#1C1C1C]">
{title}
</h2>
<p className="mt-2 max-w-[280px] group-10 leading-[1.45] font-semibold text-[#8A8A8A]">
<p className="mt-1.5 max-w-[280px] text-[10.5px] leading-[1.4] font-medium text-[#8A8A8A]">
{description}
</p>
<div className="mt-4 flex items-center justify-between gap-3">
<div className="flex items-center pl-1">
<div className="mt-3 flex items-center justify-between gap-3">
<div className="flex items-center shrink-0">
{isLoading
? /* ── Shimmer circles while API loads ── */
Array.from({ length: 3 }).map((_, i) => (
<span
key={`shimmer-${i}`}
className="-ml-1.5 flex h-[30px] w-[30px] overflow-hidden rounded-full border-2 border-white first:ml-0 shimmer-bg"
className="-ml-1.5 flex h-[28px] w-[28px] overflow-hidden rounded-full border-2 border-white first:ml-0 shimmer-bg"
/>
))
: displayAvatars.map((avatar) => (
<span
key={avatar.id}
className="-ml-1.5 flex h-[30px] w-[30px] overflow-hidden rounded-full border-2 border-white bg-[#E7E7E7] first:ml-0"
className="-ml-1.5 flex h-[28px] w-[28px] overflow-hidden rounded-full border-2 border-white bg-[#E7E7E7] first:ml-0"
>
<NetworkImage
src={avatar.src}
fallbackSrc={FALLBACK_AVATAR}
alt=""
width={30}
height={30}
width={28}
height={28}
className="h-full w-full object-cover"
/>
</span>
))}
{!isLoading && displayExtraCount > 0 && (
<span className="-ml-1.5 flex h-[30px] w-[30px] items-center justify-center rounded-full border-2 border-white bg-[#EDEEF1] group-10 font-semibold text-[#1C1C1C]">
<span className="-ml-1.5 flex h-[28px] w-[28px] items-center justify-center rounded-full border-2 border-white bg-[#EDEEF1] text-[10px] font-bold text-[#1C1C1C]">
+{displayExtraCount}
</span>
)}
</div>
<Button
className="w-auto rounded-[9px] border-none bg-[#EBEDF0] bg-none px-5 py-[13px] text-[#111111]! shadow-none"
href={onGetAdvisor ? undefined : getAdvisorHref}
{getAdvisorHref ? (
<Link
href={localizePath(getAdvisorHref, locale)}
className="inline-flex h-[36px] min-w-[105px] px-3.5 shrink-0 items-center justify-center rounded-[9px] border-none bg-[#EBEDF0] text-[12.5px] font-bold text-[#111111] shadow-none hover:bg-[#E2E4E8] active:scale-[0.98] transition-all cursor-pointer whitespace-nowrap"
>
{getAdvisorLabel}
</Link>
) : (
<button
type="button"
onClick={onGetAdvisor}
className="inline-flex h-[36px] min-w-[105px] px-3.5 shrink-0 items-center justify-center rounded-[9px] border-none bg-[#EBEDF0] text-[12.5px] font-bold text-[#111111] shadow-none hover:bg-[#E2E4E8] active:scale-[0.98] transition-all cursor-pointer whitespace-nowrap"
>
{getAdvisorLabel}
</Button>
</button>
)}
</div>
</div>
</section>

111
src/components/Componentes/information-sheet.tsx

@ -16,6 +16,12 @@ type InformationSheetPresetIcon =
| "warning"
| "coin"
| "check"
| "diamond"
| "diamond-color"
| "diamond-color.svg"
| "diamond-color.png"
| "/assets/images/diamond-color.png"
| "/assets/images/diamond-color.svg"
| "stash_play-solid.svg"
| "warning.svg"
| "coin.svg"
@ -42,6 +48,7 @@ export type InformationSheetProps = Omit<
closeOnOutside?: boolean;
onClose?: () => void;
isLoading?: boolean;
showCloseButton?: boolean;
};
const DEFAULT_ICON = {
@ -98,6 +105,42 @@ const ICON_PRESETS: Record<
width: 36,
height: 36,
},
diamond: {
src: "/assets/images/diamond-color.svg",
alt: "Subscription",
width: 48,
height: 48,
},
"diamond-color": {
src: "/assets/images/diamond-color.svg",
alt: "Subscription",
width: 48,
height: 48,
},
"diamond-color.svg": {
src: "/assets/images/diamond-color.svg",
alt: "Subscription",
width: 48,
height: 48,
},
"diamond-color.png": {
src: "/assets/images/diamond-color.svg",
alt: "Subscription",
width: 48,
height: 48,
},
"/assets/images/diamond-color.png": {
src: "/assets/images/diamond-color.svg",
alt: "Subscription",
width: 48,
height: 48,
},
"/assets/images/diamond-color.svg": {
src: "/assets/images/diamond-color.svg",
alt: "Subscription",
width: 48,
height: 48,
},
};
function resolveIcon(icon: InformationSheetIcon | null | undefined) {
@ -110,6 +153,16 @@ function resolveIcon(icon: InformationSheetIcon | null | undefined) {
}
if (typeof icon === "string") {
// If diamond PNG was requested, automatically upgrade to high-res vector SVG
if (icon.includes("diamond-color")) {
return {
src: "/assets/images/diamond-color.svg",
alt: "Subscription",
width: 48,
height: 48,
};
}
return (
ICON_PRESETS[icon as InformationSheetPresetIcon] ?? {
src: icon,
@ -137,6 +190,7 @@ export function InformationSheet({
onClose,
className,
isLoading = false,
showCloseButton = true,
...props
}: InformationSheetProps) {
const { locale, dictionary: t } = useI18n();
@ -241,17 +295,68 @@ export function InformationSheet({
<section
{...props}
className={[
"w-full sm:max-w-[375px] rounded-t-[22px] bg-[#F9F8F8] px-4 pt-4 pb-[max(24px,calc(24px+var(--safe-bottom)))] text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform animate-in slide-in-from-bottom",
"relative w-full sm:max-w-[375px] rounded-t-[22px] bg-[#F9F8F8] px-4 pt-4 pb-[max(24px,calc(24px+var(--safe-bottom)))] text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0",
className,
]
.filter(Boolean)
.join(" ")}
>
{showCloseButton && (
<button
type="button"
onClick={closeSheet}
className="absolute top-3.5 end-3.5 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#9CA3AF] hover:bg-black/5 hover:text-[#4B5563] active:scale-95 transition-all cursor-pointer border-none bg-transparent"
aria-label={(t as any)?.["Close"] || "Close"}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
)}
<div className="mx-auto flex flex-col items-center w-full">
{isLoading ? (
<div className="flex h-[280px] min-h-[280px] w-full items-center justify-center">
<LoadingThreeDot className="text-[#FF4E67]" />
<div className="flex flex-col items-center w-full animate-in fade-in duration-200">
{/* Icon Skeleton */}
<LoadingSkeleton className="h-[56px] w-[56px] rounded-full" />
{/* Title Skeleton */}
<LoadingSkeleton className="mt-3.5 h-[22px] w-[65%] rounded-md" />
{/* Description Lines Skeleton */}
<div className="mt-3 flex flex-col items-center gap-2 w-full">
<LoadingSkeleton className="h-[14px] w-[90%] rounded-md" />
<LoadingSkeleton className="h-[14px] w-[80%] rounded-md" />
<LoadingSkeleton className="h-[14px] w-[60%] rounded-md" />
</div>
{/* Middle Box Skeleton (e.g. Plan / Info) */}
<div className="mt-4 w-full rounded-[11px] bg-white p-3 border border-gray-100 flex flex-col items-center gap-2">
<LoadingSkeleton className="h-[12px] w-[35%] rounded-md" />
<LoadingSkeleton className="h-[20px] w-[50%] rounded-md" />
</div>
{/* Subtext Skeleton */}
<div className="mt-3.5 flex flex-col items-center gap-1.5 w-full">
<LoadingSkeleton className="h-[11px] w-[85%] rounded-md" />
<LoadingSkeleton className="h-[11px] w-[70%] rounded-md" />
</div>
{/* Action / Swipe Buttons Skeleton */}
<div className="mt-5 grid grid-cols-[33fr_67fr] w-full gap-3">
<LoadingSkeleton className="h-[52px] w-full rounded-[11px]" />
<LoadingSkeleton className="h-[52px] w-full rounded-[11px]" />
</div>
</div>
) : (
<>

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

@ -135,7 +135,7 @@ export function NavigationButton({
return hasActiveSubscription ? (
<InformationSheet
icon="/assets/images/diamond-color.png"
icon="/assets/images/diamond-color.svg"
title={t["Subscription Status"] || "Subscription Status"}
description={subscriptionDescription}
buttons={t["Got it"] || t["Back"] || "Back"}
@ -201,7 +201,7 @@ export function NavigationButton({
case "subscription":
return hasActiveSubscription ? (
<Image
src="/assets/images/diamond-color.png"
src="/assets/images/diamond-color.svg"
alt=""
aria-hidden="true"
className="size-6 object-contain"
@ -292,7 +292,7 @@ export function NavigationButton({
className="flex w-full items-center gap-3 px-4 py-3 text-start text-sm font-semibold text-gray-700 hover:bg-gray-50 cursor-pointer border-0 bg-transparent"
>
<Image
src="/assets/images/diamond-color.png"
src="/assets/images/diamond-color.svg"
alt=""
width={20}
height={20}

18
src/components/Componentes/page-header.tsx

@ -6,7 +6,6 @@ import {
NavigationButton,
type NavigationButtonProps,
} from "./navigation-button";
import { hasSupportAccess } from "./support-access";
type PageHeaderProps = {
className?: string;
@ -72,32 +71,19 @@ export function PageHeader({
{isCustomRightButton ? (
<NavigationButton
icon={iconFromProp || "support"}
icon={iconFromProp || "close"}
profile={profile}
{...(iconFromProp === "support" ? { iconLabel: t["Support"] } : {})}
{...rightButtonRest}
/>
) : isMale ? (
<div className="flex items-center gap-2">
<NavigationButton
icon="subscription"
profile={profile}
iconLabel={t["Subscription Status"] || "Subscription"}
/>
<NavigationButton
icon="support"
profile={profile}
iconLabel={t["Support"]}
{...rightButtonRest}
/>
</div>
) : (
<NavigationButton
icon="support"
profile={profile}
iconLabel={t["Support"]}
{...rightButtonRest}
/>
<div className="size-10 shrink-0" />
)}
</header>
);

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

@ -841,6 +841,14 @@ export function QuestionAnswersProvider({
void queryClient.invalidateQueries({
queryKey: marriageQueryKeys.sectionData(slugRef.current),
});
// Invalidate formSection cache for this slug (all locales)
void queryClient.invalidateQueries({
queryKey: [...marriageQueryKeys.all, "form-section", "profile", slugRef.current],
});
// Invalidate formOverview cache (all locales)
void queryClient.invalidateQueries({
queryKey: [...marriageQueryKeys.all, "form-overview", "profile"],
});
})
.catch((err) => {
console.error('[KEEPALIVE] Fetch FAILED:', err);

224
src/components/Componentes/question-file.test.tsx

@ -0,0 +1,224 @@
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { QuestionField } from "@/lib/schema-adapter";
import { QuestionFile } from "./question-file";
let answerMap: Record<string, unknown> = {};
const mockSetAnswerValue = vi.fn((q: QuestionField, val: unknown) => {
answerMap[q.id] = val;
});
const mockMutateAsync = vi.fn(async (file: File) => ({
path: `/media/tmp/${file.name}`,
}));
vi.mock("@/hooks/marriage/use-upload-tmp-media", () => ({
useUploadTmpMediaMutation: () => ({
mutate: vi.fn(),
mutateAsync: mockMutateAsync,
isPending: false,
isError: false,
}),
}));
vi.mock("@/translations/provider", () => ({
useI18n: () => ({
locale: "en",
dictionary: {
upload_certificates: "upload certificates",
add_another_document: "Add another document",
max_files_reached: "Maximum of 4 files uploaded",
remove_document: "Remove document",
upload_failed: "Upload failed. Please try again.",
},
}),
}));
vi.mock("./question-answer-storage", () => ({
useQuestionAnswers: () => ({
getAnswerValue: (q: QuestionField) => answerMap[q.id] ?? null,
setAnswerValue: mockSetAnswerValue,
isLoading: false,
}),
}));
const mockFileQuestion: QuestionField = {
id: "documents_verification.valid_identification_document",
title: "Valid Identification Document",
type: "file",
order: 1,
required: true,
baseRequired: true,
isVisible: true,
description: "Passport, National ID, or Driver's License",
tooltip: "",
extras: {
placeHolder: "Upload document",
range: [0, 0],
options: [".pdf", ".jpg", ".jpeg", ".png"],
},
options: [],
};
describe("QuestionFile Component (Multiple Upload)", () => {
beforeEach(() => {
answerMap = {};
mockSetAnswerValue.mockClear();
mockMutateAsync.mockClear();
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
it("renders empty dropzone when no files are uploaded without formats text", () => {
render(<QuestionFile question={mockFileQuestion} />);
expect(screen.getByText(/Valid Identification/)).toBeInTheDocument();
expect(screen.getByText(/Passport, National ID, or Driver's License/)).toBeInTheDocument();
expect(screen.getByText("upload certificates")).toBeInTheDocument();
expect(screen.queryByText("pdf, jpg, jpeg, png")).not.toBeInTheDocument();
});
it("renders legacy single file stored as string correctly", () => {
answerMap[mockFileQuestion.id] = "/media/marriage/media/passport.pdf";
render(<QuestionFile question={mockFileQuestion} />);
expect(screen.getByText("passport.pdf")).toBeInTheDocument();
expect(screen.getByText(/Add another document/)).toBeInTheDocument();
expect(screen.getByText(/(1\/4)/)).toBeInTheDocument();
});
it("renders multiple stored files correctly", () => {
answerMap[mockFileQuestion.id] = [
"/media/marriage/media/doc1.pdf",
"/media/marriage/media/doc2.jpg",
"/media/marriage/media/doc3.png",
];
render(<QuestionFile question={mockFileQuestion} />);
expect(screen.getByText("doc1.pdf")).toBeInTheDocument();
expect(screen.getByAltText("doc2.jpg")).toBeInTheDocument();
expect(screen.getByAltText("doc3.png")).toBeInTheDocument();
expect(screen.getByText(/Add another document/)).toBeInTheDocument();
expect(screen.getByText(/(3\/4)/)).toBeInTheDocument();
});
it("uploads a new file and transitions from empty state to list with Add button", async () => {
render(<QuestionFile question={mockFileQuestion} />);
const input = screen.getByLabelText("Upload files");
expect(input).toBeInTheDocument();
const file = new File(["dummy content"], "national_id.pdf", {
type: "application/pdf",
});
await act(async () => {
fireEvent.change(input, { target: { files: [file] } });
});
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith(file);
expect(mockSetAnswerValue).toHaveBeenCalledWith(mockFileQuestion, [
"/media/tmp/national_id.pdf",
]);
});
expect(screen.getByText("national_id.pdf")).toBeInTheDocument();
expect(screen.getByText(/Add another document/)).toBeInTheDocument();
});
it("allows adding 2nd, 3rd, 4th file and disables upload at 4 items", async () => {
answerMap[mockFileQuestion.id] = [
"/media/marriage/media/file1.pdf",
"/media/marriage/media/file2.pdf",
"/media/marriage/media/file3.pdf",
];
render(<QuestionFile question={mockFileQuestion} />);
expect(screen.getByText("file1.pdf")).toBeInTheDocument();
expect(screen.getByText("file2.pdf")).toBeInTheDocument();
expect(screen.getByText("file3.pdf")).toBeInTheDocument();
expect(screen.getByText(/(3\/4)/)).toBeInTheDocument();
// Add 4th file
const addInput = screen.getByLabelText("Add file");
const fourthFile = new File(["content"], "file4.jpg", { type: "image/jpeg" });
await act(async () => {
fireEvent.change(addInput, { target: { files: [fourthFile] } });
});
await waitFor(() => {
expect(mockSetAnswerValue).toHaveBeenCalledWith(mockFileQuestion, [
"/media/marriage/media/file1.pdf",
"/media/marriage/media/file2.pdf",
"/media/marriage/media/file3.pdf",
"/media/tmp/file4.jpg",
]);
});
// Now 4 items are present, max files reached badge should be displayed
expect(screen.getByText("Maximum of 4 files uploaded")).toBeInTheDocument();
expect(screen.queryByText(/Add another document/)).not.toBeInTheDocument();
});
it("deleting an item removes it from list, preserves remaining items, and restores Add button", async () => {
answerMap[mockFileQuestion.id] = [
"/media/marriage/media/file1.pdf",
"/media/marriage/media/file2.jpg",
"/media/marriage/media/file3.png",
"/media/marriage/media/file4.pdf",
];
render(<QuestionFile question={mockFileQuestion} />);
expect(screen.getByText("Maximum of 4 files uploaded")).toBeInTheDocument();
const deleteButtons = screen.getAllByTitle("Remove document");
expect(deleteButtons).toHaveLength(4);
// Delete 2nd file (file2.jpg)
await act(async () => {
fireEvent.click(deleteButtons[1]);
});
expect(mockSetAnswerValue).toHaveBeenCalledWith(mockFileQuestion, [
"/media/marriage/media/file1.pdf",
"/media/marriage/media/file3.png",
"/media/marriage/media/file4.pdf",
]);
// Now 3 items remain, add button should be visible again
expect(screen.getByText(/Add another document/)).toBeInTheDocument();
expect(screen.getByText(/(3\/4)/)).toBeInTheDocument();
});
it("deleting the only item clears answer back to null and returns to empty dropzone", async () => {
answerMap[mockFileQuestion.id] = ["/media/marriage/media/only_file.pdf"];
render(<QuestionFile question={mockFileQuestion} />);
expect(screen.getByText("only_file.pdf")).toBeInTheDocument();
const deleteButton = screen.getByTitle("Remove document");
await act(async () => {
fireEvent.click(deleteButton);
});
expect(mockSetAnswerValue).toHaveBeenCalledWith(mockFileQuestion, null);
expect(screen.getByText("upload certificates")).toBeInTheDocument();
});
});

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

@ -383,4 +383,54 @@ describe("QuestionSheet component", () => {
expect(section).not.toHaveClass("h-auto");
expect(screen.getByPlaceholderText("جستجو...")).toBeDefined();
});
it("should render relationship persons and NOT countries for contact_residence.relationship_to_representative", () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const relQuestion = {
id: "contact_residence.relationship_to_representative",
title: "نسبت رابط با شما",
type: "dropdown",
required: false,
extras: {
placeHolder: "انتخاب کنید",
noSearch: true,
},
options: [
{ id: "contact_residence.relationship_to_representative.father", value: "father", label: "پدر", order: 1 },
{ id: "contact_residence.relationship_to_representative.mother", value: "mother", label: "مادر", order: 2 },
{ id: "contact_residence.relationship_to_representative.brother", value: "brother", label: "برادر", order: 3 },
{ id: "contact_residence.relationship_to_representative.sister", value: "sister", label: "خواهر", order: 4 },
{ id: "contact_residence.relationship_to_representative.paternal_maternal_uncle", value: "paternal_maternal_uncle", label: "عمو / دایی", order: 5 },
{ id: "contact_residence.relationship_to_representative.paternal_maternal_aunt", value: "paternal_maternal_aunt", label: "خاله / عمه", order: 6 },
{ id: "contact_residence.relationship_to_representative.trusted_family_friend", value: "trusted_family_friend", label: "دوست خانوادگی معتمد", order: 7 },
{ id: "contact_residence.relationship_to_representative.religious_clerical_sponsor", value: "religious_clerical_sponsor", label: "معرف مذهبی / روحانی", order: 8 },
{ id: "contact_residence.relationship_to_representative.trusted_social_sponsor", value: "trusted_social_sponsor", label: "معرف اجتماعی معتمد", order: 9 },
],
ui_config: {},
} as QuestionField;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[relQuestion]}>
<QuestionSheet question={relQuestion} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "انتخاب کنید" }));
// Should contain person relationships
expect(screen.getByText("پدر")).toBeDefined();
expect(screen.getByText("مادر")).toBeDefined();
expect(screen.getByText("برادر")).toBeDefined();
expect(screen.getByText("عمو / دایی")).toBeDefined();
// Should NOT contain countries
expect(screen.queryByText("Afghanistan")).toBeNull();
expect(screen.queryByText("Antigua and Barbuda")).toBeNull();
expect(screen.queryByText("افغانستان")).toBeNull();
});
});

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

@ -97,30 +97,38 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, closeSheet]);
const isExcludedFromAutoDatasets =
question.id?.toLowerCase().includes("representative") ||
question.id?.toLowerCase().includes("relationship") ||
question.id?.toLowerCase().includes("residence_status") ||
question.id?.toLowerCase().includes("status") ||
question.id?.toLowerCase().includes("responsibility") ||
question.extras?.noSearch === true ||
question.ui_config?.noSearch === true;
const isLanguageQuestion =
question.id?.toLowerCase().includes("language") ||
question.id?.toLowerCase().includes("mother_tongue") ||
question.id?.toLowerCase().includes("other_languages") ||
question.title?.toLowerCase().includes("language") ||
question.title?.toLowerCase().includes("tongue") ||
question.title?.includes("زبان") ||
question.ui_config?.dataset === "languages";
!isExcludedFromAutoDatasets &&
(question.ui_config?.dataset === "languages" ||
question.id?.endsWith(".mother_tongue") ||
question.id?.endsWith(".native_language") ||
question.id?.endsWith(".other_languages") ||
(Boolean(question.title?.toLowerCase().includes("language")) &&
!question.options?.length));
const isCountryQuestion =
question.id?.toLowerCase().includes("nationality") ||
question.id?.toLowerCase().includes("citizenship") ||
question.id?.toLowerCase().includes("country") ||
question.id?.toLowerCase().includes("birthplace") ||
question.id?.toLowerCase().includes("residence") ||
!isExcludedFromAutoDatasets &&
(question.ui_config?.dataset === "countries" ||
question.ui_config?.dataset === "nationalities" ||
question.id?.endsWith(".nationality") ||
question.id?.endsWith(".citizenship") ||
question.id?.endsWith(".second_nationality") ||
(Boolean(
question.id?.endsWith(".birthplace") ||
question.id?.endsWith(".current_residence") ||
question.title?.toLowerCase().includes("nationality") ||
question.title?.toLowerCase().includes("citizenship") ||
question.title?.toLowerCase().includes("country") ||
question.title?.includes("ملیت") ||
question.title?.includes("تابعیت") ||
question.title?.includes("کشور") ||
question.title?.includes("سکونت") ||
question.ui_config?.dataset === "countries" ||
question.ui_config?.dataset === "nationalities";
question.title?.toLowerCase().includes("citizenship"),
) &&
!question.options?.length));
const options = useMemo(() => {
const rawOptions = question.options || [];
@ -142,8 +150,8 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
LANGUAGES_EN.forEach((enLang, idx) => {
const localizedLabel =
(locale === "fa" || locale === "fa-ir"
? (LANGUAGE_EN_TO_FA[enLang] || LANGUAGES_FA[idx])
: (t as any)[enLang]) || enLang;
? LANGUAGES_FA[idx]
: (t as any)[LANGUAGE_EN_TO_FA[enLang] || enLang]) || enLang;
const cleanSlug = enLang
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
@ -162,7 +170,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const isPersian = locale === "fa" || locale === "fa-ir";
const displayLabel = isPersian
? (LANGUAGE_EN_TO_FA[enLang] || localizedLabel)
? (LANGUAGES_FA[idx] || localizedLabel)
: enLang;
mergedOptions.push({
@ -241,14 +249,22 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
return mergedOptions;
}
return rawOptions;
return rawOptions.map((opt) => ({
...opt,
label: (t as any)[opt.label] || opt.label,
}));
}, [question, isLanguageQuestion, isCountryQuestion, locale, t]);
const noSearch = Boolean(
question.extras?.noSearch === true ||
question.ui_config?.noSearch === true ||
question.id?.toLowerCase().includes("responsibility"),
);
const COMPACT_OPTIONS_MAX = 6;
const isCompact = options.length <= COMPACT_OPTIONS_MAX;
const showSearch =
!question.extras?.noSearch && options.length > COMPACT_OPTIONS_MAX;
const isCompact = options.length <= COMPACT_OPTIONS_MAX || noSearch;
const showSearch = !noSearch && options.length > COMPACT_OPTIONS_MAX;
useEffect(() => {
if (!isOpen || isClosing || !isCompact) return;
@ -411,7 +427,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
ref={sheetRef}
className={[
"flex w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom",
isCompact && !showSearch
!showSearch
? "h-auto max-h-[82svh]"
: "h-[82svh] min-h-[82svh] max-h-[82svh]",
isClosing ? "translate-y-full" : "translate-y-0",
@ -527,9 +543,9 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
}
}}
className={[
"flex min-h-12 w-full items-start gap-3 rounded-lg border px-3 py-3 text-start transition-colors cursor-pointer",
"flex min-h-[52px] w-full items-start gap-3 rounded-[12px] border px-3.5 py-3 text-start transition-colors cursor-pointer",
isSelected
? "bg-[#FFF4F5] border-[#F0445B]/30 text-[#181818]"
? "bg-[#FFF4F5] border-[#F0445B]/40 text-[#181818]"
: "bg-white hover:bg-[#F2F4F7] border-[#EAECF0] text-[#181818]",
].join(" ")}
>
@ -537,7 +553,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
{isMulti ? (
<div
className={[
"size-[22px] shrink-0 rounded-[6px] transition-all duration-150 mt-0.5 flex items-center justify-center",
"size-[20px] shrink-0 rounded-[6px] transition-all duration-150 mt-[2px] flex items-center justify-center",
isSelected
? "bg-[#F0445B] text-white shadow-xs"
: "border-[2px] border-[#98A2B3] bg-white",
@ -564,15 +580,19 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
) : (
<div
className={[
"size-[22px] shrink-0 rounded-full transition-all duration-150 mt-0.5 flex items-center justify-center",
"size-[20px] shrink-0 rounded-full border-[2px] transition-all flex items-center justify-center mt-[2px]",
isSelected
? "border-[6px] border-[#F0445B] bg-white"
: "border-[2px] border-[#98A2B3] bg-white",
? "border-[#F0445B] bg-white text-[#F0445B]"
: "border-[#98A2B3] bg-white text-transparent",
].join(" ")}
/>
>
{isSelected && (
<div className="size-[10px] rounded-full bg-[#F0445B]" />
)}
</div>
)}
<span className="text-[15px] leading-snug flex-1">
<span className="text-[15px] leading-[1.45] flex-1 text-start break-words">
{option.label.includes(" - ") ? (
(() => {
const parts = option.label.split(" - ");
@ -593,8 +613,8 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
<span
className={
isSelected
? "font-bold text-[#181818]"
: "font-semibold text-[#344054]"
? "font-bold text-[#181818] block"
: "font-semibold text-[#344054] block"
}
>
{option.label}

19
src/components/Componentes/swipe-button.tsx

@ -2,6 +2,7 @@
import { useState } from "react";
import { useI18n } from "@/translations/provider";
import { LoadingSkeleton } from "./loading-skeleton";
import { LoadingThreeDot } from "./loading-three-dot";
type SwipeButtonProps = {
@ -10,6 +11,7 @@ type SwipeButtonProps = {
text: string;
cancelText?: string;
disabled?: boolean;
isLoading?: boolean;
theme?: "default" | "green";
};
@ -19,11 +21,28 @@ export function SwipeButton({
text,
cancelText,
disabled = false,
isLoading = false,
theme = "default",
}: SwipeButtonProps) {
const { dictionary: t } = useI18n();
const [clicked, setClicked] = useState(false);
if (isLoading) {
if (onCancel) {
return (
<div className="flex w-full items-center gap-3">
<LoadingSkeleton className="flex-1 h-[52px] rounded-[11px]" />
<LoadingSkeleton className="flex-1 h-[52px] rounded-[11px]" />
</div>
);
}
return (
<div className="w-full flex">
<LoadingSkeleton className="w-full h-[52px] rounded-[11px]" />
</div>
);
}
const handleClick = () => {
setClicked(true);
onSuccess();

67
src/components/Componentes/test-completed-sheet.test.tsx

@ -0,0 +1,67 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TestCompletedSheet } from "./test-completed-sheet";
vi.mock("@/translations/provider", () => ({
useI18n: () => ({
locale: "fa",
dictionary: {},
}),
}));
vi.mock("@/hooks/use-hardware-back-handler", () => ({
useHardwareBackHandler: vi.fn(),
}));
describe("TestCompletedSheet", () => {
afterEach(() => {
cleanup();
});
it("renders completion message and 'متوجه شدم' button when open", () => {
render(
<TestCompletedSheet
isOpen={true}
onClose={vi.fn()}
title="تست گلاسر"
/>,
);
expect(screen.getByRole("heading", { name: "تست گلاسر" })).toBeInTheDocument();
expect(
screen.getByText(
"شما این آزمون را قبلاً تکمیل کرده‌اید و امکان شرکت مجدد در آن وجود ندارد.",
),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "متوجه شدم" })).toBeInTheDocument();
});
it("calls onClose when clicking 'متوجه شدم' button", async () => {
const handleClose = vi.fn();
render(
<TestCompletedSheet
isOpen={true}
onClose={handleClose}
title="تست شخصیت‌شناسی"
/>,
);
const gotItBtn = screen.getByRole("button", { name: "متوجه شدم" });
fireEvent.click(gotItBtn);
await new Promise((r) => setTimeout(r, 260));
expect(handleClose).toHaveBeenCalled();
});
it("returns null when isOpen is false", () => {
const { container } = render(
<TestCompletedSheet
isOpen={false}
onClose={vi.fn()}
/>,
);
expect(container.firstChild).toBeNull();
});
});

61
src/components/Componentes/test-completed-sheet.tsx

@ -0,0 +1,61 @@
"use client";
import { useI18n } from "@/translations/provider";
import InformationSheet from "./information-sheet";
import SwipeButton from "./swipe-button";
export type TestCompletedSheetProps = {
isOpen: boolean;
onClose: () => void;
title?: string;
closeOnOutside?: boolean;
};
export function TestCompletedSheet({
isOpen,
onClose,
title,
closeOnOutside = true,
}: TestCompletedSheetProps) {
const { dictionary: t, locale } = useI18n();
if (!isOpen) {
return null;
}
const isFa = locale === "fa";
const sheetTitle =
title ||
(t as Record<string, string>)["Test Completed"] ||
(isFa ? "آزمون تکمیل شده است" : "Test Completed");
const message = isFa
? "شما این آزمون را قبلاً تکمیل کرده‌اید و امکان شرکت مجدد در آن وجود ندارد."
: (t as Record<string, string>)["You have already completed this test, and it cannot be retaken."] ||
"You have already completed this test, and it cannot be retaken.";
const buttonLabel = (t as Record<string, string>)["Got it"] || (isFa ? "متوجه شدم" : "Got it");
return (
<InformationSheet
icon="check"
title={sheetTitle}
description={
<p className="text-center mt-2 group-12 text-[#4D4D4D] leading-relaxed font-medium">
{message}
</p>
}
buttons={({ close }) => (
<SwipeButton
text={buttonLabel}
onSuccess={close}
/>
)}
closeOnOutside={closeOnOutside}
onClose={onClose}
/>
);
}
export default TestCompletedSheet;

92
src/components/Componentes/test-exit-sheet.test.tsx

@ -0,0 +1,92 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TestExitSheet } from "./test-exit-sheet";
vi.mock("@/translations/provider", () => ({
useI18n: () => ({
locale: "fa",
dictionary: {},
}),
}));
vi.mock("@/hooks/use-hardware-back-handler", () => ({
useHardwareBackHandler: vi.fn(),
}));
describe("TestExitSheet", () => {
afterEach(() => {
cleanup();
});
it("renders warning title and explanation points when open", () => {
render(
<TestExitSheet
isOpen={true}
onClose={vi.fn()}
onConfirmExit={vi.fn()}
/>,
);
expect(screen.getByRole("heading", { name: "خروج از آزمون" })).toBeInTheDocument();
expect(
screen.getByText("در صورت خروج از تست، ادامه فعلی حفظ نمی‌شود."),
).toBeInTheDocument();
expect(
screen.getByText("پاسخ‌های واردشده ذخیره نخواهند شد."),
).toBeInTheDocument();
expect(
screen.getByText("برای انجام دوباره تست باید از ابتدا شروع کنید."),
).toBeInTheDocument();
expect(screen.getByText("ادامه آزمون")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "خروج از آزمون" })).toBeInTheDocument();
});
it("calls onClose when clicking continue test (cancel)", async () => {
const handleClose = vi.fn();
const handleConfirm = vi.fn();
render(
<TestExitSheet
isOpen={true}
onClose={handleClose}
onConfirmExit={handleConfirm}
/>,
);
const continueBtn = screen.getByText("ادامه آزمون");
fireEvent.click(continueBtn);
await new Promise((r) => setTimeout(r, 260));
expect(handleClose).toHaveBeenCalled();
});
it("calls onConfirmExit when confirming exit", () => {
const handleClose = vi.fn();
const handleConfirm = vi.fn();
render(
<TestExitSheet
isOpen={true}
onClose={handleClose}
onConfirmExit={handleConfirm}
/>,
);
const exitBtn = screen.getByRole("button", { name: "خروج از آزمون" });
fireEvent.click(exitBtn);
expect(handleConfirm).toHaveBeenCalled();
});
it("returns null when isOpen is false", () => {
const { container } = render(
<TestExitSheet
isOpen={false}
onClose={vi.fn()}
onConfirmExit={vi.fn()}
/>,
);
expect(container.firstChild).toBeNull();
});
});

78
src/components/Componentes/test-exit-sheet.tsx

@ -0,0 +1,78 @@
"use client";
import { useI18n } from "@/translations/provider";
import InformationSheet from "./information-sheet";
import SwipeButton from "./swipe-button";
export type TestExitSheetProps = {
isOpen: boolean;
onClose: () => void;
onConfirmExit: () => void;
closeOnOutside?: boolean;
};
export function TestExitSheet({
isOpen,
onClose,
onConfirmExit,
closeOnOutside = true,
}: TestExitSheetProps) {
const { dictionary: t, locale } = useI18n();
if (!isOpen) {
return null;
}
const isFa = locale === "fa";
const tr = t as Record<string, string>;
const title = tr["Exit Test"] || (isFa ? "خروج از آزمون" : "Exit Test");
const points = isFa
? [
"در صورت خروج از تست، ادامه فعلی حفظ نمی‌شود.",
"پاسخ‌های واردشده ذخیره نخواهند شد.",
"برای انجام دوباره تست باید از ابتدا شروع کنید.",
]
: [
tr["If you exit the test, your current progress will not be saved."] ||
"If you exit the test, your current progress will not be saved.",
tr["Your entered answers will not be saved."] ||
"Your entered answers will not be saved.",
tr["To take the test again, you must start from the beginning."] ||
"To take the test again, you must start from the beginning.",
];
const cancelLabel =
tr["Continue Test"] || (isFa ? "ادامه آزمون" : "Continue Test");
const exitLabel =
tr["Exit Test"] || (isFa ? "خروج از آزمون" : "Exit Test");
return (
<InformationSheet
icon="warning"
title={title}
description={
<div className="flex flex-col gap-2 text-center mt-2 group-12 text-[#4D4D4D] leading-relaxed">
{points.map((pt, idx) => (
<p key={idx} className="font-medium">
{pt}
</p>
))}
</div>
}
buttons={({ close }) => (
<SwipeButton
text={exitLabel}
cancelText={cancelLabel}
onCancel={close}
onSuccess={onConfirmExit}
/>
)}
closeOnOutside={closeOnOutside}
onClose={onClose}
/>
);
}
export default TestExitSheet;

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

@ -1,8 +1,9 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { GoArrowLeft, GoArrowRight } from "react-icons/go";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useI18n } from "@/translations/provider";
import Button from "./button";
@ -10,6 +11,7 @@ import { ExplanationUiFont } from "./explanation-ui-font";
import NavigationButton from "./navigation-button";
import { PageBackground } from "./page-background";
import StickyHeader from "./sticky-header";
import TestExitSheet from "./test-exit-sheet";
import TestLoadingScreen from "./test-loading-screen";
export type QuestionOption = {
@ -37,36 +39,6 @@ type TestQuestionsFlowProps = {
onClose?: () => void;
draftStorageKey?: string | null;
};
type StoredTestDraft = {
answers?: Record<number, string | number>;
currentIndex?: number;
totalQuestions?: number;
};
function getStoredDraft(
storageKey: string | null | undefined,
totalQuestions: number,
) {
if (!storageKey || typeof window === "undefined")
return { answers: {}, currentIndex: 0 };
try {
const rawDraft = window.localStorage.getItem(storageKey);
if (!rawDraft) return { answers: {}, currentIndex: 0 };
const draft = JSON.parse(rawDraft) as StoredTestDraft;
return {
answers:
draft.answers && typeof draft.answers === "object" ? draft.answers : {},
currentIndex: Number.isInteger(draft.currentIndex)
? Math.min(
Math.max(draft.currentIndex ?? 0, 0),
Math.max(totalQuestions - 1, 0),
)
: 0,
};
} catch {
return { answers: {}, currentIndex: 0 };
}
}
function formatOptionLabel(str: string): string {
if (!str) return str;
@ -117,40 +89,17 @@ export default function TestQuestionsFlow({
}: TestQuestionsFlowProps) {
const router = useRouter();
const { locale } = useI18n();
const [currentIndex, setCurrentIndex] = useState(
() => getStoredDraft(draftStorageKey, questions.length).currentIndex,
);
const [answers, setAnswers] = useState<Record<number, string | number>>(
() => getStoredDraft(draftStorageKey, questions.length).answers,
);
const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState<Record<number, string | number>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isExitSheetOpen, setIsExitSheetOpen] = useState(false);
const isTargetTest =
!!draftStorageKey &&
(draftStorageKey.includes("personality_test") ||
draftStorageKey.includes("glasser_5_needs_test"));
const [maxVisitedIndex, setMaxVisitedIndex] = useState(() => {
const initialIndex = getStoredDraft(
draftStorageKey,
questions.length,
).currentIndex;
const initialAnswers = getStoredDraft(
draftStorageKey,
questions.length,
).answers;
let highestAnswered = -1;
for (let i = 0; i < questions.length; i++) {
if (initialAnswers[questions[i].id] !== undefined) {
highestAnswered = i;
}
}
const furthestReached =
highestAnswered !== -1
? Math.min(highestAnswered + 1, questions.length - 1)
: 0;
return Math.max(initialIndex, furthestReached);
});
const [maxVisitedIndex, setMaxVisitedIndex] = useState(0);
useEffect(() => {
if (Object.keys(answers).length === 0) {
@ -160,35 +109,47 @@ export default function TestQuestionsFlow({
}
}, [currentIndex, answers, maxVisitedIndex]);
const handleRequestClose = useCallback(() => {
setIsExitSheetOpen(true);
}, []);
useHardwareBackHandler(() => {
if (isExitSheetOpen) {
setIsExitSheetOpen(false);
return true;
}
setIsExitSheetOpen(true);
return true;
}, true);
const handleConfirmExit = useCallback(() => {
if (draftStorageKey) {
try {
window.localStorage.removeItem(draftStorageKey);
} catch {}
}
if (typeof window !== "undefined") {
try {
window.localStorage.removeItem("marriage:tests:personality_test:draft");
window.localStorage.removeItem("marriage:tests:glasser_5_needs_test:draft");
} catch {}
}
setAnswers({});
setCurrentIndex(0);
setIsExitSheetOpen(false);
if (onClose) {
onClose();
} else {
router.back();
}
}, [draftStorageKey, onClose, router]);
const currentQuestion = questions[currentIndex] ?? questions[0];
const totalQuestions = questions.length;
const isLastQuestion = currentIndex === totalQuestions - 1;
const selectedValue = currentQuestion
? answers[currentQuestion.id]
: undefined;
useEffect(() => {
if (!draftStorageKey) return;
try {
const match = draftStorageKey.match(
/^marriage:user:(\d+):tests:([^:]+):draft:v(\d+)$/,
);
const ownerProfileId = match ? Number(match[1]) : undefined;
const version = match ? Number(match[3]) : undefined;
const slug = match ? match[2] : undefined;
window.localStorage.setItem(
draftStorageKey,
JSON.stringify({
answers,
currentIndex,
totalQuestions,
...(ownerProfileId !== undefined ? { ownerProfileId } : {}),
...(version !== undefined ? { version } : {}),
...(slug !== undefined ? { slug } : {}),
}),
);
} catch {}
}, [answers, currentIndex, draftStorageKey, totalQuestions]);
const handleOptionSelect = (value: string | number) => {
if (!currentQuestion) return;
@ -222,8 +183,22 @@ export default function TestQuestionsFlow({
if (onFinish) {
await onFinish(answers);
}
if (draftStorageKey) window.localStorage.removeItem(draftStorageKey);
if (draftStorageKey) {
try {
window.localStorage.removeItem(draftStorageKey);
} catch {}
}
if (typeof window !== "undefined") {
try {
window.localStorage.removeItem("marriage:tests:personality_test:draft");
window.localStorage.removeItem("marriage:tests:glasser_5_needs_test:draft");
} catch {}
}
if (onClose) {
onClose();
} else {
router.back();
}
} catch {
// ignore
} finally {
@ -263,7 +238,7 @@ export default function TestQuestionsFlow({
variant="transparent"
icon="close"
iconLabel={closeLabel}
onClick={onClose}
onClick={handleRequestClose}
/>
<h1 className="min-w-0 flex-1 text-center font-semibold text-white truncate group-16">
{title}
@ -312,7 +287,7 @@ export default function TestQuestionsFlow({
key={q.id}
aria-hidden={offset !== 0}
className={[
"absolute inset-0 flex flex-col justify-start overflow-y-auto pt-9 pb-4 transition-transform duration-250 ease-[cubic-bezier(0.25,1,0.5,1)]",
"absolute inset-0 flex flex-col overflow-y-auto pt-4 pb-2 transition-transform duration-250 ease-[cubic-bezier(0.25,1,0.5,1)]",
offset === 0 ? "pointer-events-auto" : "pointer-events-none",
].join(" ")}
style={{
@ -321,13 +296,13 @@ export default function TestQuestionsFlow({
>
{/* Question Title */}
<h2
className="text-[17px] sm:text-[18px] font-bold text-[#1F2024] leading-[1.6] text-center px-3 mb-7 flex items-center justify-center shrink-0 min-h-[110px] sm:min-h-[120px]"
className="text-[17px] sm:text-[18px] font-bold text-[#1F2024] leading-[1.6] text-center px-3 mb-2 flex items-center justify-center shrink-0 min-h-[90px] sm:min-h-[100px]"
>
<span className="w-full">{q.text}</span>
</h2>
{/* Answer Options Stack */}
<div className="flex flex-col gap-3.5 pt-1">
<div className="flex-1 flex flex-col justify-center gap-3.5 my-auto pb-3">
{options.map((option) => {
const isSelected = qSelectedValue === option.value;
@ -438,6 +413,13 @@ export default function TestQuestionsFlow({
</div>
</div>
</main>
<TestExitSheet
isOpen={isExitSheetOpen}
onClose={() => setIsExitSheetOpen(false)}
onConfirmExit={handleConfirmExit}
/>
</>
);
}

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

@ -136,7 +136,7 @@ export function useFormSectionQuery(
queryFn: () => getFormSection(formId, slug, locale),
enabled,
staleTime: 30 * 1000,
refetchOnMount: false,
refetchOnMount: "always",
refetchOnWindowFocus: false,
});
}

25
src/lib/auth-bridge.ts

@ -57,13 +57,20 @@ class AuthBridge {
return false;
}
if (!token || token.trim() === "") {
const effectiveToken =
token && token.trim() !== ""
? token
: process.env.NEXT_PUBLIC_DEFAULT_TOKEN ||
process.env.NEXT_PUBLIC_AUTH_KEY ||
null;
if (!effectiveToken || effectiveToken === "NO_TOKEN") {
this.token = null;
setCachedMarriageEntryPath(null);
return false;
}
this.token = token;
this.token = effectiveToken;
this.coins = coinsValue ?? Number(coinsCookie ?? 0);
return true;
}
@ -258,6 +265,20 @@ class AuthBridge {
return effectiveCookie;
}
const defaultDevToken =
process.env.NEXT_PUBLIC_DEFAULT_TOKEN ||
process.env.NEXT_PUBLIC_AUTH_KEY;
if (
defaultDevToken &&
defaultDevToken.trim() !== "" &&
defaultDevToken !== "NO_TOKEN" &&
this.token !== "NO_TOKEN" &&
cookieToken !== "NO_TOKEN"
) {
return defaultDevToken;
}
return null;
}

639
src/lib/conditional-rules.test.ts

@ -200,4 +200,643 @@ describe("Conditional Rules Evaluator", () => {
expect(isQuestionVisible(q, matchingAnswers)).toBe(true);
expect(isQuestionRequired(q, matchingAnswers)).toBe(true);
});
it("should evaluate representative questions as optional for men and conditional for women based on age", () => {
const repNameQuestion: QuestionField = {
...dummyQuestion,
id: "contact_residence.representative_s_full_name",
title: "Representative's Full Name",
required: false,
baseRequired: false,
requiredWhen: {
genders: ["female"],
maxAge: 26,
},
};
const repPhoneQuestion: QuestionField = {
...dummyQuestion,
id: "contact_residence.representative_s_contact_number",
title: "Representative's Contact Number",
type: "phone",
required: false,
baseRequired: false,
requiredWhen: {
genders: ["female"],
maxAge: 26,
},
};
const repRelQuestion: QuestionField = {
...dummyQuestion,
id: "contact_residence.relationship_to_representative",
title: "Relationship to Representative",
type: "dropdown",
required: false,
baseRequired: false,
requiredWhen: {
genders: ["female"],
maxAge: 26,
},
};
const repQuestions = [repNameQuestion, repPhoneQuestion, repRelQuestion];
// For Male (regardless of age: 20, 26, 30): ALWAYS OPTIONAL
for (const age of [18, 20, 25, 26, 27, 35]) {
const maleContext = { gender: "male", age };
for (const question of repQuestions) {
expect(isQuestionRequired(question, {}, maleContext)).toBe(false);
}
}
// For Female <= 26: REQUIRED
for (const age of [18, 20, 25, 26]) {
const youngFemaleContext = { gender: "female", age };
for (const question of repQuestions) {
expect(isQuestionRequired(question, {}, youngFemaleContext)).toBe(true);
}
}
// For Female >= 27: OPTIONAL
for (const age of [27, 30, 35]) {
const olderFemaleContext = { gender: "female", age };
for (const question of repQuestions) {
expect(isQuestionRequired(question, {}, olderFemaleContext)).toBe(false);
}
}
// When gender is unknown: OPTIONAL
for (const question of repQuestions) {
expect(isQuestionRequired(question, {}, {})).toBe(false);
}
});
it("should correctly handle physical health 4-option visibility and medication requirement", () => {
// 1. Physical Health Description
const physicalHealthDescription: QuestionField = {
...dummyQuestion,
id: "appearance_health.physical_health_description",
title: "Physical Health Description",
required: false,
baseRequired: false,
visibility: {
parent_question_id: "appearance_health.physical_health_status",
trigger_option_ids: [
"appearance_health.physical_health_status.i_have_a_specific_or_chronic_illness",
"appearance_health.physical_health_status.i_have_a_disability_or_physical_limitation",
"appearance_health.physical_health_status.i_have_other_illness_or_physical_conditions",
],
operator: "any_of",
},
requiredWhen: {
parent_question_id: "appearance_health.physical_health_status",
trigger_option_ids: [
"appearance_health.physical_health_status.i_have_a_specific_or_chronic_illness",
"appearance_health.physical_health_status.i_have_a_disability_or_physical_limitation",
"appearance_health.physical_health_status.i_have_other_illness_or_physical_conditions",
],
operator: "any_of",
},
};
// Option A: No illness -> hidden and not required
const optionAAnswers = {
"appearance_health.physical_health_status": {
value: "i_currently_have_no_specific_illness_or_physical_limitation",
option_id:
"appearance_health.physical_health_status.i_currently_have_no_specific_illness_or_physical_limitation",
},
};
expect(isQuestionVisible(physicalHealthDescription, optionAAnswers)).toBe(false);
expect(isQuestionRequired(physicalHealthDescription, optionAAnswers)).toBe(false);
// Options B, C, D -> visible and required
const optionBAnswers = {
"appearance_health.physical_health_status": {
value: "i_have_a_specific_or_chronic_illness",
option_id:
"appearance_health.physical_health_status.i_have_a_specific_or_chronic_illness",
},
};
expect(isQuestionVisible(physicalHealthDescription, optionBAnswers)).toBe(true);
expect(isQuestionRequired(physicalHealthDescription, optionBAnswers)).toBe(true);
const optionCAnswers = {
"appearance_health.physical_health_status": {
value: "i_have_a_disability_or_physical_limitation",
option_id:
"appearance_health.physical_health_status.i_have_a_disability_or_physical_limitation",
},
};
expect(isQuestionVisible(physicalHealthDescription, optionCAnswers)).toBe(true);
expect(isQuestionRequired(physicalHealthDescription, optionCAnswers)).toBe(true);
const optionDAnswers = {
"appearance_health.physical_health_status": {
value: "i_have_other_illness_or_physical_conditions",
option_id:
"appearance_health.physical_health_status.i_have_other_illness_or_physical_conditions",
},
};
expect(isQuestionVisible(physicalHealthDescription, optionDAnswers)).toBe(true);
expect(isQuestionRequired(physicalHealthDescription, optionDAnswers)).toBe(true);
// 2. Medication Name and Reason
const medicationDescription: QuestionField = {
...dummyQuestion,
id: "appearance_health.medication_name_and_reason_for_use",
title: "Medication Name and Reason for Use",
required: false,
baseRequired: false,
visibility: {
parent_question_id:
"appearance_health.do_you_currently_take_any_medication_on_an_ongoing_basis",
trigger_option_ids: [
"appearance_health.do_you_currently_take_any_medication_on_an_ongoing_basis.yes",
],
operator: "any_of",
conditions: [
{
parent_question_id:
"appearance_health.are_you_currently_taking_medication_for_mental_health_related_issues",
trigger_option_ids: [
"appearance_health.are_you_currently_taking_medication_for_mental_health_related_issues.yes",
],
operator: "any_of",
},
],
root_operator: "any_of",
},
requiredWhen: {
parent_question_id:
"appearance_health.do_you_currently_take_any_medication_on_an_ongoing_basis",
trigger_option_ids: [
"appearance_health.do_you_currently_take_any_medication_on_an_ongoing_basis.yes",
],
operator: "any_of",
conditions: [
{
parent_question_id:
"appearance_health.are_you_currently_taking_medication_for_mental_health_related_issues",
trigger_option_ids: [
"appearance_health.are_you_currently_taking_medication_for_mental_health_related_issues.yes",
],
operator: "any_of",
},
],
root_operator: "any_of",
},
};
// Both No -> hidden
const medNoAnswers = {
"appearance_health.do_you_currently_take_any_medication_on_an_ongoing_basis": {
value: "no",
option_id:
"appearance_health.do_you_currently_take_any_medication_on_an_ongoing_basis.no",
},
"appearance_health.are_you_currently_taking_medication_for_mental_health_related_issues": {
value: "no",
option_id:
"appearance_health.are_you_currently_taking_medication_for_mental_health_related_issues.no",
},
};
expect(isQuestionVisible(medicationDescription, medNoAnswers)).toBe(false);
expect(isQuestionRequired(medicationDescription, medNoAnswers)).toBe(false);
// Mental health med Yes -> visible and required
const medMentalYesAnswers = {
"appearance_health.do_you_currently_take_any_medication_on_an_ongoing_basis": {
value: "no",
option_id:
"appearance_health.do_you_currently_take_any_medication_on_an_ongoing_basis.no",
},
"appearance_health.are_you_currently_taking_medication_for_mental_health_related_issues": {
value: "yes",
option_id:
"appearance_health.are_you_currently_taking_medication_for_mental_health_related_issues.yes",
},
};
expect(isQuestionVisible(medicationDescription, medMentalYesAnswers)).toBe(true);
expect(isQuestionRequired(medicationDescription, medMentalYesAnswers)).toBe(true);
// Ongoing med Yes -> visible and required
const medOngoingYesAnswers = {
"appearance_health.do_you_currently_take_any_medication_on_an_ongoing_basis": {
value: "yes",
option_id:
"appearance_health.do_you_currently_take_any_medication_on_an_ongoing_basis.yes",
},
"appearance_health.are_you_currently_taking_medication_for_mental_health_related_issues": {
value: "no",
option_id:
"appearance_health.are_you_currently_taking_medication_for_mental_health_related_issues.no",
},
};
expect(isQuestionVisible(medicationDescription, medOngoingYesAnswers)).toBe(true);
expect(isQuestionRequired(medicationDescription, medOngoingYesAnswers)).toBe(true);
});
it("should hide job title, work location, and monthly income for student, unemployed, student & job seeking, and homemaker", () => {
const jobTitle: QuestionField = {
...dummyQuestion,
id: "education_career.job_title",
title: "عنوان شغلی",
visibility: {
parent_question_id: "education_career.employment_status",
trigger_option_ids: [
"education_career.employment_status.full_time_employed",
"education_career.employment_status.part_time_employed",
"education_career.employment_status.self_employed_freelancer",
"education_career.employment_status.entrepreneur_business_owner",
"education_career.employment_status.working_student",
"education_career.employment_status.retired",
],
operator: "any_of",
},
};
const workLocation: QuestionField = {
...dummyQuestion,
id: "education_career.work_location",
title: "محل فعالیت",
visibility: {
parent_question_id: "education_career.employment_status",
trigger_option_ids: [
"education_career.employment_status.full_time_employed",
"education_career.employment_status.part_time_employed",
"education_career.employment_status.self_employed_freelancer",
"education_career.employment_status.entrepreneur_business_owner",
"education_career.employment_status.working_student",
],
operator: "any_of",
},
};
const monthlyIncome: QuestionField = {
...dummyQuestion,
id: "education_career.monthly_income",
title: "میزان درآمد ماهانه",
required: false,
visibility: {
parent_question_id: "education_career.employment_status",
trigger_option_ids: [
"education_career.employment_status.full_time_employed",
"education_career.employment_status.part_time_employed",
"education_career.employment_status.self_employed_freelancer",
"education_career.employment_status.entrepreneur_business_owner",
"education_career.employment_status.working_student",
"education_career.employment_status.retired",
],
operator: "any_of",
},
requiredWhen: {
parent_question_id: "education_career.employment_status",
trigger_option_ids: [
"education_career.employment_status.full_time_employed",
"education_career.employment_status.part_time_employed",
"education_career.employment_status.self_employed_freelancer",
"education_career.employment_status.entrepreneur_business_owner",
"education_career.employment_status.working_student",
"education_career.employment_status.retired",
],
operator: "any_of",
},
};
// Hidden cases: دانشجو, دانشجو و جویای کار, جویای کار / بیکار, خانه‌دار
const hiddenStatuses = [
"student",
"student_and_job_seeking",
"job_seeking_unemployed",
"homemaker",
];
for (const status of hiddenStatuses) {
const answers = {
"education_career.employment_status": {
value: status,
option_id: `education_career.employment_status.${status}`,
},
};
expect(isQuestionVisible(jobTitle, answers)).toBe(false);
expect(isQuestionVisible(workLocation, answers)).toBe(false);
expect(isQuestionVisible(monthlyIncome, answers)).toBe(false);
expect(isQuestionRequired(monthlyIncome, answers)).toBe(false);
}
// Visible case: Full time employed
const fullTimeAnswers = {
"education_career.employment_status": {
value: "full_time_employed",
option_id: "education_career.employment_status.full_time_employed",
},
};
expect(isQuestionVisible(jobTitle, fullTimeAnswers)).toBe(true);
expect(isQuestionVisible(workLocation, fullTimeAnswers)).toBe(true);
expect(isQuestionVisible(monthlyIncome, fullTimeAnswers)).toBe(true);
expect(isQuestionRequired(monthlyIncome, fullTimeAnswers)).toBe(true);
// Visible case: Working student
const workingStudentAnswers = {
"education_career.employment_status": {
value: "working_student",
option_id: "education_career.employment_status.working_student",
},
};
expect(isQuestionVisible(jobTitle, workingStudentAnswers)).toBe(true);
expect(isQuestionVisible(workLocation, workingStudentAnswers)).toBe(true);
expect(isQuestionVisible(monthlyIncome, workingStudentAnswers)).toBe(true);
expect(isQuestionRequired(monthlyIncome, workingStudentAnswers)).toBe(true);
// Retired case: Job title and monthly income visible, work location hidden
const retiredAnswers = {
"education_career.employment_status": {
value: "retired",
option_id: "education_career.employment_status.retired",
},
};
expect(isQuestionVisible(jobTitle, retiredAnswers)).toBe(true);
expect(isQuestionVisible(workLocation, retiredAnswers)).toBe(false);
expect(isQuestionVisible(monthlyIncome, retiredAnswers)).toBe(true);
expect(isQuestionRequired(monthlyIncome, retiredAnswers)).toBe(true);
});
it("should show parents marital status only when both parents are alive (Option A) and hide for options B, C, D", () => {
const parentsMaritalStatus: QuestionField = {
...dummyQuestion,
id: "family_background.parents_marital_status",
title: "وضعیت تأهل والدین",
required: false,
visibility: {
parent_question_id: "family_background.parents_survival_status",
trigger_option_ids: [
"family_background.parents_survival_status.both_parents_are_alive",
],
operator: "any_of",
},
requiredWhen: {
parent_question_id: "family_background.parents_survival_status",
trigger_option_ids: [
"family_background.parents_survival_status.both_parents_are_alive",
],
operator: "any_of",
},
};
// Option A: Both parents are alive -> Visible & Required
const optionAAnswers = {
"family_background.parents_survival_status": {
value: "both_parents_are_alive",
option_id: "family_background.parents_survival_status.both_parents_are_alive",
},
};
expect(isQuestionVisible(parentsMaritalStatus, optionAAnswers)).toBe(true);
expect(isQuestionRequired(parentsMaritalStatus, optionAAnswers)).toBe(true);
// Option B: Father has passed away -> Hidden & Not Required
const optionBAnswers = {
"family_background.parents_survival_status": {
value: "father_has_passed_away",
option_id: "family_background.parents_survival_status.father_has_passed_away",
},
};
expect(isQuestionVisible(parentsMaritalStatus, optionBAnswers)).toBe(false);
expect(isQuestionRequired(parentsMaritalStatus, optionBAnswers)).toBe(false);
// Option C: Mother has passed away -> Hidden & Not Required
const optionCAnswers = {
"family_background.parents_survival_status": {
value: "mother_has_passed_away",
option_id: "family_background.parents_survival_status.mother_has_passed_away",
},
};
expect(isQuestionVisible(parentsMaritalStatus, optionCAnswers)).toBe(false);
expect(isQuestionRequired(parentsMaritalStatus, optionCAnswers)).toBe(false);
// Option D: Both parents have passed away -> Hidden & Not Required
const optionDAnswers = {
"family_background.parents_survival_status": {
value: "both_parents_have_passed_away",
option_id: "family_background.parents_survival_status.both_parents_have_passed_away",
},
};
expect(isQuestionVisible(parentsMaritalStatus, optionDAnswers)).toBe(false);
expect(isQuestionRequired(parentsMaritalStatus, optionDAnswers)).toBe(false);
});
it("should show family responsibility follow-up questions (live with you, additional details) and make them required only when responsibility options are selected", () => {
const parentId = "family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member";
const triggerOptionIds = [
`${parentId}.i_am_responsible_for_caring_for_my_father`,
`${parentId}.i_am_responsible_for_caring_for_my_mother`,
`${parentId}.i_am_responsible_for_caring_for_both_parents`,
`${parentId}.i_am_responsible_for_caring_for_a_sibling_brother_sister`,
`${parentId}.i_am_the_legal_guardian_or_supervisor_of_a_family_member`,
`${parentId}.i_regularly_provide_financial_support_for_a_family_member_s_living_expenses`,
`${parentId}.i_have_other_circumstances_and_will_explain_in_the_description`,
`${parentId}.i_am_responsible_for_caring_for_my_parent_s_father_mother_or_both`,
];
const liveWithYou: QuestionField = {
...dummyQuestion,
id: "family_background.do_the_supported_individual_s_live_with_you",
title: "آیا فرد یا افراد تحت حمایت با شما زندگی می‌کنند؟",
required: false,
visibility: {
parent_question_id: parentId,
trigger_option_ids: triggerOptionIds,
operator: "any_of",
},
requiredWhen: {
parent_question_id: parentId,
trigger_option_ids: triggerOptionIds,
operator: "any_of",
},
};
const additionalDetails: QuestionField = {
...dummyQuestion,
id: "family_background.additional_details_about_family_responsibility",
title: "توضیحات تکمیلی درباره مسئولیت خانوادگی",
required: false,
visibility: {
parent_question_id: parentId,
trigger_option_ids: triggerOptionIds,
operator: "any_of",
},
requiredWhen: {
parent_question_id: parentId,
trigger_option_ids: triggerOptionIds,
operator: "any_of",
},
};
// Case 1: No ongoing responsibility selected -> Hidden & Not Required
const noRespAnswers = {
[parentId]: {
value: ["خیر، مسئولیت مستمری ندارم."],
option_id: [`${parentId}.no_i_do_not_have_any_ongoing_responsibility`],
},
};
expect(isQuestionVisible(liveWithYou, noRespAnswers)).toBe(false);
expect(isQuestionRequired(liveWithYou, noRespAnswers)).toBe(false);
expect(isQuestionVisible(additionalDetails, noRespAnswers)).toBe(false);
expect(isQuestionRequired(additionalDetails, noRespAnswers)).toBe(false);
// Case 2: Responsibility selected (e.g. Caring for father) -> Visible & Required
const fatherRespAnswers = {
[parentId]: {
value: ["مسئولیت مراقبت از پدر را بر عهده دارم."],
option_id: [`${parentId}.i_am_responsible_for_caring_for_my_father`],
},
};
expect(isQuestionVisible(liveWithYou, fatherRespAnswers)).toBe(true);
expect(isQuestionRequired(liveWithYou, fatherRespAnswers)).toBe(true);
expect(isQuestionVisible(additionalDetails, fatherRespAnswers)).toBe(true);
expect(isQuestionRequired(additionalDetails, fatherRespAnswers)).toBe(true);
// Case 3: Multiple responsibilities selected -> Visible & Required
const multiRespAnswers = {
[parentId]: {
value: ["مسئولیت مراقبت از پدر", "حمایت مالی"],
option_id: [
`${parentId}.i_am_responsible_for_caring_for_my_father`,
`${parentId}.i_regularly_provide_financial_support_for_a_family_member_s_living_expenses`,
],
},
};
expect(isQuestionVisible(liveWithYou, multiRespAnswers)).toBe(true);
expect(isQuestionRequired(liveWithYou, multiRespAnswers)).toBe(true);
expect(isQuestionVisible(additionalDetails, multiRespAnswers)).toBe(true);
expect(isQuestionRequired(additionalDetails, multiRespAnswers)).toBe(true);
});
it("should handle Section 6 marital history and children follow-up rules properly", () => {
const mStatusId = "marital_history.current_marital_status";
const previousTriggerIds = [
`${mStatusId}.failed_engagement_annulled_marriage_without_living_together`,
`${mStatusId}.failed_marriage_contract_annulled_engagement_without_starting_joint_life`,
`${mStatusId}.divorced_after_living_together`,
`${mStatusId}.widowed`,
];
const divorcedOrAnnulledIds = [
`${mStatusId}.failed_engagement_annulled_marriage_without_living_together`,
`${mStatusId}.failed_marriage_contract_annulled_engagement_without_starting_joint_life`,
`${mStatusId}.divorced_after_living_together`,
];
const cStatusId = "marital_history.children_and_guardianship_status";
const hasChildrenTriggerIds = [
`${cStatusId}.have_children_living_with_me`,
`${cStatusId}.have_children_not_living_with_me`,
];
const prevDuration: QuestionField = {
...dummyQuestion,
id: "marital_history.previous_marriage_duration",
title: "مدت ازدواج یا عقد قبلی",
required: false,
visibility: { parent_question_id: mStatusId, trigger_option_ids: previousTriggerIds, operator: "any_of" },
requiredWhen: { parent_question_id: mStatusId, trigger_option_ids: previousTriggerIds, operator: "any_of" },
};
const reasonSeparation: QuestionField = {
...dummyQuestion,
id: "marital_history.reason_for_separation",
title: "علت جدایی، در صورت وجود",
required: false,
visibility: { parent_question_id: mStatusId, trigger_option_ids: divorcedOrAnnulledIds, operator: "any_of" },
};
const childrenStatus: QuestionField = {
...dummyQuestion,
id: "marital_history.children_and_guardianship_status",
title: "وضعیت فرزند و تکفل",
required: false,
visibility: { parent_question_id: mStatusId, trigger_option_ids: previousTriggerIds, operator: "any_of" },
requiredWhen: { parent_question_id: mStatusId, trigger_option_ids: previousTriggerIds, operator: "any_of" },
};
const numChildren: QuestionField = {
...dummyQuestion,
id: "marital_history.number_of_children",
title: "تعداد فرزندان",
required: false,
visibility: { parent_question_id: cStatusId, trigger_option_ids: hasChildrenTriggerIds, operator: "any_of" },
requiredWhen: { parent_question_id: cStatusId, trigger_option_ids: hasChildrenTriggerIds, operator: "any_of" },
};
const custodyStatus: QuestionField = {
...dummyQuestion,
id: "marital_history.what_is_the_custody_status_of_your_child_ren",
title: "وضعیت حضانت فرزند یا فرزندان شما چگونه است؟",
required: false,
visibility: { parent_question_id: cStatusId, trigger_option_ids: hasChildrenTriggerIds, operator: "any_of" },
requiredWhen: { parent_question_id: cStatusId, trigger_option_ids: hasChildrenTriggerIds, operator: "any_of" },
};
// 1. Single: previous duration, reason for separation, and children status are all HIDDEN
const singleAns = {
[mStatusId]: {
value: "single_never_married",
option_id: `${mStatusId}.single_never_married`,
},
};
expect(isQuestionVisible(prevDuration, singleAns)).toBe(false);
expect(isQuestionVisible(reasonSeparation, singleAns)).toBe(false);
expect(isQuestionVisible(childrenStatus, singleAns)).toBe(false);
// 2. Divorced: duration, reason, and children status are all VISIBLE
const divorcedAns = {
[mStatusId]: {
value: "divorced_after_living_together",
option_id: `${mStatusId}.divorced_after_living_together`,
},
};
expect(isQuestionVisible(prevDuration, divorcedAns)).toBe(true);
expect(isQuestionRequired(prevDuration, divorcedAns)).toBe(true);
expect(isQuestionVisible(reasonSeparation, divorcedAns)).toBe(true);
expect(isQuestionVisible(childrenStatus, divorcedAns)).toBe(true);
expect(isQuestionRequired(childrenStatus, divorcedAns)).toBe(true);
// 3. Widowed: duration & children VISIBLE, reason for separation HIDDEN
const widowedAns = {
[mStatusId]: {
value: "widowed",
option_id: `${mStatusId}.widowed`,
},
};
expect(isQuestionVisible(prevDuration, widowedAns)).toBe(true);
expect(isQuestionRequired(prevDuration, widowedAns)).toBe(true);
expect(isQuestionVisible(reasonSeparation, widowedAns)).toBe(false);
expect(isQuestionVisible(childrenStatus, widowedAns)).toBe(true);
expect(isQuestionRequired(childrenStatus, widowedAns)).toBe(true);
// 4. Children follow-ups: when children exist -> VISIBLE & REQUIRED
const hasChildAns = {
[cStatusId]: {
value: "have_children_living_with_me",
option_id: `${cStatusId}.have_children_living_with_me`,
},
};
expect(isQuestionVisible(numChildren, hasChildAns)).toBe(true);
expect(isQuestionRequired(numChildren, hasChildAns)).toBe(true);
expect(isQuestionVisible(custodyStatus, hasChildAns)).toBe(true);
expect(isQuestionRequired(custodyStatus, hasChildAns)).toBe(true);
// 5. No children: child questions HIDDEN & NOT REQUIRED
const noChildAns = {
[cStatusId]: {
value: "no_children",
option_id: `${cStatusId}.no_children`,
},
};
expect(isQuestionVisible(numChildren, noChildAns)).toBe(false);
expect(isQuestionRequired(numChildren, noChildAns)).toBe(false);
expect(isQuestionVisible(custodyStatus, noChildAns)).toBe(false);
expect(isQuestionRequired(custodyStatus, noChildAns)).toBe(false);
});
});

41
src/lib/conditional-rules.ts

@ -58,6 +58,16 @@ export function canonicalRule(rule: any): CanonicalRule | null {
if (rule.audience && typeof rule.audience === "object") {
result.audience = rule.audience;
} else if (
rule.genders ||
rule.minAge !== undefined ||
rule.maxAge !== undefined
) {
result.audience = {
genders: rule.genders,
minAge: rule.minAge,
maxAge: rule.maxAge,
};
}
if (Array.isArray(rule.conditions)) {
@ -140,19 +150,24 @@ export function matchesAudience(
}
if (audience.genders && audience.genders.length > 0) {
if (context?.gender && !audience.genders.includes(context.gender)) {
if (
!context?.gender ||
!audience.genders
.map((g) => g.toLowerCase())
.includes(context.gender.toLowerCase())
) {
return false;
}
}
if (audience.minAge !== undefined && context?.age !== undefined && context.age !== null) {
if (context.age < audience.minAge) {
if (audience.minAge !== undefined) {
if (context?.age === undefined || context.age === null || context.age < audience.minAge) {
return false;
}
}
if (audience.maxAge !== undefined && context?.age !== undefined && context.age !== null) {
if (context.age > audience.maxAge) {
if (audience.maxAge !== undefined) {
if (context?.age === undefined || context.age === null || context.age > audience.maxAge) {
return false;
}
}
@ -250,7 +265,9 @@ export function ruleMatches(
: mainMatches && conditionsResult;
}
return mainMatches;
return parentId
? mainMatches
: Boolean(!rule.audience || matchesAudience(rule.audience, context));
}
export function isQuestionVisible(
@ -289,16 +306,16 @@ export function isQuestionRequired(
return false;
}
if (question.required || question.baseRequired) {
return true;
}
if (question.requiredWhen) {
if (question.requiredWhen.genders || question.requiredWhen.minAge || question.requiredWhen.maxAge) {
if (
question.requiredWhen.genders ||
question.requiredWhen.minAge !== undefined ||
question.requiredWhen.maxAge !== undefined
) {
return matchesAudience(question.requiredWhen, context);
}
return ruleMatches(question.requiredWhen, answers, context);
}
return false;
return Boolean(question.baseRequired ?? question.required);
}

66
src/lib/schema-adapter.ts

@ -46,6 +46,49 @@ export const sectionSlugIconMap: Record<string, QuestionCardIcon> = {
glasser_test: "glasser",
};
export const sectionEstimatedMinutesMap: Record<string, number> = {
personal_info: 2,
personal_identity: 2,
contact_residence_family_communication: 2,
contact_residence: 2,
appearance_health_activity: 2,
appearance_health: 2,
education_career_economic_status: 3,
education_career: 3,
family_background: 3,
marital_history_children: 2,
marital_history: 2,
beliefs_lifestyle_boundaries: 4,
beliefs_lifestyle: 4,
personality_test: 16,
cattell_test: 16,
glasser_5_needs_test: 5,
glasser_test: 5,
future_spouse_criteria: 6,
spouse_criteria: 6,
identity_verification: 2,
documents_verification: 2,
};
export function resolveSectionEstimatedMinutes(
slug: string,
backendEstimatedMinutes?: number | null,
): number {
if (
(slug === "personality_test" || slug === "cattell_test") &&
(!backendEstimatedMinutes || backendEstimatedMinutes === 8)
) {
return 16;
}
if (typeof backendEstimatedMinutes === "number" && backendEstimatedMinutes > 0) {
return backendEstimatedMinutes;
}
if (slug && sectionEstimatedMinutesMap[slug]) {
return sectionEstimatedMinutesMap[slug];
}
return 3;
}
export function resolveSectionIcon(
slug: string,
backendIcon?: string,
@ -231,12 +274,15 @@ export function mapBackendSectionToFrontend(
});
});
const estMinutes = resolveSectionEstimatedMinutes(
section.id,
section.estimated_minutes,
);
return {
slug: section.id,
title: section.title,
estimate: section.estimated_minutes
? `${section.estimated_minutes} min`
: "5 min",
estimate: `${estMinutes} min`,
progress: progress,
icon: resolveSectionIcon(section.id, section.icon),
required: section.is_required,
@ -273,12 +319,15 @@ export function convertOverviewToFrontendItems(
if (!overview) return [];
return [...overview.sections]
.sort((a, b) => a.order - b.order)
.map((section) => ({
.map((section) => {
const estMinutes = resolveSectionEstimatedMinutes(
section.id,
section.estimated_minutes,
);
return {
slug: section.id,
title: section.title,
estimate: section.estimated_minutes
? `${section.estimated_minutes} min`
: "5 min",
estimate: `${estMinutes} min`,
progress: section.progress?.completion_percent ?? 0,
icon: resolveSectionIcon(section.id, section.icon),
required: section.is_required,
@ -287,5 +336,6 @@ export function convertOverviewToFrontendItems(
checkpoints: [],
tooltip: "",
questions: [],
}));
};
});
}

7
src/translations/locales/ar.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "أدخل اسم الدواء وسبب الاستخدام...",
"وضعیت سلامت جسمانی": "حالة الصحة الجسدية",
"توضیحات وضعیت جسمانی": "وصف الصحة الجسدية",
"Name": "الاسم"
"Name": "الاسم",
"upload_certificates": "تحميل الشهادات والوثائق",
"add_another_document": "إضافة وثيقة أخرى",
"max_files_reached": "تم تحميل الحد الأقصى (4 ملفات)",
"remove_document": "حذف الوثيقة",
"upload_failed": "فشل التحميل. يرجى المحاولة مرة أخرى."
}

7
src/translations/locales/az.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "Dərmanın adını və istifadə səbəbini daxil edin...",
"وضعیت سلامت جسمانی": "Fiziki Sağlamlıq Vəziyyəti",
"توضیحات وضعیت جسمانی": "Fiziki Sağlamlıq Təsviri",
"Name": "Ad"
"Name": "Ad",
"upload_certificates": "Sənədləri yükləyin",
"add_another_document": "Başqa sənəd əlavə edin",
"max_files_reached": "Maksimum 4 fayl yükləndi",
"remove_document": "Sənədi sil",
"upload_failed": "Yükləmə uğursuz oldu. Yenidən cəhd edin."
}

7
src/translations/locales/bn.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "ওষুধের নাম এবং কারণ লিখুন...",
"وضعیت سلامت جسمانی": "শারীরিক স্বাস্থ্যের অবস্থা",
"توضیحات وضعیت جسمانی": "শারীরিক স্বাস্থ্যের বিবরণ",
"Name": "নাম"
"Name": "নাম",
"upload_certificates": "নথিপত্র আপলোড করুন",
"add_another_document": "অন্য নথি যোগ করুন",
"max_files_reached": "সর্বোচ্চ ৪টি ফাইল আপলোড করা হয়েছে",
"remove_document": "নথি মুছুন",
"upload_failed": "আপলোড ব্যর্থ হয়েছে। আবার চেষ্টা করুন।"
}

7
src/translations/locales/da.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "Indtast medicinnavn og årsag...",
"وضعیت سلامت جسمانی": "Fysisk helbredstilstand",
"توضیحات وضعیت جسمانی": "Beskrivelse af fysisk helbred",
"Name": "Navn"
"Name": "Navn",
"upload_certificates": "Upload certifikater",
"add_another_document": "Tilføj et andet dokument",
"max_files_reached": "Maksimalt 4 filer uploadet",
"remove_document": "Fjern dokument",
"upload_failed": "Upload mislykkedes. Prøv igen."
}

7
src/translations/locales/de.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "Geben Sie den Medikamentennamen und den Grund ein...",
"وضعیت سلامت جسمانی": "Körperlicher Gesundheitszustand",
"توضیحات وضعیت جسمانی": "Beschreibung des körperlichen Zustands",
"Name": "Name"
"Name": "Name",
"upload_certificates": "Zertifikate hochladen",
"add_another_document": "Ein weiteres Dokument hinzufügen",
"max_files_reached": "Maximal 4 Dateien hochgeladen",
"remove_document": "Dokument entfernen",
"upload_failed": "Upload fehlgeschlagen. Bitte versuchen Sie es erneut."
}

21
src/translations/locales/en.json

@ -248,8 +248,8 @@
"General Health:": "General Health:",
"German": "German",
"Germany": "Germany",
"Get Advisor": "Get Advisor",
"Get an advisor": "Get an advisor",
"Get Advisor": "Talk to Advisor",
"Get an advisor": "Need Marriage Guidance?",
"Glasser 5 Needs Test": "Glasser 5 Needs Test",
"Go back": "Go back",
"Good": "Good",
@ -377,7 +377,8 @@
"Next": "Next",
"Next Page": "Next Page",
"No Active Subscription": "No Active Subscription",
"No Contact Received": "No Contact Received",
"No Contact": "No Contact",
"No Contact Received": "No Contact",
"No Hijab (Casual/Modern) - Modern styling and casual outfits.": "No Hijab (Casual/Modern) - Modern styling and casual outfits.",
"No Hijab (Modest styling) - Dignified modest attire without headscarf.": "No Hijab (Modest styling) - Dignified modest attire without headscarf.",
"No ceremony or very simple": "No ceremony or very simple",
@ -406,7 +407,7 @@
"Not a good personal fit": "Not a good personal fit",
"Not committed": "Not committed",
"Not important": "Not important",
"Not sure what to do next? Our psychology section is here to guide you at every step.": "Not sure what to do next? Our psychology section is here to guide you at every step.",
"Not sure what to do next? Our psychology section is here to guide you at every step.": "Unsure about your next step? Our expert counselors are here to help you make confident decisions.",
"Nothing is shared without your consent.": "Nothing is shared without your consent.",
"Number of Children": "Number of Children",
"Number of Siblings": "Number of Siblings",
@ -690,10 +691,10 @@
"Very religious and committed": "Very religious and committed",
"View Contact": "View Contact",
"View Contact Details": "View Contact Details",
"View More Details": "View More Details",
"View More Details": "View Full Profile",
"View Profile": "View Profile",
"View contact number": "View contact number",
"View more details": "View more details",
"View more details": "View Full Profile",
"View profile": "View profile",
"Watch Video": "Watch Video",
"We are in the acquaintance/proposal process and nothing is finalized yet": "We are in the acquaintance/proposal process and nothing is finalized yet",
@ -741,6 +742,7 @@
"Your details are only used for the matching process.": "Your details are only used for the matching process.",
"Your information is kept strictly confidential.": "Your information is kept strictly confidential.",
"Your privacy and safety are our top priorities. We are committed to keeping your information secure and giving you full control throughout the process.": "Your privacy and safety are our top priorities. We are committed to keeping your information secure and giving you full control throughout the process.",
"Your request has been sent. Once the gentleman reviews your request, you will be notified.": "Your request has been sent. Once the gentleman reviews your request, you will be notified.",
"Your request has been sent. Once the lady reviews your request, you will be notified.": "Your request has been sent. Once the lady reviews your request, you will be notified.",
"Your request was rejected": "Your request was rejected",
"Your request was rejected by the lady. You will be introduced to other candidates in the future.": "Your request was rejected by the lady. You will be introduced to other candidates in the future.",
@ -845,5 +847,10 @@
"Medication Name and Reason for Use": "Medication Name and Reason for Use",
"Enter medication name and reason for use...": "Enter medication name and reason for use...",
"Enter a valid phone number with country code.": "Enter a valid phone number with country code.",
"Name": "Name"
"Name": "Name",
"upload_certificates": "Upload certificates",
"add_another_document": "Add another document",
"max_files_reached": "Maximum of 4 files uploaded",
"remove_document": "Remove document",
"upload_failed": "Upload failed. Please try again."
}

7
src/translations/locales/es.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "Ingrese el nombre del medicamento y el motivo...",
"وضعیت سلامت جسمانی": "Estado de salud física",
"توضیحات وضعیت جسمانی": "Descripción de la salud física",
"Name": "Nombre"
"Name": "Nombre",
"upload_certificates": "Subir certificados",
"add_another_document": "Agregar otro documento",
"max_files_reached": "Máximo de 4 archivos subidos",
"remove_document": "Eliminar documento",
"upload_failed": "Error al subir. Por favor, inténtelo de nuevo."
}

20
src/translations/locales/fa.json

@ -248,8 +248,8 @@
"General Health:": "سلامت عمومی:",
"German": "آلمانی",
"Germany": "آلمان",
"Get Advisor": "دریافت مشاور",
"Get an advisor": "دریافت مشاور",
"Get Advisor": "گفتگو با مشاور",
"Get an advisor": "نیاز به راهنمایی دارید؟",
"Glasser 5 Needs Test": "تست ۵ نیاز گلاسر",
"Go back": "بازگشت",
"Good": "خوب",
@ -377,6 +377,7 @@
"Next": "بعدی",
"Next Page": "صفحه بعدی",
"No Active Subscription": "فاقد اشتراک فعال",
"No Contact": "عدم تماس",
"No Contact Received": "عدم دریافت تماس",
"No Hijab (Casual/Modern) - Modern styling and casual outfits.": "پوشش مدرن و آزاد (بدون رعایت حجاب) - دنبال کردن استایل‌های روز بدون پایبندی به قواعد حجاب اسلامی.",
"No Hijab (Modest styling) - Dignified modest attire without headscarf.": "پوشش آراسته و سنگین (بدون پوشش مو) - لباس‌های رسمی و موقر بدون استفاده از روسری یا شال.",
@ -406,7 +407,7 @@
"Not a good personal fit": "تناسب شخصی کافی نبود",
"Not committed": "مقید نیستم",
"Not important": "این معیار برایم اهمیت زیادی ندارد.",
"Not sure what to do next? Our psychology section is here to guide you at every step.": "نمی‌دانید قدم بعدی چیست؟ بخش روانشناسی ما در هر مرحله شما را راهنمایی می‌کند.",
"Not sure what to do next? Our psychology section is here to guide you at every step.": "در تصمیم‌گیری یا ادامه مسیر مردد هستید؟ مشاوران متخصص ما در تمام مراحل آشنایی همراه شما هستند.",
"Nothing is shared without your consent.": "هیچ چیز بدون رضایت شما به اشتراک گذاشته نمی‌شود.",
"Number of Children": "تعداد فرزندان",
"Number of Siblings": "تعداد خواهر و برادر",
@ -690,10 +691,10 @@
"Very religious and committed": "بسیار مذهبی و مقید",
"View Contact": "مشاهده تماس",
"View Contact Details": "مشاهده شماره تماس",
"View More Details": "مشاهده جزئیات بیشتر",
"View More Details": "مشاهده مشخصات کامل و ادامه فرایند",
"View Profile": "مشاهده پروفایل",
"View contact number": "مشاهده شماره تماس",
"View more details": "مشاهده جزئیات بیشتر",
"View more details": "مشاهده مشخصات کامل و ادامه فرایند",
"View profile": "مشاهده پروفایل",
"Watch Video": "مشاهده ویدیو",
"We are in the acquaintance/proposal process and nothing is finalized yet": "در مسیر آشنایی و خواستگاری هستیم و هنوز هیچی نهایی نشده",
@ -740,7 +741,7 @@
"Your Personality Traits": "ویژگی‌های شخصیتی خودتان",
"Your details are only used for the matching process.": "اطلاعات شما فقط برای فرآیند تطبیق‌دهی استفاده می‌شود.",
"Your information is kept strictly confidential.": "اطلاعات شما کاملاً محرمانه نگهداری می‌شود.",
"Your privacy and safety are our top priorities. We are committed to keeping your information secure and giving you full control throughout the process.": "حفظ حریم خصوصی و امنیت شما بالاترین اولویت ماست. ما متعهد به حفظ امنیت اطلاعات شما و اعطای کنترل کامل به شما در طول فرآیند هستیم.",
"Your request has been sent. Once the gentleman reviews your request, you will be notified.": "درخواست شما ارسال شد. پس از بررسی درخواست شما توسط آقا، به شما اطلاع‌رسانی خواهد شد.",
"Your request has been sent. Once the lady reviews your request, you will be notified.": "درخواست شما ارسال شد. پس از بررسی درخواست شما توسط خانم، به شما اطلاع‌رسانی خواهد شد.",
"Your request was rejected": "درخواست شما رد شد",
"Your request was rejected by the lady. You will be introduced to other candidates in the future.": "درخواست شما توسط خانم رد شد. به شما مورد های دیگه ای در اینده معرفی خواهد شد.",
@ -856,5 +857,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "نام دارو و دلیل مصرف را وارد نمایید...",
"وضعیت سلامت جسمانی": "وضعیت سلامت جسمانی",
"توضیحات وضعیت جسمانی": "توضیحات وضعیت جسمانی",
"Name": "نام"
"Name": "نام",
"upload_certificates": "بارگذاری مدارک",
"add_another_document": "افزودن مدرک جدید",
"max_files_reached": "حداکثر ۴ فایل بارگذاری شده است",
"remove_document": "حذف مدرک",
"upload_failed": "بارگذاری با خطا مواجه شد. لطفاً دوباره تلاش کنید."
}

7
src/translations/locales/fr.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "Entrez le nom du médicament et le motif...",
"وضعیت سلامت جسمانی": "État de santé physique",
"توضیحات وضعیت جسمانی": "Description de la santé physique",
"Name": "Nom"
"Name": "Nom",
"upload_certificates": "Télécharger les certificats",
"add_another_document": "Ajouter un autre document",
"max_files_reached": "Maximum de 4 fichiers téléchargés",
"remove_document": "Supprimer le document",
"upload_failed": "Échec du téléchargement. Veuillez réessayer."
}

7
src/translations/locales/gu.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "દવાનું નામ અને કારણ દાખલ કરો...",
"وضعیت سلامت جسمانی": "શારીરિક સ્વાસ્થ્ય સ્થિતિ",
"توضیحات وضعیت جسمانی": "શારીરિક સ્વાસ્થ્ય વર્ણન",
"Name": "નામ"
"Name": "નામ",
"upload_certificates": "પ્રમાણપત્રો અપલોડ કરો",
"add_another_document": "બીજો દસ્તાવેજ ઉમેરો",
"max_files_reached": "મહત્તમ 4 ફાઇલો અપલોડ કરવામાં આવી છે",
"remove_document": "દસ્તાવેજ દૂર કરો",
"upload_failed": "અપલોડ નિષ્ફળ ગયું. કૃપા કરીને ફરી પ્રયાસ કરો."
}

7
src/translations/locales/ha.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "Shigar da sunan magani da dalilin sha...",
"وضعیت سلامت جسمانی": "Yanayin Lafiyar Jiki",
"توضیحات وضعیت جسمانی": "Bayanin Lafiyar Jiki",
"Name": "Suna"
"Name": "Suna",
"upload_certificates": "Loda takardun shaida",
"add_another_document": "Ƙara wata takarda",
"max_files_reached": "An loda matsakaicin fayiloli 4",
"remove_document": "Cire takarda",
"upload_failed": "Loda ya faskara. Da fatan za a sake gwadawa."
}

7
src/translations/locales/he.json

@ -355,5 +355,10 @@
"Job Title": "תואר התפקיד",
"Employment Status": "מצב תעסוקתי",
"Your Hobbies and Main Interests": "התחביבים ותחומי העניין העיקריים שלך",
"View more details": "הצג פרטים נוספים"
"View more details": "הצג פרטים נוספים",
"upload_certificates": "העלאת תעודות ומסמכים",
"add_another_document": "הוסף מסמך נוסף",
"max_files_reached": "הועלו מקסימום 4 קבצים",
"remove_document": "הסר מסמך",
"upload_failed": "ההעלאה נכשלה. אנא נסה שוב."
}

7
src/translations/locales/hi.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "दवा का नाम और उपयोग का कारण दर्ज करें...",
"وضعیت سلامت جسمانی": "शारीरिक स्वास्थ्य की स्थिति",
"توضیحات وضعیت جسمانی": "शारीरिक स्वास्थ्य का विवरण",
"Name": "नाम"
"Name": "नाम",
"upload_certificates": "प्रमाणपत्र अपलोड करें",
"add_another_document": "दूसरा दस्तावेज़ जोड़ें",
"max_files_reached": "अधिकतम 4 फ़ाइलें अपलोड की गईं",
"remove_document": "दस्तावेज़ हटाएं",
"upload_failed": "अपलोड विफल रहा। कृपया पुन: प्रयास करें।"
}

7
src/translations/locales/id.json

@ -355,5 +355,10 @@
"Job Title": "Jabatan / Pekerjaan",
"Employment Status": "Status Pekerjaan",
"Your Hobbies and Main Interests": "Hobi dan Minat Utama Anda",
"View more details": "Lihat detail selengkapnya"
"View more details": "Lihat detail selengkapnya",
"upload_certificates": "Unggah sertifikat",
"add_another_document": "Tambah dokumen lain",
"max_files_reached": "Maksimum 4 file diunggah",
"remove_document": "Hapus dokumen",
"upload_failed": "Pengunggahan gagal. Silakan coba lagi."
}

7
src/translations/locales/ks.json

@ -355,5 +355,10 @@
"Job Title": "کٲم ہُنٛد ناو",
"Employment Status": "مُلازمتٕچ حالت",
"Your Hobbies and Main Interests": "تُہنٛدؠ شۄق تہٕ اَہَم دِلچسپی",
"View more details": "مزید تفصیل وُچھِو"
"View more details": "مزید تفصیل وُچھِو",
"upload_certificates": "دستاویز اپ لوڈ کریو",
"add_another_document": "بیٛاکھ دستاویز جمع کریو",
"max_files_reached": "زیاد کھوتہ زیاد ۴ فائل اپ لوڈ کرنہ آمژٕ",
"remove_document": "دستاویز ہٹاوِیو",
"upload_failed": "اپ لوڈ ناکام۔ مہربانی کرتھ دوبارہ کوشش کریو۔"
}

7
src/translations/locales/pt.json

@ -355,5 +355,10 @@
"Job Title": "Cargo / Título profissional",
"Employment Status": "Situação profissional",
"Your Hobbies and Main Interests": "Seus hobbies e principais interesses",
"View more details": "Ver mais detalhes"
"View more details": "Ver mais detalhes",
"upload_certificates": "Enviar certificados",
"add_another_document": "Adicionar outro documento",
"max_files_reached": "Máximo de 4 arquivos enviados",
"remove_document": "Remover documento",
"upload_failed": "Falha no envio. Por favor, tente novamente."
}

7
src/translations/locales/ru.json

@ -812,5 +812,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "Введите название препарата и причину приема...",
"وضعیت سلامت جسمانی": "Физическое состояние здоровья",
"توضیحات وضعیت جسمانی": "Описание физического состояния",
"Name": "Имя"
"Name": "Имя",
"upload_certificates": "Загрузить сертификаты",
"add_another_document": "Добавить еще один документ",
"max_files_reached": "Загружено максимум 4 файла",
"remove_document": "Удалить документ",
"upload_failed": "Ошибка загрузки. Пожалуйста, повторите попытку."
}

7
src/translations/locales/sw.json

@ -355,5 +355,10 @@
"Job Title": "Wadhifa wa Kazi",
"Employment Status": "Hali ya Ajira",
"Your Hobbies and Main Interests": "Mambo unayopenda na Maslahi Kuu",
"View more details": "Angalia maelezo zaidi"
"View more details": "Angalia maelezo zaidi",
"upload_certificates": "Pakia vyeti",
"add_another_document": "Ongeza hati nyingine",
"max_files_reached": "Upeo wa faili 4 zimepakiwa",
"remove_document": "Ondoa hati",
"upload_failed": "Upakiaji umeshindwa. Tafadhali jaribu tena."
}

7
src/translations/locales/tg.json

@ -355,5 +355,10 @@
"Job Title": "Унвони вазифа",
"Employment Status": "Вазъи шуғл",
"Your Hobbies and Main Interests": "Машғулиятҳо ва манфиатҳои асосии шумо",
"View more details": "Дидани тафсилоти бештар"
"View more details": "Дидани тафсилоти бештар",
"upload_certificates": "Боргузории ҳуҷҷатҳо",
"add_another_document": "Ҳуҷҷати дигар илова кунед",
"max_files_reached": "Ҳадди аксар 4 файл боргузорӣ шудааст",
"remove_document": "Ҳуҷҷатро нест кунед",
"upload_failed": "Боргузорӣ ноком шуд. Лутфан бори дигар кӯшиш кунед."
}

7
src/translations/locales/tr.json

@ -355,5 +355,10 @@
"Job Title": "Meslek / Unvan",
"Employment Status": "Çalışma Durumu",
"Your Hobbies and Main Interests": "Hobileriniz ve Temel İlgi Alanlarınız",
"View more details": "Daha fazla ayrıntı gör"
"View more details": "Daha fazla ayrıntı gör",
"upload_certificates": "Belgeleri yükle",
"add_another_document": "Başka bir belge ekle",
"max_files_reached": "Maksimum 4 dosya yüklendi",
"remove_document": "Belgeyi kaldır",
"upload_failed": "Yükleme başarısız oldu. Lütfen tekrar deneyin."
}

7
src/translations/locales/ul.json

@ -355,5 +355,10 @@
"Job Title": "Job Title",
"Employment Status": "Employment Status",
"Your Hobbies and Main Interests": "Your Hobbies and Main Interests",
"View more details": "View more details"
"View more details": "View more details",
"upload_certificates": "دستاویزات اپ لوڈ کریں",
"add_another_document": "مزید دستاویز شامل کریں",
"max_files_reached": "زیادہ سے زیادہ 4 فائلیں اپ لوڈ کی گئیں",
"remove_document": "دستاویز ہٹائیں",
"upload_failed": "اپ لوڈ ناکام ہو گیا۔ براہ کرم دوبارہ کوشش کریں۔"
}

7
src/translations/locales/ur.json

@ -355,5 +355,10 @@
"Job Title": "عہدہ / ملازمت کا عنوان",
"Employment Status": "ملازمت کی صورتحال",
"Your Hobbies and Main Interests": "آپ کے مشاغل اور اہم دلچسپیاں",
"View more details": "مزید تفصیلات دیکھیں"
"View more details": "مزید تفصیلات دیکھیں",
"upload_certificates": "دستاویزات اپ لوڈ کریں",
"add_another_document": "مزید دستاویز شامل کریں",
"max_files_reached": "زیادہ سے زیادہ 4 فائلیں اپ لوڈ کی گئیں",
"remove_document": "دستاویز ہٹائیں",
"upload_failed": "اپ لوڈ ناکام ہو گیا۔ براہ کرم دوبارہ کوشش کریں۔"
}

7
src/translations/locales/uz.json

@ -355,5 +355,10 @@
"Job Title": "Kasb / Lavozim",
"Employment Status": "Bandlik holati",
"Your Hobbies and Main Interests": "Qiziqishlaringiz va asosiy mashgʻulotlaringiz",
"View more details": "Batafsil maʼlumotni koʻrish"
"View more details": "Batafsil maʼlumotni koʻrish",
"upload_certificates": "Hujjatlarni yuklash",
"add_another_document": "Boshqa hujjat qo'shish",
"max_files_reached": "Maksimal 4 ta fayl yuklandi",
"remove_document": "Hujjatni o'chirish",
"upload_failed": "Yuklab bo'lmadi. Qayta urinib ko'ring."
}

7
src/translations/locales/zh.json

@ -808,5 +808,10 @@
"نام دارو و دلیل مصرف را وارد نمایید...": "输入药物名称和使用原因...",
"وضعیت سلامت جسمانی": "身体健康状况",
"توضیحات وضعیت جسمانی": "身体健康说明",
"Name": "姓名"
"Name": "姓名",
"upload_certificates": "上传证书和文件",
"add_another_document": "添加其他文件",
"max_files_reached": "最多已上传 4 个文件",
"remove_document": "删除文件",
"upload_failed": "上传失败,请重试。"
}

47
test-all.js

@ -0,0 +1,47 @@
const fs = require('fs');
const rules = require('./conditional-rules.js');
const answers = {
"family_background.number_of_siblings": {
value: 2,
option_id: null
},
"family_background.parents_survival_status": {
value: "both_parents_are_alive",
option_id: "family_background.parents_survival_status.both_parents_are_alive"
},
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member": {
value: "i_am_responsible_for_caring_for_my_parent(s)_(father,_mother,_or_both).",
option_id: "family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_my_parent_s_father_mother_or_both"
},
"family_background.family_s_religious_and_ideological_atmosphere": {
value: "religious_(observant_of_obligations)",
option_id: "family_background.family_s_religious_and_ideological_atmosphere.religious_observant_of_obligations"
},
"family_background.family_economic_status": {
value: "prosperous",
option_id: "family_background.family_economic_status.prosperous"
}
};
const questions = JSON.parse(fs.readFileSync('api_questions.json', 'utf8'));
const context = { age: 25, gender: 'male' };
for (const q of questions) {
const mapped = {
id: q.id,
type: q.type,
title: q.title,
required: q.is_required !== undefined ? q.is_required : q.required,
baseRequired: q.required,
isVisible: q.is_visible,
conditionalRule: q.conditional_rule || q.visibility || q.logic || undefined,
visibility: q.visibility || q.conditional_rule || undefined,
logic: q.logic || undefined,
requiredWhen: q.required_when || undefined
};
const isVis = rules.isQuestionVisible(mapped, answers, context);
const isReq = rules.isQuestionRequired(mapped, answers, context);
console.log(`Q: ${q.id} | Visible: ${isVis} | Required: ${isReq}`);
}

29
test-cond.js

@ -0,0 +1,29 @@
const fs = require('fs');
const ts = require('typescript');
// Compile conditional-rules.ts on the fly
const source = fs.readFileSync('src/lib/conditional-rules.ts', 'utf8');
const result = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS }});
fs.writeFileSync('conditional-rules.js', result.outputText);
const rules = require('./conditional-rules.js');
const answers = {
"family_background.parents_survival_status": {
value: "both_parents_are_alive",
option_id: "family_background.parents_survival_status.both_parents_are_alive"
}
};
const visibility = {
operator: "any_of",
parent_question_id: "family_background.parents_survival_status",
trigger_option_ids: ["family_background.parents_survival_status.both_parents_are_alive"],
clear_answer_when_hidden: true
};
const context = { age: 25, gender: 'male' };
const isVisible = rules.ruleMatches(visibility, answers, context);
console.log("Is parents_marital_status visible?", isVisible);

32
test-cond2.js

@ -0,0 +1,32 @@
const fs = require('fs');
const rules = require('./conditional-rules.js');
const answers = {
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member": {
value: "i_am_responsible_for_caring_for_my_parent(s)_(father,_mother,_or_both).",
option_id: "family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_my_parent_s_father_mother_or_both"
}
};
const visibility = {
"operator": "any_of",
"parent_question_id": "family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member",
"trigger_option_ids": [
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_my_parent_s_father_mother_or_both",
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_the_care_custody_or_guardianship_of_other_family_members_sibling_etc",
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_regularly_provide_financial_support_for_a_family_member_s_living_expenses",
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_my_father",
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_my_mother",
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_both_parents",
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_a_sibling_brother_sister",
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_the_legal_guardian_or_supervisor_of_a_family_member",
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_have_other_circumstances_and_will_explain_in_the_description"
],
"clear_answer_when_hidden": true
};
const context = { age: 25, gender: 'male' };
const isVisible = rules.ruleMatches(visibility, answers, context);
console.log("Is additional_details visible?", isVisible);

36
test-cond3.js

@ -0,0 +1,36 @@
const fs = require('fs');
const rules = require('./conditional-rules.js');
const answers = {
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member": {
value: "i_am_responsible_for_caring_for_my_parent(s)_(father,_mother,_or_both).",
option_id: "family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_my_parent_s_father_mother_or_both"
}
};
const visibility = {
"operator": "any_of",
"parent_question_id": "family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member",
"trigger_option_ids": [
"family_background.do_you_currently_have_an_ongoing_financial_caregiving_or_guardianship_responsibility_for_a_family_member.i_am_responsible_for_caring_for_my_parent_s_father_mother_or_both"
],
"clear_answer_when_hidden": true
};
const context = { age: 25, gender: 'male' };
const question = {
id: "family_background.additional_details_about_family_responsibility",
required: false,
baseRequired: false, // in backend DYNAMIC_REQUIRED sets is_required=True, but what does the schema send?
isVisible: true,
conditionalRule: visibility,
visibility: visibility,
};
// Wait, the API sends `required: true` and `is_required: true` when it's dynamically required!
// See check_form_section.py output: family_background.additional_details_about_family_responsibility - is_visible=True is_required=True
question.required = true;
question.baseRequired = true; // Wait, schema adapter does baseRequired: bq.required, and required: bq.is_required !== undefined ? bq.is_required : bq.required
console.log("Is additional_details required?", rules.isQuestionRequired(question, answers, context));

26
test-empty.js

@ -0,0 +1,26 @@
const fs = require('fs');
const rules = require('./conditional-rules.js');
const answers = {};
const questions = JSON.parse(fs.readFileSync('api_questions.json', 'utf8'));
const context = { age: 25, gender: 'male' };
for (const q of questions) {
const mapped = {
id: q.id,
type: q.type,
title: q.title,
required: q.is_required !== undefined ? q.is_required : q.required,
baseRequired: q.required,
isVisible: q.is_visible,
conditionalRule: q.conditional_rule || q.visibility || q.logic || undefined,
visibility: q.visibility || q.conditional_rule || undefined,
logic: q.logic || undefined,
requiredWhen: q.required_when || undefined
};
const isVis = rules.isQuestionVisible(mapped, answers, context);
const isReq = rules.isQuestionRequired(mapped, answers, context);
console.log(`Q: ${q.id} | Visible: ${isVis} | Required: ${isReq}`);
}
Loading…
Cancel
Save