Browse Source

feat: implement marriage matchmaking question flow and advisor action interface with multi-language support

Dev
parent
commit
aef94e525d
  1. 570
      src/app/new-match/new-match-client.tsx
  2. 55
      src/app/questions-list/[slug]/question-detail-client.tsx
  3. 58
      src/app/questions-list/questions-list-client.tsx
  4. 23
      src/app/questions-list/sections-request.tsx
  5. 24
      src/components/Componentes/advisor-actions-card.tsx
  6. 67
      src/components/Componentes/test-completed-sheet.test.tsx
  7. 61
      src/components/Componentes/test-completed-sheet.tsx
  8. 92
      src/components/Componentes/test-exit-sheet.test.tsx
  9. 78
      src/components/Componentes/test-exit-sheet.tsx
  10. 156
      src/components/Componentes/test-questions-flow.tsx
  11. 86
      src/lib/schema-adapter.ts
  12. 4
      src/translations/locales/en.json
  13. 4
      src/translations/locales/fa.json

570
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,
@ -56,6 +66,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 +93,7 @@ const fieldCandidateMatchers = {
"residence_city",
"city",
"country",
"birth_city",
"birthplace",
"current_country",
],
educationLevel: [
"highest_level_of_education",
@ -79,38 +106,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;
@ -288,107 +298,188 @@ 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-col text-start">
<span
className="
text-[12px]
leading-tight
font-bold
text-[#1E293B]
"
>
{field.label}
</span>
<span
className="
mt-0.5
break-words
text-[12px]
leading-[1.35]
font-semibold
text-[#F0445B]
"
>
{field.value}
</span>
</div>
</div>
);
}
@ -522,57 +613,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 +675,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 +700,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,98 +738,173 @@ 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]" />
{/* 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>
<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%)]" />
<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>
</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}
{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}
{matchDisplay.job ? (
<FieldLine field={matchDisplay.job} />
) : null}
{matchDisplay.hobbies ? (
<FieldLine field={matchDisplay.hobbies} />
) : null}
{matchDisplay.maritalStatus ? (
<FieldLine field={matchDisplay.maritalStatus} />
) : null}
{matchDisplay.cityPreference ? (
<FieldLine field={matchDisplay.cityPreference} />
) : null}
{/* 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>
<button
type="button"
onClick={() => {
if (isMale && !hasActiveSub) {
setIsPaymentSheetOpen(true);
} else {
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"
>
{t["View more details"]}
</button>
{/* 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="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>
{/* 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.currentCountry && (
<ProfileInfoItem
field={matchDisplay.currentCountry}
icon={<FaLocationDot />}
/>
)}
{matchDisplay.currentCity && (
<ProfileInfoItem
field={matchDisplay.currentCity}
icon={<FaLocationDot />}
/>
)}
{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={() => {
if (isMale && !hasActiveSub) {
setIsPaymentSheetOpen(true);
} else {
openProfile();
}
}}
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
"
>
<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>
)}

55
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";
@ -307,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",
@ -715,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)}
/>
@ -792,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);
}}
>
@ -908,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"
/>
);
}

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

@ -55,49 +55,49 @@ 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.5 flex items-center justify-between gap-3">
<div className="flex items-center pl-0.5">
{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"
className="w-auto rounded-[9px] border-none bg-[#EBEDF0] bg-none px-4 py-2 text-[12.5px] font-bold text-[#111111]! shadow-none hover:bg-[#E2E4E8] active:scale-[0.98] transition-all"
href={onGetAdvisor ? undefined : getAdvisorHref}
onClick={onGetAdvisor}
>

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;

156
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);
router.back();
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}
/>
</>
);
}

86
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,19 +319,23 @@ export function convertOverviewToFrontendItems(
if (!overview) return [];
return [...overview.sections]
.sort((a, b) => a.order - b.order)
.map((section) => ({
slug: section.id,
title: section.title,
estimate: section.estimated_minutes
? `${section.estimated_minutes} min`
: "5 min",
progress: section.progress?.completion_percent ?? 0,
icon: resolveSectionIcon(section.id, section.icon),
required: section.is_required,
showInfoBadge: false,
summary: "",
checkpoints: [],
tooltip: "",
questions: [],
}));
.map((section) => {
const estMinutes = resolveSectionEstimatedMinutes(
section.id,
section.estimated_minutes,
);
return {
slug: section.id,
title: section.title,
estimate: `${estMinutes} min`,
progress: section.progress?.completion_percent ?? 0,
icon: resolveSectionIcon(section.id, section.icon),
required: section.is_required,
showInfoBadge: false,
summary: "",
checkpoints: [],
tooltip: "",
questions: [],
};
});
}

4
src/translations/locales/en.json

@ -690,10 +690,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 & Proceed",
"View Profile": "View Profile",
"View contact number": "View contact number",
"View more details": "View more details",
"View more details": "View Full Profile & Proceed",
"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",

4
src/translations/locales/fa.json

@ -690,10 +690,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": "در مسیر آشنایی و خواستگاری هستیم و هنوز هیچی نهایی نشده",

Loading…
Cancel
Save