Browse Source

feat: scaffold marriage feature with new pages, components, hooks, and localization support

front-test-2
ghorbani 2 weeks ago
parent
commit
ce60a97090
  1. 1
      src/app/[lang]/marriage-advisors/page.tsx
  2. 36
      src/app/candidate-contact/page.tsx
  3. 2
      src/app/finding-match/page.tsx
  4. 35
      src/app/globals.css
  5. 14
      src/app/intro/page.tsx
  6. 234
      src/app/marriage-advisors/page.tsx
  7. 36
      src/app/new-match/page.tsx
  8. 72
      src/app/providers.tsx
  9. 6
      src/app/questions-list/[slug]/page.tsx
  10. 236
      src/app/questions-list/[slug]/question-detail-client.tsx
  11. 24
      src/app/questions-list/page.tsx
  12. 7
      src/app/request-accepted/page.tsx
  13. 12
      src/app/request-sent/page.tsx
  14. 2
      src/components/Componentes/call-result-sheet.tsx
  15. 12
      src/components/Componentes/dismiss-reason-sheet.tsx
  16. 4
      src/components/Componentes/female-outcome-sheet.test.tsx
  17. 9
      src/components/Componentes/help-modal.tsx
  18. 4
      src/components/Componentes/information-sheet.tsx
  19. 11
      src/components/Componentes/loading-border-spinner.tsx
  20. 20
      src/components/Componentes/loading-icon-spinner.tsx
  21. 6
      src/components/Componentes/loading-select-spinner.tsx
  22. 4
      src/components/Componentes/loading-skeleton.tsx
  23. 5
      src/components/Componentes/loading-three-dot.tsx
  24. 26
      src/components/Componentes/navigation-button.tsx
  25. 12
      src/components/Componentes/page-header.tsx
  26. 54
      src/components/Componentes/page-loading-skeleton.tsx
  27. 22
      src/components/Componentes/progress-helper.ts
  28. 15
      src/components/Componentes/question-birthplace.tsx
  29. 4
      src/components/Componentes/question-file.tsx
  30. 10
      src/components/Componentes/question-number.tsx
  31. 8
      src/components/Componentes/question-section-flow.tsx
  32. 3
      src/components/Componentes/question-text.tsx
  33. 8
      src/components/Componentes/question-title.tsx
  34. 8
      src/components/Componentes/required-steps-card.tsx
  35. 61
      src/components/Componentes/silent-reloader.tsx
  36. 23
      src/components/Componentes/support-access.ts
  37. 6
      src/components/Componentes/support-sheet.tsx
  38. 6
      src/components/Componentes/swipe-button.tsx
  39. 30
      src/components/Componentes/test-intro-page.tsx
  40. 177
      src/components/Componentes/test-questions-flow.tsx
  41. 2
      src/data/cattell-fallback.ts
  42. 2
      src/hooks/marriage/use-marriage-config.ts
  43. 4
      src/lib/get-submit-path.ts
  44. 48
      src/translations/locales/en.json
  45. 48
      src/translations/locales/fa.json
  46. 1043
      src/translations/locales/ru.json

1
src/app/[lang]/marriage-advisors/page.tsx

@ -0,0 +1 @@
export { default } from "@/app/marriage-advisors/page";

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

@ -119,7 +119,9 @@ export default function CandidateContactPage() {
<main className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(16px,calc(var(--safe-top)+8px))] pb-[calc(20px+var(--safe-bottom))]">
<PageHeader
className="-mx-[6px]"
leftButton={isFemale ? { icon: "back", className: "hidden" } : undefined}
leftButton={
isFemale ? { icon: "back", className: "hidden" } : undefined
}
/>
<div className="flex flex-1 flex-col justify-between gap-6 pt-4">
@ -133,7 +135,11 @@ export default function CandidateContactPage() {
{t["Congratulations! 🎉"]}
</h1>
<p className="mt-6 text-[#4A4A4A] group-14 font-medium leading-relaxed">
{t["Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed."]}
{
t[
"Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed."
]
}
</p>
</div>
) : (
@ -151,7 +157,11 @@ export default function CandidateContactPage() {
{/* Title / Status message */}
<h1 className="mt-8 text-center text-[20px] leading-[1.25] font-bold text-[#1A1A1A] max-w-[280px]">
{t["The selected candidate will contact your family shortly."]}
{
t[
"The selected candidate will contact your family shortly."
]
}
</h1>
{caseStatus === "contacted" ? (
@ -159,7 +169,11 @@ export default function CandidateContactPage() {
{/* Outcome instructions and button */}
<div className="w-full border border-[#ECECEC] bg-white rounded-[15px] mt-8 px-4 py-4 text-center shadow-sm">
<p className="text-[#555555] group-14 font-medium leading-relaxed">
{t["Thank you for giving us feedback, we would be very happy if you also let us know the final result."]}
{
t[
"Thank you for giving us feedback, we would be very happy if you also let us know the final result."
]
}
</p>
</div>
@ -230,7 +244,11 @@ export default function CandidateContactPage() {
{/* Red warning box */}
<div className="w-full border border-[#F0445B] bg-[#FFF5F6] rounded-[15px] mt-4 px-4 py-3 text-center">
<p className="text-[#F0445B] group-12 font-semibold leading-normal">
{t["To keep the process moving smoothly, the other party has a 48-hour (2-day) window to make initial contact with you or your family. If no contact is established after 2 days, you have the option to decline his request or notify us that he hasn't reached out."]}
{
t[
"To keep the process moving smoothly, the other party has a 48-hour (2-day) window to make initial contact with you or your family. If no contact is established after 2 days, you have the option to decline his request or notify us that he hasn't reached out."
]
}
</p>
</div>
</>
@ -243,11 +261,15 @@ export default function CandidateContactPage() {
{/* Advisor section */}
<AdvisorActionsCard
title={t["Get an advisor"]}
description={t["Not sure what to do next? Our psychology section is here to guide you at every step."]}
description={
t[
"Not sure what to do next? Our psychology section is here to guide you at every step."
]
}
avatars={advisorAvatars}
extraCount={7}
getAdvisorLabel={t["Get Advisor"]}
getAdvisorHref="/questions-list"
getAdvisorHref="/marriage-advisors"
/>
{/* Profile Locked banner */}

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

@ -153,7 +153,7 @@ export default function FindingMatchPage() {
avatars={advisorAvatars}
extraCount={7}
getAdvisorLabel={copy.getAdvisor}
getAdvisorHref="/questions-list"
getAdvisorHref="/marriage-advisors"
/>
<FixToTheEnd>

35
src/app/globals.css

@ -288,31 +288,40 @@ body[data-page-background="custom"] .app-shell {
/* ─── Premium Shimmer Animations ─── */
@keyframes shimmer {
0% {
background-position: 200% 0;
background-position: -200% 0;
}
100% {
background-position: -200% 0;
background-position: 200% 0;
}
}
.shimmer-bg {
background-color: rgba(0, 0, 0, 0.05);
background-image: linear-gradient(
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.4) 50%,
rgba(255, 255, 255, 0) 100%
rgba(0, 0, 0, 0.05) 25%,
rgba(0, 0, 0, 0.1) 37%,
rgba(0, 0, 0, 0.05) 63%
);
background-size: 200% 100%;
background-repeat: no-repeat;
animation: shimmer 1.5s infinite linear;
}
.dark .shimmer-bg {
background-color: rgba(255, 255, 255, 0.04);
background-image: linear-gradient(
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.04) 25%,
rgba(255, 255, 255, 0.09) 37%,
rgba(255, 255, 255, 0.04) 63%
);
background-size: 200% 100%;
}
.shimmer-white-bg {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.06) 50%,
rgba(255, 255, 255, 0) 100%
rgba(255, 255, 255, 0.12) 25%,
rgba(255, 255, 255, 0.24) 37%,
rgba(255, 255, 255, 0.12) 63%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite linear;
}

14
src/app/intro/page.tsx

@ -167,7 +167,11 @@ export default function Intro() {
{t["A Path to Heavenly Marriage"]}
</h2>
<p className="text-center mt-2 group-12 text-[#4D4D4D]">
{t["We have come together with the goal of creating a secure and confidential path for \"permanent marriage\" among Muslims"]}
{
t[
'We have come together with the goal of creating a secure and confidential path for "permanent marriage" among Muslims'
]
}
</p>
</div>
<div className="flex items-center justify-between mt-4">
@ -180,7 +184,7 @@ export default function Intro() {
/>
<div className="flex flex-col items-start justify-start -mb-2">
<p className="text-[#36363C] group-16 font-bold leading-3">120</p>
<p className="text-[#36363C] group-10 font-semibold">
<p className="text-[#36363C] group-10 font-semibold capitalize">
{t["user profiles"]}
</p>
</div>
@ -191,10 +195,11 @@ export default function Intro() {
alt={t["matches"]}
width={37}
height={37}
priority
/>
<div className="flex flex-col items-start justify-start -mb-2">
<p className="text-[#36363C] group-16 font-bold leading-3">14</p>
<p className="text-[#36363C] group-10 font-semibold">
<p className="text-[#36363C] group-10 font-semibold capitalize">
{t["matches"]}
</p>
</div>
@ -205,10 +210,11 @@ export default function Intro() {
alt={t["marriages"]}
width={37}
height={37}
priority
/>
<div className="flex flex-col items-start justify-start -mb-2">
<p className="text-[#36363C] group-16 font-bold leading-3">14</p>
<p className="text-[#36363C] group-10 font-semibold">
<p className="text-[#36363C] group-10 font-semibold capitalize">
{t["marriages"]}
</p>
</div>

234
src/app/marriage-advisors/page.tsx

@ -0,0 +1,234 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { FiMessageSquare, FiPhone } from "react-icons/fi";
import { FaStar } from "react-icons/fa";
import StickyHeader from "@/components/Componentes/sticky-header";
import NavigationButton from "@/components/Componentes/navigation-button";
import { PageBackground } from "@/components/Componentes/page-background";
import SupportSheet from "@/components/Componentes/support-sheet";
import { useI18n } from "@/translations/provider";
export default function MarriageAdvisorsPage() {
const { dictionary: t } = useI18n();
const [isSupportOpen, setIsSupportOpen] = useState(false);
const mainStyle = {
backgroundImage: `url("/assets/images/islamic_pattern_2_2892_3864.svg")`,
backgroundColor: "#F5F5F5",
backgroundRepeat: "repeat-y",
backgroundSize: "100% auto",
};
const advisors = [
{
id: "hasti-masoudi",
name: t["Dr. Hasti Masoudi"] || "Dr. Hasti Masoudi",
specialty:
t["Specialist in clinical psychology"] ||
"Specialist in clinical psychology",
avatar: "/assets/images/female_avatar.svg",
rating: "3.2",
tagsRow1: [
t["Personal Development"] || "Personal Development",
t["Team Manager"] || "Team Manager",
t["Science"] || "Science",
],
tagsRow2: [
t["Manager"] || "Manager",
t["Team Manager"] || "Team Manager",
t["Science"] || "Science",
],
online: true,
},
{
id: "ali-rezaei",
name: t["Dr. Ali Rezaei"] || "Dr. Ali Rezaei",
specialty:
t["Family & marriage counselor"] || "Family & marriage counselor",
avatar: "/assets/images/Avatar Image.png",
rating: "4.9",
tagsRow1: [
t["Premarital Counselling"] || "Premarital Counselling",
t["Conflict Resolution"] || "Conflict Resolution",
],
tagsRow2: [
t["Behavioral Therapy"] || "Behavioral Therapy",
t["Self-Awareness"] || "Self-Awareness",
],
online: true,
},
{
id: "sara-alavi",
name: t["Dr. Sara Alavi"] || "Dr. Sara Alavi",
specialty:
t["Relationship & intimacy coach"] || "Relationship & intimacy coach",
avatar: "/assets/images/Ellipse 370.png",
rating: "4.7",
tagsRow1: [
t["Emotional Health"] || "Emotional Health",
t["Communication Skills"] || "Communication Skills",
],
tagsRow2: [
t["Relationship Coach"] || "Relationship Coach",
t["Personal Development"] || "Personal Development",
],
online: false,
},
];
return (
<>
<PageBackground />
<main
className="-mx-[17px] flex min-h-screen flex-col pb-10"
style={mainStyle}
>
<StickyHeader>
<div className="flex items-center justify-between gap-3">
<NavigationButton
variant="transparent"
icon="back"
iconLabel={t["Go back"]}
/>
<h1
className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white"
style={{ fontFamily: "'Segoe UI', sans-serif" }}
>
{t["Marriage advisors"]}
</h1>
<div className="size-10 shrink-0" />
</div>
</StickyHeader>
<section className="px-[17px] pb-32 pt-5 flex flex-col items-center gap-4">
{advisors.map((advisor) => (
<div
key={advisor.id}
className="flex flex-col items-center py-[13px] gap-[13px] w-[343px] bg-white rounded-[15px] shadow-[0px_4px_12px_rgba(0,0,0,0.05)] border border-[#EBEBEB]"
>
{/* Top Frame containing Left (Avatar + Info) and Right (Rating) and Actions */}
<div className="flex flex-col items-start p-0 gap-[11px] w-[317px]">
{/* Header Row */}
<div className="flex flex-row items-start justify-between w-full h-[45px]">
{/* Left part: Avatar + Info */}
<div className="flex flex-row items-center gap-2.5 h-[45px]">
{/* Avatar Container */}
<div className="relative w-[45px] h-[45px] shrink-0">
<Image
src={advisor.avatar}
alt={advisor.name}
width={45}
height={45}
className="w-full h-full rounded-full object-cover"
/>
{/* Status indicator dot */}
{advisor.online && (
<div className="absolute top-0 right-0 w-[12px] h-[12px] rounded-full bg-white flex items-center justify-center select-none pointer-events-none">
<span className="absolute w-[10px] h-[10px] rounded-full bg-[#31CB2E] opacity-20 animate-ping" />
<span className="w-[6px] h-[6px] rounded-full bg-[#31CB2E]" />
</div>
)}
</div>
{/* Name + Title Info */}
<div className="flex flex-col items-start gap-[2px] h-[35px]">
<h3
className="font-bold text-[16px] leading-[20px] text-[#111111] text-start font-sans truncate w-[170px]"
style={{ fontFamily: "'Segoe UI', sans-serif" }}
>
{advisor.name}
</h3>
<p
className="font-semibold text-[10px] leading-[13px] text-[#8B8B8B] text-start font-sans truncate w-[170px]"
style={{ fontFamily: "'Segoe UI', sans-serif" }}
>
{advisor.specialty}
</p>
</div>
</div>
{/* Right part: Rating */}
<div className="flex flex-row justify-center items-center gap-[4px] h-[20px] shrink-0">
<FaStar className="size-[14px] text-[#ECA533] shrink-0" />
<span
className="font-semibold text-[10px] leading-[13px] text-[#ECA533] font-sans text-center"
style={{ fontFamily: "'Segoe UI', sans-serif" }}
>
{advisor.rating}
</span>
</div>
</div>
{/* Actions Row */}
<div className="flex flex-row items-center gap-[7px] h-[22px]">
{/* Text Contact Button */}
<button
type="button"
onClick={() => setIsSupportOpen(true)}
className="flex flex-row justify-center items-center px-2 py-0.5 gap-1.5 h-[22px] bg-[rgba(14,177,60,0.1)] rounded-[20px] text-[#0EB13C] font-semibold text-[10px] leading-[13px] transition-transform active:scale-95 cursor-pointer border-none"
>
<FiMessageSquare className="size-3 text-[#0EB13C] shrink-0" />
<span style={{ fontFamily: "'Segoe UI', sans-serif" }}>
{t["Text"] || "Text"}
</span>
</button>
{/* Voice Call Contact Button */}
<button
type="button"
onClick={() => setIsSupportOpen(true)}
className="flex flex-row justify-center items-center px-2 py-0.5 gap-1.5 h-[22px] bg-[rgba(14,177,60,0.1)] rounded-[20px] text-[#0EB13C] font-semibold text-[10px] leading-[13px] transition-transform active:scale-95 cursor-pointer border-none"
>
<FiPhone className="size-3 text-[#0EB13C] shrink-0" />
<span style={{ fontFamily: "'Segoe UI', sans-serif" }}>
{t["Voice Call"] || "Voice Call"}
</span>
</button>
</div>
</div>
{/* Divider */}
<div className="w-[343px] h-[1px] bg-[#EBEBEB]" />
{/* Tags Section */}
<div className="flex flex-col items-start gap-[9px] w-[317px]">
{/* Row 1 tags */}
<div className="flex flex-row items-center gap-[9px] w-full flex-wrap">
{advisor.tagsRow1.map((tag, index) => (
<span
key={index}
className="flex flex-row justify-center items-center py-[2px] px-[8px] bg-[#F5F5F5] rounded-[15px] text-[#8B8B8B] font-semibold text-[10px] leading-[13px] shrink-0 font-sans"
style={{ fontFamily: "'Segoe UI', sans-serif" }}
>
{tag}
</span>
))}
</div>
{/* Row 2 tags */}
<div className="flex flex-row items-center gap-[9px] w-full flex-wrap">
{advisor.tagsRow2.map((tag, index) => (
<span
key={index}
className="flex flex-row justify-center items-center py-[2px] px-[8px] bg-[#F5F5F5] rounded-[15px] text-[#8B8B8B] font-semibold text-[10px] leading-[13px] shrink-0 font-sans"
style={{ fontFamily: "'Segoe UI', sans-serif" }}
>
{tag}
</span>
))}
</div>
</div>
</div>
))}
</section>
</main>
<SupportSheet
isOpen={isSupportOpen}
onClose={() => setIsSupportOpen(false)}
/>
</>
);
}

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

@ -355,8 +355,12 @@ export default function NewMatchPage() {
? t["New Marriage Proposal"]
: t["YOU HAVE A NEW MATCH!"];
const matchHeadingDescription = isFemaleProfile
? t["A suitable match has been found for you. If approved, your profile will be evaluated to proceed with the introduction process."]
: t["If you proceed, we will notify the other party, and upon their approval, you can view each other's contact information."];
? t[
"A suitable match has been found for you. If approved, your profile will be evaluated to proceed with the introduction process."
]
: t[
"If you proceed, we will notify the other party, and upon their approval, you can view each other's contact information."
];
const isMale = profile?.gender === "male";
const hasActiveSub = !!profile?.active_subscription;
@ -458,7 +462,9 @@ export default function NewMatchPage() {
if (isMale && !hasActiveSub) {
setIsPaymentSheetOpen(true);
} else {
router.push(localizePath("/new-match/profile", locale));
router.push(
localizePath("/new-match/profile", locale),
);
}
}}
className="mt-[15px] 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"
@ -477,11 +483,15 @@ export default function NewMatchPage() {
<AdvisorActionsCard
className="mt-[36px]"
title={t["Get an advisor"]}
description={t["Not sure what to do next? Our psychology section is here to guide you at every step."]}
description={
t[
"Not sure what to do next? Our psychology section is here to guide you at every step."
]
}
avatars={advisorAvatars}
extraCount={7}
getAdvisorLabel={t["Get Advisor"]}
getAdvisorHref="/questions-list"
getAdvisorHref="/marriage-advisors"
/>
</div>
</div>
@ -539,7 +549,9 @@ export default function NewMatchPage() {
</h3>
<p className="text-xs text-gray-500 leading-relaxed mb-4 text-center">
{t["This payment acts as a verification process for your account and activates a 3-month subscription to receive new case introductions. The cost of this subscription is 50 Habib Coins."] ||
{t[
"This payment acts as a verification process for your account and activates a 3-month subscription to receive new case introductions. The cost of this subscription is 50 Habib Coins."
] ||
"This payment acts as a verification process for your account and activates a 3-month subscription to receive new case introductions. The cost of this subscription is 50 Habib Coins."}
</p>
@ -553,7 +565,9 @@ export default function NewMatchPage() {
</div>
<p className="text-[10px] text-gray-400 leading-normal mb-6 text-center">
{t["Please note that there is no guarantee for a specific number of cases, and the volume of incoming cases is solely subject to profile compatibility with other users."] ||
{t[
"Please note that there is no guarantee for a specific number of cases, and the volume of incoming cases is solely subject to profile compatibility with other users."
] ||
"Please note that there is no guarantee for a specific number of cases, and the volume of incoming cases is solely subject to profile compatibility with other users."}
</p>
@ -576,7 +590,9 @@ export default function NewMatchPage() {
{respondMutation.isPending ? (
<LoadingThreeDot />
) : (
<span className="truncate">{t["Decline"] || "Decline"}</span>
<span className="truncate">
{t["Decline"] || "Decline"}
</span>
)}
</div>
</button>
@ -594,7 +610,9 @@ export default function NewMatchPage() {
<LoadingThreeDot />
) : (
<>
<span className="truncate min-w-0">{t["Pay"] || "Pay"}</span>
<span className="truncate min-w-0">
{t["Pay"] || "Pay"}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-[#E43B51] p-1.5 text-xs font-semibold leading-none text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.12)] shrink-0 min-w-0 whitespace-nowrap">
<Image
src="/assets/images/Inner Plugdsain Iframe.svg"

72
src/app/providers.tsx

@ -3,64 +3,13 @@
import {
QueryClient,
QueryClientProvider,
useQueryClient,
} from "@tanstack/react-query";
import { type ReactNode, useEffect, useState } from "react";
import { type ReactNode, useState } from "react";
import FlutterLocaleSync from "@/components/Componentes/flutter-locale-sync";
import SilentReloader from "@/components/Componentes/silent-reloader";
import { ViewPaddingsProvider } from "@/components/Componentes/view-paddings-provider";
function AppFocusReloader({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
useEffect(() => {
if (typeof window !== "undefined") {
(window as any).__queryClient = queryClient;
}
let lastReloadTime = 0;
const handleReload = () => {
const now = Date.now();
if (now - lastReloadTime < 5000) {
return;
}
lastReloadTime = now;
// Invalidate all active queries so fresh data is reloaded from backend
queryClient.invalidateQueries();
};
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
handleReload();
}
};
const handlePageShow = () => {
handleReload();
};
window.addEventListener("focus", handleReload);
window.addEventListener("pageshow", handlePageShow);
document.addEventListener("visibilitychange", handleVisibilityChange);
// Listen for Flutter response events if available
const win = window as any;
const unbindFlutter =
typeof win.addFlutterResponseListener === "function"
? win.addFlutterResponseListener(() => handleReload())
: null;
return () => {
window.removeEventListener("focus", handleReload);
window.removeEventListener("pageshow", handlePageShow);
document.removeEventListener("visibilitychange", handleVisibilityChange);
if (typeof unbindFlutter === "function") {
unbindFlutter();
}
};
}, [queryClient]);
return <>{children}</>;
}
// AppFocusReloader was extracted to SilentReloader in silent-reloader.tsx
type ProvidersProps = {
children: ReactNode;
@ -72,12 +21,19 @@ export default function Providers({ children }: ProvidersProps) {
new QueryClient({
defaultOptions: {
queries: {
// Keep the last successful response available while a fresh
// request runs. `isLoading` is therefore only true on the first
// visit to a query; later visits render cached data immediately.
refetchOnWindowFocus: "always",
refetchOnMount: "always",
refetchOnReconnect: "always",
retry: 1,
staleTime: 0, // Always consider queries stale to ensure fresh data from backend on every page load
gcTime: 10 * 60 * 1000, // 10 minutes
staleTime: 0,
// Retain page data long enough for back-navigation to be instant.
gcTime: 10 * 60 * 1000,
// React Query replaces only the changed branches of a response,
// preventing needless UI updates when a silent refresh is equal.
structuralSharing: true,
},
},
}),
@ -85,11 +41,11 @@ export default function Providers({ children }: ProvidersProps) {
return (
<QueryClientProvider client={queryClient}>
<AppFocusReloader>
<SilentReloader>
<ViewPaddingsProvider />
<FlutterLocaleSync />
{children}
</AppFocusReloader>
</SilentReloader>
</QueryClientProvider>
);
}

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

@ -47,7 +47,11 @@ export default async function QuestionDetailPage({
locale={locale}
questionsListHref={questionsListHref}
title={t["Answer at Your Own Pace"]}
description={t["You can pause the survey anytime and resume later. Your progress is saved automatically."]}
description={
t[
"You can pause the survey anytime and resume later. Your progress is saved automatically."
]
}
closeLabel={t["Close questions list"]}
informationLabel={t["Information sheet"]}
continueLabel={t["Submit"]}

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

@ -63,6 +63,10 @@ type StoredAnswers = {
fields?: StoredQuestionField[];
};
function getTestDraftStorageKey(slug: string) {
return `marriage:tests:${slug}:draft`;
}
function getQuestionStorageKey(slug: string) {
return `marriage:sections:${slug}:answers`;
}
@ -124,7 +128,7 @@ function getStoredAge() {
return (
f.type === "number" ||
f.label === "Age" ||
f.label === "سن" ||
f.label === "ط³ظ†" ||
(typeof f.key === "string" &&
(f.key.endsWith("_age") || f.key.endsWith("_sn")))
);
@ -139,7 +143,7 @@ function getStoredAge() {
return (
f.type === "date" ||
f.label === "Date of Birth" ||
f.label === "تاریخ تولد" ||
f.label === "طھط§ط±غŒط® طھظˆظ„ط¯" ||
(typeof f.key === "string" &&
(f.key.endsWith("_date_of_birth") || f.key.endsWith("_tarykh_twld")))
);
@ -498,6 +502,30 @@ export default function QuestionDetailClient({
const router = useRouter();
const { dictionary: t } = useI18n();
const [isTestStarted, setIsTestStarted] = useState(false);
const [hasTestProgress, setHasTestProgress] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") {
const draftKey = `marriage:tests:${itemSlug}:draft`;
const draftRaw = window.localStorage.getItem(draftKey);
if (draftRaw) {
try {
const parsed = JSON.parse(draftRaw);
if (
parsed &&
typeof parsed.answers === "object" &&
parsed.answers !== null &&
Object.keys(parsed.answers).length > 0
) {
setHasTestProgress(true);
return;
}
} catch {}
}
setHasTestProgress(false);
}
}, [itemSlug, isTestStarted]);
const { data: profile, isLoading: isProfileLoading } =
useMarriageProfileQuery();
const profileGender = profile?.gender;
@ -538,7 +566,7 @@ export default function QuestionDetailClient({
q.options && q.options.length > 0
? q.options
: locale === "fa"
? ["بله", "به اندازه کافی واضح نیست", "نه"]
? ["ط¨ظ„ظ‡", "ط¨ظ‡ ط§ظ†ط¯ط§ط²ظ‡ ع©ط§ظپغŒ ظˆط§ط¶ط­ ظ†غŒط³طھ", "ظ†ظ‡"]
: ["Yes", "Not clear enough", "No"];
const mappedOptions = rawOptions.map((optText, idx) => ({
label: optText,
@ -559,11 +587,17 @@ export default function QuestionDetailClient({
: glasserFallbackQuestions;
const defaultGlasserOptions = [
{ label: locale === "fa" ? "خیلی کم (۱)" : "Very Low (1)", value: 1 },
{ label: locale === "fa" ? "کم (۲)" : "Low (2)", value: 2 },
{ label: locale === "fa" ? "متوسط (۳)" : "Moderate (3)", value: 3 },
{ label: locale === "fa" ? "زیاد (۴)" : "High (4)", value: 4 },
{ label: locale === "fa" ? "خیلی زیاد (۵)" : "Very High (5)", value: 5 },
{
label: locale === "fa" ? "خیلی کم" : "Very Low",
value: 1,
},
{ label: locale === "fa" ? "کم" : "Low", value: 2 },
{ label: locale === "fa" ? "متوسط" : "Moderate", value: 3 },
{ label: locale === "fa" ? "زیاد" : "High", value: 4 },
{
label: locale === "fa" ? "خیلی زیاد" : "Very High",
value: 5,
},
];
return questionsList.map((q) => ({
@ -585,14 +619,14 @@ export default function QuestionDetailClient({
}
const hasDobQuestion = item.questions.some(
(q) => q.title === "Date of Birth" || q.title === "تاریخ تولد",
(q) => q.title === "Date of Birth" || q.title === "طھط§ط±غŒط® طھظˆظ„ط¯",
);
return item.questions
.filter((question) => {
if (
hasDobQuestion &&
(question.title === "Age" || question.title === "سن")
(question.title === "Age" || question.title === "ط³ظ†")
) {
return false;
}
@ -622,7 +656,12 @@ export default function QuestionDetailClient({
}, [isProfileLoading, item, profileContext, questionsListHref, router]);
if (isProfileLoading && item) {
return <PageLoadingSkeleton compact />;
return (
<PageLoadingSkeleton
compact
variant={isCattellSlug || isGlasserSlug ? "test" : "questions"}
/>
);
} else if (
!item ||
!isQuestionListItemVisibleForProfile(item, profileContext)
@ -633,13 +672,13 @@ export default function QuestionDetailClient({
if (item && item.questions.length === 0) {
if (isTestStarted) {
const isQuestionsLoading = isCattellSlug
? cattellQuery.isLoading || cattellQuery.isFetching
? cattellQuery.isLoading
: isGlasserSlug
? glasserQuery.isLoading || glasserQuery.isFetching
? glasserQuery.isLoading
: false;
if (isQuestionsLoading) {
return <PageLoadingSkeleton compact />;
return <PageLoadingSkeleton compact variant="test" />;
}
const activeTestQuestions = isCattellSlug
@ -665,10 +704,10 @@ export default function QuestionDetailClient({
<p className="font-semibold text-[#1B1B1B]">
{isError
? locale === "fa"
? "خطا در دریافت سوالات از سرور. لطفاً از اتصال اینترنت یا ورود به حساب کاربری اطمینان حاصل کنید."
? "ط®ط·ط§ ط¯ط± ط¯ط±غŒط§ظپطھ ط³ظˆط§ظ„ط§طھ ط§ط² ط³ط±ظˆط±. ظ„ط·ظپط§ظ‹ ط§ط² ط§طھطµط§ظ„ ط§غŒظ†طھط±ظ†طھ غŒط§ ظˆط±ظˆط¯ ط¨ظ‡ ط­ط³ط§ط¨ ع©ط§ط±ط¨ط±غŒ ط§ط·ظ…غŒظ†ط§ظ† ط­ط§طµظ„ ع©ظ†غŒط¯."
: "Failed to load questions from server. Please check your connection or login status."
: locale === "fa"
? "سوالاتی برای این آزمون یافت نشد."
? "ط³ظˆط§ظ„ط§طھغŒ ط¨ط±ط§غŒ ط§غŒظ† ط¢ط²ظ…ظˆظ† غŒط§ظپطھ ظ†ط´ط¯."
: "No questions found for this test."}
</p>
<div className="flex gap-3">
@ -684,7 +723,7 @@ export default function QuestionDetailClient({
onClick={() => refetch()}
className="rounded-xl bg-[#F2465F] px-4 py-2 text-sm font-semibold text-white shadow-md"
>
{locale === "fa" ? "تلاش مجدد" : "Retry"}
{locale === "fa" ? "طھظ„ط§ط´ ظ…ط¬ط¯ط¯" : "Retry"}
</button>
</div>
</main>
@ -700,11 +739,7 @@ export default function QuestionDetailClient({
question_number: Number(qNum),
option: String(option),
}));
try {
await submitCattellMutation.mutateAsync({ responses });
} catch {
// Ignore if already submitted or API returned error
}
try {
window.localStorage.setItem(
getQuestionStorageKey(item.slug),
@ -716,11 +751,7 @@ export default function QuestionDetailClient({
question_number: Number(qNum),
score: Number(score),
}));
try {
await submitGlasserMutation.mutateAsync({ responses });
} catch {
// Ignore if already submitted
}
try {
window.localStorage.setItem(
getQuestionStorageKey(item.slug),
@ -739,20 +770,36 @@ export default function QuestionDetailClient({
informationLabel={informationLabel}
onClose={() => setIsTestStarted(false)}
onFinish={handleTestFinish}
draftStorageKey={getTestDraftStorageKey(item.slug)}
/>
);
}
const bulletKey =
item.slug === "glasser_5_needs_test" ? "glasser" : "personality";
const bullets = bulletKey === "glasser" ? [
t["Understanding your five basic needs helps you recognize what truly drives your behavior in relationships."],
t["Taking this test is not mandatory, but it will help you better understand your priorities and find a more compatible spouse."],
t["By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."]
] : [
t["Do you struggle to understand the underlying psychological drivers that dictate your interpersonal connections and communication barriers?"],
t["Taking this test is not mandatory, but it will help you better search for a spouse. The Personality test is a test for self-knowledge and better understanding of your spouse."],
t["Do you operate based on superficial behavioral adaptations, or are you aware of the deep \"source traits\" that fundamentally control your decision-making processes?"]
const bullets =
bulletKey === "glasser"
? [
t[
"Understanding your five basic needs helps you recognize what truly drives your behavior in relationships."
],
t[
"Taking this test is not mandatory, but it will help you better understand your priorities and find a more compatible spouse."
],
t[
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships."
],
]
: [
t[
"Do you struggle to understand the underlying psychological drivers that dictate your interpersonal connections and communication barriers?"
],
t[
"Taking this test is not mandatory, but it will help you better search for a spouse. The Personality test is a test for self-knowledge and better understanding of your spouse."
],
t[
'Do you operate based on superficial behavioral adaptations, or are you aware of the deep "source traits" that fundamentally control your decision-making processes?'
],
];
return (
@ -786,13 +833,128 @@ export default function QuestionDetailClient({
title={item.title}
estimateTime={item.estimate}
description={t["Estimate time"]}
bulletPoints={bullets}
disclaimerText={t["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={t["Start"]}
bulletPoints={
isCattellSlug || isGlasserSlug ? undefined : bullets
}
disclaimerText={
t[
"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"]}
onStart={() => {
setIsTestStarted(true);
}}
/>
>
{isCattellSlug ? (
<div className="mt-5 text-left">
<ul className="space-y-4">
{[
{
title: t["Understanding Personality Traits"],
desc: t[
"Helps provide an overall picture of traits such as sociability, independence, emotional sensitivity, and interpersonal style."
],
},
{
title: t["Assessing Communication Style"],
desc: t[
"Shows how a person typically communicates, expresses emotions, and builds closeness in relationships."
],
},
{
title:
t["Understanding Responses to Stress and Conflict"],
desc: t[
"Offers insight into emotional stability, tension levels, and how a person may react in difficult or stressful situations."
],
},
{
title: t["Assessing Independence and Decision-Making"],
desc: t[
"Helps identify the person’s level of independence, assertiveness, and preference for individual or shared decision-making."
],
},
{
title:
t["Identifying Potential Differences and Challenges"],
desc: t[
"Comparing two individuals’ results can highlight personality differences that may require attention in a long-term relationship."
],
},
{
title: t["Supporting Better Match Recommendations"],
desc: t[
"Alongside interviews and other relationship criteria, personality assessment can help make the matching process more targeted and improve the evaluation of compatibility."
],
},
].map((bullet, index) => (
<li key={index} className="min-w-0">
<h4 className="group-12 font-bold text-[#1B1B1B] leading-snug">
{bullet.title}
</h4>
<p className="group-12 text-[#5A5A5A] mt-1 leading-[1.55] text-justify">
{bullet.desc}
</p>
</li>
))}
</ul>
</div>
) : isGlasserSlug ? (
<div className="mt-5 text-left">
<ul className="space-y-4">
{[
{
title: t["Understanding Core Psychological Needs"],
desc: t[
"Helps identify the importance of the five basic needs—love and belonging, power, freedom, fun, and survival—in each person’s life."
],
},
{
title: t["Recognizing Relationship Expectations"],
desc: t[
"Provides insight into what each person expects from a relationship, such as closeness, independence, security, achievement, or shared enjoyment."
],
},
{
title: t["Assessing Personality Compatibility"],
desc: t[
"Helps compare personality traits, behavioral tendencies, and interaction styles to identify areas of compatibility between two individuals."
],
},
{
title: t["Identifying Potential Sources of Conflict"],
desc: t[
"Differences in needs or personality styles can highlight areas where misunderstandings, tension, or disagreements may arise in the relationship."
],
},
{
title: t["Improving Mutual Understanding"],
desc: t[
"Helps individuals better understand their own needs as well as their partner’s motivations, preferences, and emotional priorities."
],
},
{
title:
t["Supporting More Suitable Match Recommendations"],
desc: t[
"Combining needs and personality assessments with interviews and other marriage criteria can help make partner recommendations more personalized and well-matched."
],
},
].map((bullet, index) => (
<li key={index} className="min-w-0">
<h4 className="group-12 font-bold text-[#1B1B1B] leading-snug">
{bullet.title}
</h4>
<p className="group-12 text-[#5A5A5A] mt-1 leading-[1.55] text-justify">
{bullet.desc}
</p>
</li>
))}
</ul>
</div>
) : null}
</TestIntroPage>
</div>
</main>
</>

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

@ -146,9 +146,19 @@ export default function QuestionsListPage() {
useEffect(() => {
if (startMatchMutation.isError || isSyncError) {
setToastMessage(t["Sending the match request failed. Please check your connection and try again."]);
setToastMessage(
t[
"Sending the match request failed. Please check your connection and try again."
],
);
}
}, [startMatchMutation.isError, isSyncError, t["Sending the match request failed. Please check your connection and try again."]]);
}, [
startMatchMutation.isError,
isSyncError,
t[
"Sending the match request failed. Please check your connection and try again."
],
]);
const handleCloseToast = () => {
setToastMessage(null);
@ -304,7 +314,11 @@ export default function QuestionsListPage() {
title={t["Important Note"]}
description={
<p className="px-0.5 text-sm leading-[1.45] text-[#2D2D2D]">
{t["You've completed all required fields. However, filling in all sections will help us find better matches for you"]}
{
t[
"You've completed all required fields. However, filling in all sections will help us find better matches for you"
]
}
</p>
}
onClose={() => setIsOptionalInfoSheetOpen(false)}
@ -439,9 +453,7 @@ export default function QuestionsListPage() {
height={28}
className="shrink-0"
/>
<span className="leading-none font-semibold">
{t["Submit"]}
</span>
<span className="leading-none font-semibold">{t["Submit"]}</span>
</span>
</Button>
</FixToTheEnd>

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

@ -204,8 +204,6 @@ export default function RequestAcceptedPage() {
}
}, [profile, router, locale, noContactReportedSuccess]);
const isRedirecting = useMemo(() => {
if (!profile) return false;
return getSubmitPath(profile) !== "/request-accepted";
@ -620,7 +618,8 @@ export default function RequestAcceptedPage() {
type="button"
onClick={() => setIsNoContactConfirmOpen(true)}
disabled={
contactStatusMutation.isPending || noContactReportedSuccess
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"
>
@ -703,7 +702,7 @@ export default function RequestAcceptedPage() {
avatars={advisorAvatars}
extraCount={7}
getAdvisorLabel={t["Get Advisor"]}
getAdvisorHref="/questions-list"
getAdvisorHref="/marriage-advisors"
/>
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]">

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

@ -47,12 +47,18 @@ export default function RequestSentPage() {
const copy = {
advisorTitle: t["Get an advisor"],
advisorDescription: t["Not sure what to do next? Our psychology section is here to guide you at every step."],
advisorDescription:
t[
"Not sure what to do next? Our psychology section is here to guide you at every step."
],
getAdvisor: t["Get Advisor"],
};
const requestSentCopy = {
title: t["Request Sent"],
description: t["Your request has been sent. Once the lady reviews your request, you will be notified."],
description:
t[
"Your request has been sent. Once the lady reviews your request, you will be notified."
],
matchProfile: t["View More Details"],
profileLocked: t["Profile is locked"],
};
@ -105,7 +111,7 @@ export default function RequestSentPage() {
avatars={advisorAvatars}
extraCount={7}
getAdvisorLabel={copy.getAdvisor}
getAdvisorHref="/questions-list"
getAdvisorHref="/marriage-advisors"
/>
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]">

2
src/components/Componentes/call-result-sheet.tsx

@ -31,7 +31,7 @@ export function CallResultSheet({
t["Different expectations"],
t["No connection felt"],
t["Location not suitable"],
t["Other reasons"]
t["Other reasons"],
];
const groupId = useId();
const [isVisible, setIsVisible] = useState(true);

12
src/components/Componentes/dismiss-reason-sheet.tsx

@ -30,7 +30,7 @@ export function DismissReasonSheet({
t["Different expectations"],
t["No connection felt"],
t["Location not suitable"],
t["Other reasons"]
t["Other reasons"],
];
const groupId = useId();
const [isVisible, setIsVisible] = useState(true);
@ -154,7 +154,11 @@ export function DismissReasonSheet({
</h2>
<p className="mt-3.5 w-full text-start text-[14px] leading-[1.45] text-[#2C2C2C] dir-auto">
{t["Please provide the full reason for rejecting the submitted item"]}
{
t[
"Please provide the full reason for rejecting the submitted item"
]
}
</p>
<fieldset
@ -228,7 +232,9 @@ export function DismissReasonSheet({
<textarea
ref={textareaRef}
className="mt-1 h-[125px] w-full resize-none rounded-[16px] border border-[#454545] bg-transparent px-3 py-3 text-[16px] text-[#171717] outline-none placeholder:text-[#AAAAAA]"
placeholder={t["Feel free to briefly explain your decision..."]}
placeholder={
t["Feel free to briefly explain your decision..."]
}
value={reasonText}
onChange={(event) =>
setReasonText(event.target.value)

4
src/components/Componentes/female-outcome-sheet.test.tsx

@ -40,9 +40,7 @@ describe("FemaleOutcomeSheet", () => {
it("keeps the profile unchanged when the acquaintance process is still ongoing", async () => {
const onSubmit = renderSheet();
await userEvent.click(
screen.getByRole("button", { name: "Continue" }),
);
await userEvent.click(screen.getByRole("button", { name: "Continue" }));
expect(onSubmit).toHaveBeenCalledWith("success");
});

9
src/components/Componentes/help-modal.tsx

@ -36,12 +36,11 @@ export function HelpModal({
const resolvedTitle = "Tips";
const resolvedDescription =
description ??
t["Psychologically, this practice fosters a sense of empathy and contentment, which can reduce financial stress. Socially, lending strengthens neighborhood bonds and creates support networks that can lead to economic opportunities. This hadith encourages believers to lend dough, bread, and fire to increase their sustenance."] ??
t[
"Psychologically, this practice fosters a sense of empathy and contentment, which can reduce financial stress. Socially, lending strengthens neighborhood bonds and creates support networks that can lead to economic opportunities. This hadith encourages believers to lend dough, bread, and fire to increase their sustenance."
] ??
"Psychologically, this practice fosters a sense of empathy and contentment, which can reduce financial stress. Socially, lending strengthens neighborhood bonds and creates support networks that can lead to economic opportunities. This hadith encourages believers to lend dough, bread, and fire to increase their sustenance.";
const resolvedButtonText =
buttonText ??
t["Got it"] ??
"Got it";
const resolvedButtonText = buttonText ?? t["Got it"] ?? "Got it";
const closeSheet = useCallback(() => {
if (isClosing) return;

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

@ -288,7 +288,9 @@ export function InformationSheet({
</h2>
{description ? (
<div className="mt-3 group-12 text-[#4D4D4D]">{description}</div>
<div className="mt-3 group-12 text-[#4D4D4D]">
{description}
</div>
) : null}
{resolvedButtons ? (

11
src/components/Componentes/loading-border-spinner.tsx

@ -1,12 +1,19 @@
import type { ComponentProps } from "react";
export function LoadingBorderSpinner({ className = "", ...props }: ComponentProps<"span">) {
export function LoadingBorderSpinner({
className = "",
...props
}: ComponentProps<"span">) {
const hasBorder = className.split(" ").some((c) => c.startsWith("border-"));
const borderClasses = hasBorder
? ""
: "border-2 border-rose-500/25 border-t-rose-500 dark:border-rose-400/20 dark:border-t-rose-400";
const hasSize = className.split(" ").some((c) => c.startsWith("size-") || c.startsWith("w-") || c.startsWith("h-"));
const hasSize = className
.split(" ")
.some(
(c) => c.startsWith("size-") || c.startsWith("w-") || c.startsWith("h-"),
);
const sizeClasses = hasSize ? "" : "size-5";
return (

20
src/components/Componentes/loading-icon-spinner.tsx

@ -1,6 +1,9 @@
import type { ComponentProps } from "react";
export function LoadingIconSpinner({ className = "", ...props }: ComponentProps<"svg">) {
export function LoadingIconSpinner({
className = "",
...props
}: ComponentProps<"svg">) {
return (
<svg
className={`animate-spin transition-all duration-300 ease-out animate-fade-in ${className}`}
@ -9,8 +12,19 @@ export function LoadingIconSpinner({ className = "", ...props }: ComponentProps<
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}

6
src/components/Componentes/loading-select-spinner.tsx

@ -1,4 +1,8 @@
export function LoadingSelectSpinner({ className = "" }: { className?: string }) {
export function LoadingSelectSpinner({
className = "",
}: {
className?: string;
}) {
return (
<span
className={`size-3 animate-spin rounded-full inline-block border border-neutral-200 dark:border-neutral-800 border-t-rose-500 dark:border-t-rose-400 animate-fade-in ${className}`.trim()}

4
src/components/Componentes/loading-skeleton.tsx

@ -14,7 +14,9 @@ export function LoadingSkeleton({
? "shimmer-bg"
: "animate-pulse bg-neutral-200/80 dark:bg-neutral-800/80";
const hasRoundedClass = className.split(" ").some((c) => c.startsWith("rounded-"));
const hasRoundedClass = className
.split(" ")
.some((c) => c.startsWith("rounded-"));
const roundedClass = hasRoundedClass ? "" : "rounded-md";
return (

5
src/components/Componentes/loading-three-dot.tsx

@ -2,7 +2,10 @@ import type { ComponentProps } from "react";
interface LoadingThreeDotProps extends ComponentProps<"span"> {}
export function LoadingThreeDot({ className = "", ...props }: LoadingThreeDotProps) {
export function LoadingThreeDot({
className = "",
...props
}: LoadingThreeDotProps) {
return (
<span
className={`inline-flex items-center justify-center gap-1.5 py-0.5 ${className}`}

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

@ -14,6 +14,7 @@ import SupportSheet from "./support-sheet";
import InformationSheet from "./information-sheet";
import { useQueryClient } from "@tanstack/react-query";
import ErrorToast from "./error-toast";
import { hasSupportAccess } from "./support-access";
import {
useHabcoinPaymentMutation,
extractHabcoinPaymentUrl,
@ -143,7 +144,9 @@ export function NavigationButton({
err?.response?.status === 400
) {
setToastMessage(
t["Insufficient coin balance. Please recharge your account."] ||
t[
"Insufficient coin balance. Please recharge your account."
] ||
"Insufficient coin balance. Please recharge your account.",
);
} else {
@ -156,9 +159,7 @@ export function NavigationButton({
>
<div className="inline-flex w-full items-center justify-center gap-2 rounded-[18px] bg-[#F0445B] px-4 h-[52px] text-[16px] font-semibold text-white shadow-[0_10px_18px_rgba(240,68,91,0.28)] transition-opacity active:opacity-90 min-w-0">
<span className="truncate">
{isRenewing
? t["Renewing..."]
: t["Renew Subscription"]}
{isRenewing ? t["Renewing..."] : t["Renew Subscription"]}
</span>
{!isRenewing && (
@ -186,7 +187,11 @@ export function NavigationButton({
<InformationSheet
icon="/assets/images/icon-park-outline_diamond.svg"
title={t["No Active Subscription"]}
description={t["You currently do not have an active subscription. Activation of subscription is only possible when your first case is introduced to you."]}
description={
t[
"You currently do not have an active subscription. Activation of subscription is only possible when your first case is introduced to you."
]
}
buttons={t["Got it"]}
onClose={() => setIsSubscriptionInfoOpen(false)}
/>
@ -210,14 +215,13 @@ export function NavigationButton({
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, [isDropdownOpen]);
// پشتیبانی برای هم خانم‌ها و هم آقایان در هر وضعیتی فعال و در دسترس است
const hasSupportAccess = true;
const canAccessSupport = hasSupportAccess(profile);
if (icon === "subscription" && isFemale) {
return <div className="size-10" />;
}
if (icon === "support" && !hasSupportAccess) {
if (icon === "support" && !canAccessSupport) {
return <div className="size-10" />;
}
@ -301,7 +305,9 @@ export function NavigationButton({
const resolvedHelpDescription =
helpDescription ??
(icon === "document"
? (t as any)["Taking this test is not mandatory, but it will help you better search for a spouse."]
? (t as any)[
"Taking this test is not mandatory, but it will help you better search for a spouse."
]
: undefined);
if (icon === "more") {
@ -331,6 +337,7 @@ export function NavigationButton({
{isDropdownOpen && (
<div className="absolute right-0 mt-2 w-48 origin-top-right rounded-2xl bg-white shadow-[0_12px_30px_rgba(0,0,0,0.15)] ring-1 ring-black/5 focus:outline-hidden z-50 overflow-hidden divide-y divide-gray-100 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="py-1">
{canAccessSupport && (
<button
type="button"
onClick={() => {
@ -348,6 +355,7 @@ export function NavigationButton({
/>
<span>{t["Support"]}</span>
</button>
)}
<button
type="button"

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

@ -6,6 +6,7 @@ import {
NavigationButton,
type NavigationButtonProps,
} from "./navigation-button";
import { hasSupportAccess } from "./support-access";
type PageHeaderProps = {
className?: string;
@ -23,21 +24,16 @@ export function PageHeader({
const { dictionary: t } = useI18n();
const { data: profile } = useMarriageProfileQuery();
const isMale = profile?.gender === "male";
const iconFromProp = rightButton?.icon;
const isDefaultOrSubscription =
iconFromProp === undefined || iconFromProp === "subscription";
let finalIcon: NavigationButtonProps["icon"] = iconFromProp || "support";
if (isDefaultOrSubscription) {
if (isMale) {
if (profile?.active_subscription) {
finalIcon = "more";
} else {
finalIcon = "subscription";
}
} else {
if (hasSupportAccess(profile)) {
finalIcon = "support";
} else if (profile?.gender === "male") {
finalIcon = "subscription";
}
}

54
src/components/Componentes/page-loading-skeleton.tsx

@ -3,12 +3,66 @@ import { PageBackground } from "./page-background";
type PageLoadingSkeletonProps = {
compact?: boolean;
variant?: "profile" | "questions" | "test";
};
/** Shared full-page loading state for the mobile frontend. */
export function PageLoadingSkeleton({
compact = false,
variant = "profile",
}: PageLoadingSkeletonProps) {
if (variant === "questions" || variant === "test") {
return (
<>
<PageBackground disabled />
<main
aria-busy="true"
className="-mx-[17px] flex min-h-svh flex-col px-[17px] pt-[max(16px,calc(var(--safe-top)+8px))] pb-[calc(24px+var(--safe-bottom))] bg-[#F7F1F0]"
>
{/* Header */}
<header className="flex h-11 items-center justify-between shrink-0 mb-4">
<LoadingSkeleton className="size-10 rounded-[15px]" />
<LoadingSkeleton className="h-5 w-28 rounded-lg" />
<LoadingSkeleton className="size-10 rounded-[15px]" />
</header>
<div className="mx-auto flex w-full max-w-md flex-1 flex-col pt-3 min-h-0">
{/* Progress Section */}
<div className="w-full shrink-0 pt-2 pb-[24px]">
<div className="mb-[7px] flex items-center justify-between">
<LoadingSkeleton className="h-3.5 w-24" />
<LoadingSkeleton className="h-3.5 w-12" />
</div>
<LoadingSkeleton className="h-[6px] w-full rounded-full" />
</div>
{/* Question Section */}
{variant === "questions" ? (
<section className="flex flex-1 flex-col pt-6">
<LoadingSkeleton className="h-6 w-3/4 mb-8" />
<LoadingSkeleton className="h-14 w-full rounded-[18px]" />
</section>
) : (
<section className="flex flex-1 flex-col pt-6">
<div className="flex justify-center mb-8">
<LoadingSkeleton className="h-6 w-4/5" />
</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<LoadingSkeleton
key={i}
className="w-full h-14 rounded-[16px]"
/>
))}
</div>
</section>
)}
</div>
</main>
</>
);
}
return (
<>
<PageBackground disabled />

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

@ -94,6 +94,28 @@ export function getLocalSectionProgress(
try {
if (typeof window === "undefined") return null;
if (item.slug === "personality_test" || item.slug === "glasser_5_needs_test") {
const draftKey = `marriage:tests:${item.slug}:draft`;
const draftRaw = window.localStorage.getItem(draftKey);
if (draftRaw) {
try {
const parsed = JSON.parse(draftRaw);
if (parsed && typeof parsed.answers === "object" && parsed.answers !== null) {
const answeredCount = Object.keys(parsed.answers).length;
const totalCount = item.slug === "personality_test" ? 187 : 35;
return Math.max(
0,
Math.min(
100,
Math.round((answeredCount / totalCount) * 100),
),
);
}
} catch {}
}
return null;
}
const fields: Record<string, unknown>[] = [];
let hasFoundStorage = false;

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

@ -371,9 +371,20 @@ export function QuestionBirthplace({
fill="none"
className="shrink-0"
>
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" />
<circle
cx="8"
cy="8"
r="6"
stroke="currentColor"
strokeWidth="2"
/>
<circle cx="8" cy="8" r="2" fill="currentColor" />
<path d="M8 0V3M8 13V16M0 8H3M13 8H16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path
d="M8 0V3M8 13V16M0 8H3M13 8H16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
<span>{locale === "fa" ? "خودکار" : "Auto"}</span>
</>

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

@ -331,8 +331,7 @@ export function QuestionFile({
</div>
)}
</div>
) : (
/* ────── DEFAULT EMPTY STATE ────── */
) : /* ────── DEFAULT EMPTY STATE ────── */
isPending ? (
<LoadingSkeleton className="h-24 w-full rounded-[24px]" />
) : (
@ -356,7 +355,6 @@ export function QuestionFile({
</span>
) : null}
</>
)
)}
</span>
</div>

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

@ -365,8 +365,9 @@ export default function QuestionNumber({
</div>
{isOutOfRange ? (
<span className="block text-[10px] font-semibold text-[#F2465F]">
{t["The value entered seems incorrect. Please provide a realistic value."] ||
`Please enter a value between ${min} and ${max}`}
{t[
"The value entered seems incorrect. Please provide a realistic value."
] || `Please enter a value between ${min} and ${max}`}
</span>
) : null}
</div>
@ -416,8 +417,9 @@ export default function QuestionNumber({
/>
{isOutOfRange ? (
<span className="block text-[10px] font-semibold text-[#F2465F]">
{t["The value entered seems incorrect. Please provide a realistic value."] ||
`Please enter a value between ${min} and ${max}`}
{t[
"The value entered seems incorrect. Please provide a realistic value."
] || `Please enter a value between ${min} and ${max}`}
</span>
) : null}
</div>

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

@ -172,8 +172,12 @@ function SectionFlowContent({
<div className="w-full px-[17px] mb-6 shrink-0">
<NoticeBox>
{isUnder27
? t["To preserve your peace of mind, security, and dignity, the acquaintance process on our platform follows authentic and respectful family traditions. Having a trusted person (preferably a father or mother) as a representative, in addition to reflecting your noble family background, encourages the other party to step forward with full seriousness, respect, and confidence."]
: t["Our goal is to establish lasting bonds based on mutual trust. Although registering a representative is not mandatory for you, introducing a trusted person (such as a father, mother, or family elder) demonstrates your transparency and serious intent for marriage. Profiles that feature a trusted representative command significantly higher credibility and provide greater peace of mind to the other party's family."]}
? t[
"To preserve your peace of mind, security, and dignity, the acquaintance process on our platform follows authentic and respectful family traditions. Having a trusted person (preferably a father or mother) as a representative, in addition to reflecting your noble family background, encourages the other party to step forward with full seriousness, respect, and confidence."
]
: t[
"Our goal is to establish lasting bonds based on mutual trust. Although registering a representative is not mandatory for you, introducing a trusted person (such as a father, mother, or family elder) demonstrates your transparency and serious intent for marriage. Profiles that feature a trusted representative command significantly higher credibility and provide greater peace of mind to the other party's family."
]}
</NoticeBox>
</div>
)}

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

@ -133,7 +133,8 @@ export default function QuestionText({
className="h-[18px] w-[18px] shrink-0 rounded border-[#D0D5DD] text-[#F2465F] focus:ring-[#F2465F] accent-[#F2465F] cursor-pointer"
/>
<span className="text-[13.5px] font-semibold text-[#4F4F4F]">
{t["This is not a priority for me"] ?? "This is not a priority for me"}
{t["This is not a priority for me"] ??
"This is not a priority for me"}
</span>
</label>
</div>

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

@ -80,8 +80,12 @@ export function QuestionTitle({ question, className }: QuestionTitleProps) {
onClose={() => setIsHelpOpen(false)}
description={(() => {
const fullPath = "questions." + question.tooltip;
const englishVal = (pathMap as Record<string, string>)[fullPath];
return englishVal ? (t as any)[englishVal] || englishVal : question.tooltip;
const englishVal = (pathMap as Record<string, string>)[
fullPath
];
return englishVal
? (t as any)[englishVal] || englishVal
: question.tooltip;
})()}
/>
</>

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

@ -142,8 +142,12 @@ export default function RequiredStepsCard({
<p className="mt-2.5 max-w-[220px] group-12 leading-[1.25] font-medium text-white/80">
{isCompleted
? t["You can now submit your request so we can start finding the right match for you"]
: t["Please complete the required information so we can find suitable matches for you"]}
? t[
"You can now submit your request so we can start finding the right match for you"
]
: t[
"Please complete the required information so we can find suitable matches for you"
]}
</p>
</div>

61
src/components/Componentes/silent-reloader.tsx

@ -0,0 +1,61 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useEffect } from "react";
type SilentReloaderProps = {
children: ReactNode;
};
export default function SilentReloader({ children }: SilentReloaderProps) {
const queryClient = useQueryClient();
useEffect(() => {
if (typeof window !== "undefined") {
(window as any).__queryClient = queryClient;
}
let lastReloadTime = 0;
const handleReload = () => {
const now = Date.now();
if (now - lastReloadTime < 5000) {
return;
}
lastReloadTime = now;
// Invalidate all active queries so fresh data is reloaded from backend
queryClient.invalidateQueries();
};
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
handleReload();
}
};
const handlePageShow = () => {
handleReload();
};
window.addEventListener("focus", handleReload);
window.addEventListener("pageshow", handlePageShow);
document.addEventListener("visibilitychange", handleVisibilityChange);
// Listen for Flutter response events if available
const win = window as any;
const unbindFlutter =
typeof win.addFlutterResponseListener === "function"
? win.addFlutterResponseListener(() => handleReload())
: null;
return () => {
window.removeEventListener("focus", handleReload);
window.removeEventListener("pageshow", handlePageShow);
document.removeEventListener("visibilitychange", handleVisibilityChange);
if (typeof unbindFlutter === "function") {
unbindFlutter();
}
};
}, [queryClient]);
return <>{children}</>;
}

23
src/components/Componentes/support-access.ts

@ -0,0 +1,23 @@
import type { MarriageProfile } from "@/hooks/marriage/types";
/**
* Support is unavailable until a user has finished their profile. Women gain
* access on the waiting screen; men additionally need a currently valid
* subscription.
*/
export function hasSupportAccess(profile?: MarriageProfile): boolean {
if (!profile || !["waiting", "in_case", "matched"].includes(profile.status)) {
return false;
}
if (profile.gender === "female") {
return true;
}
const subscription = profile.active_subscription;
return (
profile.gender === "male" &&
subscription?.is_active === true &&
subscription.is_valid === true
);
}

6
src/components/Componentes/support-sheet.tsx

@ -137,7 +137,11 @@ export function SupportSheet({ isOpen, onClose }: SupportSheetProps) {
</h3>
<p className="w-full font-normal text-[14px] leading-[20px] text-[#747474] text-center">
{t["If you encounter any issues, please feel free to contact our support specialists in WhatsApp"]}
{
t[
"If you encounter any issues, please feel free to contact our support specialists in WhatsApp"
]
}
</p>
</div>
</div>

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

@ -63,11 +63,7 @@ export function SwipeButton({
}
// Single Button full width
return (
<div className="w-full flex">
{actionButton}
</div>
);
return <div className="w-full flex">{actionButton}</div>;
}
export default SwipeButton;

30
src/components/Componentes/test-intro-page.tsx

@ -1,6 +1,6 @@
"use client";
import Image from "next/image";
import type { ReactNode } from "react";
import { IoTimeOutline } from "react-icons/io5";
import Button from "./button";
@ -8,39 +8,38 @@ type TestIntroPageProps = {
title: string;
estimateTime: string;
description: string;
bulletPoints: readonly string[];
bulletPoints?: readonly string[];
disclaimerText: string;
startLabel: string;
onStart: () => void;
children?: ReactNode;
};
export default function TestIntroPage({
title,
estimateTime,
description,
bulletPoints,
bulletPoints = [],
disclaimerText,
startLabel,
onStart,
children,
}: TestIntroPageProps) {
return (
<div className="flex flex-1 flex-col min-h-0 overflow-y-auto">
{/* Hero Image */}
<div className="mx-auto w-full max-w-[340px] overflow-hidden rounded-[20px] shadow-[0_8px_28px_rgba(0,0,0,0.08)]">
<Image
<div className="mx-auto w-[348px] h-[156px] shrink-0 overflow-hidden rounded-[20px] shadow-[0_8px_28px_rgba(0,0,0,0.08)]">
<img
src="/assets/images/test-intro-hero.png"
alt=""
aria-hidden="true"
width={340}
height={200}
className="w-full h-auto object-cover"
priority
className="w-full h-full object-cover"
/>
</div>
{/* Disclaimer Card */}
<div className="mt-5 mx-1 rounded-[16px] bg-[#FFF1F4] border border-[#FDDDE3] px-4 py-4">
<p className="text-center group-12 leading-[1.55] font-medium text-[#C0475B]">
<p className="text-justify group-12 leading-[1.55] font-medium text-[#C0475B]">
{disclaimerText}
</p>
</div>
@ -60,7 +59,9 @@ export default function TestIntroPage({
</div>
{/* Bullet Points */}
{bulletPoints.length > 0 ? (
{children ? (
<div className="mt-4">{children}</div>
) : bulletPoints.length > 0 ? (
<ul className="mt-4 space-y-3 pl-1">
{bulletPoints.map((point, index) => (
<li
@ -83,12 +84,7 @@ export default function TestIntroPage({
className="sticky bottom-0 bg-[#F7F1F0] px-1 pt-3"
style={{ paddingBottom: "calc(24px + var(--safe-bottom))" }}
>
<Button
className="rounded-[14px] font-bold text-[16px] !bg-linear-to-r !from-[#F2465F] !to-[#E03950] !text-white shadow-[0_12px_28px_rgba(242,70,95,0.38)]"
onClick={onStart}
>
{startLabel}
</Button>
<Button onClick={onStart}>{startLabel}</Button>
</div>
</div>
);

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

@ -1,13 +1,14 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { GoArrowLeft, GoCheck } from "react-icons/go";
import { IoInformation } from "react-icons/io5";
import { useEffect, useState } from "react";
import { GoArrowLeft } from "react-icons/go";
import Button from "./button";
import { ExplanationUiFont } from "./explanation-ui-font";
import NavigationButton from "./navigation-button";
import StickyHeader from "./sticky-header";
import { PageBackground } from "./page-background";
import StickyHeader from "./sticky-header";
import TestLoadingScreen from "./test-loading-screen";
export type QuestionOption = {
@ -32,7 +33,72 @@ type TestQuestionsFlowProps = {
stepsLabel?: string;
onFinish?: (answers: Record<number, string | number>) => void;
onClose?: () => void;
draftStorageKey?: string;
};
type StoredTestDraft = {
answers?: Record<number, string | number>;
currentIndex?: number;
};
function getStoredDraft(
storageKey: string | 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;
let result = str.trim();
// Remove trailing numbers in parentheses, e.g., (1) or (۱)
result = result.replace(/\s*\([\d۱۲۳۴۵۶۷۸۹۰]+\)\s*$/, "");
if (result.startsWith(')')) {
result = result.slice(1).trim();
}
if (result.endsWith('(')) {
result = result.slice(0, -1).trim();
}
let openCount = 0;
let closeCount = 0;
for (let i = 0; i < result.length; i++) {
if (result[i] === '(') openCount++;
else if (result[i] === ')') closeCount++;
}
if (openCount > closeCount) {
result = result + ')'.repeat(openCount - closeCount);
} else if (closeCount > openCount) {
result = '('.repeat(closeCount - openCount) + result;
}
if (result.length > 0) {
result = result.charAt(0).toUpperCase() + result.slice(1);
}
return result;
}
export default function TestQuestionsFlow({
title,
@ -44,10 +110,15 @@ export default function TestQuestionsFlow({
stepsLabel = "Steps",
onFinish,
onClose,
draftStorageKey,
}: TestQuestionsFlowProps) {
const router = useRouter();
const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState<Record<number, string | number>>({});
const [currentIndex, setCurrentIndex] = useState(
() => getStoredDraft(draftStorageKey, questions.length).currentIndex,
);
const [answers, setAnswers] = useState<Record<number, string | number>>(
() => getStoredDraft(draftStorageKey, questions.length).answers,
);
const [isSubmitting, setIsSubmitting] = useState(false);
const currentQuestion = questions[currentIndex] ?? questions[0];
@ -56,6 +127,15 @@ export default function TestQuestionsFlow({
const selectedValue = currentQuestion
? answers[currentQuestion.id]
: undefined;
useEffect(() => {
if (!draftStorageKey) return;
try {
window.localStorage.setItem(
draftStorageKey,
JSON.stringify({ answers, currentIndex }),
);
} catch {}
}, [answers, currentIndex, draftStorageKey]);
const handleOptionSelect = (value: string | number) => {
if (!currentQuestion) return;
@ -85,6 +165,7 @@ export default function TestQuestionsFlow({
if (onFinish) {
await onFinish(answers);
}
if (draftStorageKey) window.localStorage.removeItem(draftStorageKey);
router.back();
} catch {
// ignore
@ -107,6 +188,9 @@ export default function TestQuestionsFlow({
}
const progressPercent = ((currentIndex + 1) / totalQuestions) * 100;
const areAllQuestionsAnswered = questions.every(
(question) => answers[question.id] !== undefined,
);
return (
<>
@ -156,52 +240,77 @@ export default function TestQuestionsFlow({
{/* Question Section */}
<div className="flex-1 flex flex-col justify-start overflow-y-auto pt-6 pb-4">
{/* Question Title */}
<h2 className="group-16 font-bold text-[#1B1B1B] leading-[1.45] text-center px-2 mb-8">
{currentQuestion.text}{" "}
<span className="inline-flex items-center justify-center size-[18px] rounded-full bg-[#F2465F] text-white align-middle -mt-0.5 ml-1">
<IoInformation className="size-3" />
</span>
<h2
className="group-16 font-bold text-[#1B1B1B] leading-[1.45] text-center px-2 mb-8 flex items-center justify-center"
style={{ minHeight: "5.8em" }}
>
<span className="w-full">{currentQuestion.text}</span>
</h2>
{/* Answer Options Stack */}
<div className="space-y-3">
{currentQuestion.options.map((option, idx) => {
{(() => {
const options = currentQuestion.options || [];
return (
<div className="flex flex-col gap-3.5 pt-1">
{options.map((option) => {
const isSelected = selectedValue === option.value;
return (
<button
key={idx}
key={`${currentQuestion.id}-${String(option.value)}`}
type="button"
onClick={() => handleOptionSelect(option.value)}
className={`w-full rounded-[16px] px-4 py-4 flex items-center gap-3.5 text-left transition-all duration-200 cursor-pointer ${
className={[
"cursor-pointer rounded-[16px] font-semibold text-[15px] transition-all duration-200 active:scale-[0.99] flex items-center gap-3",
"w-full px-5 py-4 text-start leading-snug",
isSelected
? "bg-[#333336] text-white shadow-md"
: "bg-[#EFEFEF] text-[#1B1B1B] hover:bg-[#E7E7E7]"
}`}
? "bg-[#F0445B] text-white shadow-[0_8px_20px_rgba(240,68,91,0.25)]"
: "bg-[#FAFAFA] text-[#181818] hover:bg-white shadow-[0_2px_6px_rgba(0,0,0,0.03)] border border-[#F0EDED]",
].join(" ")}
>
{/* Circle Icon */}
<div
className={`flex size-6 shrink-0 items-center justify-center rounded-full transition-colors ${
className={[
"size-[18px] shrink-0 rounded-full border-[2px] transition-all flex items-center justify-center",
isSelected
? "bg-white text-[#333336]"
: "border-2 border-[#C5C5C8] bg-transparent"
}`}
? "border-white bg-white text-[#F0445B]"
: "border-[#D0D5DD] bg-white text-transparent",
].join(" ")}
>
{isSelected ? (
<GoCheck className="size-4 stroke-[1.5]" />
) : null}
{isSelected && (
<div className="size-[8px] rounded-full bg-current" />
)}
</div>
{/* Option Text */}
<span
className={`group-14 font-semibold leading-snug flex-1 ${isSelected ? "text-white" : "text-[#1B1B1B]"}`}
{option.label.includes(" - ") ? (
(() => {
const parts = option.label.split(" - ");
const title = formatOptionLabel(parts[0]);
const description = formatOptionLabel(parts.slice(1).join(" - "));
return (
<span className="flex flex-col gap-1 text-start flex-1">
<span className="font-bold">{title}</span>
<ExplanationUiFont
textColor={
isSelected
? "text-white/85"
: "text-[#667085]"
}
>
{option.label}
{description}
</ExplanationUiFont>
</span>
);
})()
) : (
<span className="flex-1">{formatOptionLabel(option.label)}</span>
)}
</button>
);
})}
</div>
);
})()}
</div>
{/* Bottom Actions Bar */}
@ -220,8 +329,12 @@ export default function TestQuestionsFlow({
{/* Finish Button on Last Question */}
{isLastQuestion ? (
<Button
className="!w-auto rounded-[14px] !py-[10px] !px-7 font-bold text-[15px] !bg-linear-to-r !from-[#F2465F] !to-[#E03950] !text-white shadow-[0_8px_20px_rgba(242,70,95,0.35)]"
disabled={selectedValue === undefined || isSubmitting}
className="!w-auto !h-[42px] px-7"
disabled={
selectedValue === undefined ||
!areAllQuestionsAnswered ||
isSubmitting
}
onClick={handleFinishSubmit}
>
{finishLabel}

2
src/data/cattell-fallback.ts

@ -162,7 +162,7 @@ export const cattellFallbackQuestions: CattellFallbackQuestion[] = [
{
question_number: 27,
text: "من تعیین شده ام برای انجام",
options: [")فقط چند عملکرد (تا 5", "چندین عملکرد", "تعداد زیادی توابع"],
options: ["فقط چند عملکرد (تا 5)", "چندین عملکرد", "تعداد زیادی توابع"],
},
{
question_number: 28,

2
src/hooks/marriage/use-marriage-config.ts

@ -18,7 +18,5 @@ export function useMarriageConfigQuery() {
return useQuery({
queryKey: ["marriage", "config"],
queryFn: getMarriageConfig,
staleTime: 5 * 60 * 1000, // 5 minutes cache
refetchOnWindowFocus: false,
});
}

4
src/lib/get-submit-path.ts

@ -4,7 +4,9 @@ import {
isWithinMatchStartGrace,
} from "./match-start-grace";
export function getSubmitPath(profile: MarriageProfileResponse | undefined): string {
export function getSubmitPath(
profile: MarriageProfileResponse | undefined,
): string {
clearLegacyMatchSubmittedFlag();
if (!profile) {

48
src/translations/locales/en.json

@ -758,5 +758,51 @@
"{completed} of {total} required steps completed": "{completed} of {total} required steps completed",
"{days} days remaining of your subscription.": "{days} days remaining of your subscription.",
"⚠️ This section is completely confidential and is only used for matching and review by advisors.": "⚠️ This section is completely confidential and is only used for matching and review by advisors.",
"Please select the reason for cancellation:": "Please select the reason for cancellation:"
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Marriage advisors": "Marriage advisors",
"Dr. Hasti Masoudi": "Dr. Hasti Masoudi",
"Specialist in clinical psychology": "Specialist in clinical psychology",
"Personal Development": "Personal Development",
"Family Counselor": "Family Counselor",
"Relationship Coach": "Relationship Coach",
"Dr. Ali Rezaei": "Dr. Ali Rezaei",
"Family & marriage counselor": "Family & marriage counselor",
"Premarital Counselling": "Premarital Counselling",
"Conflict Resolution": "Conflict Resolution",
"Behavioral Therapy": "Behavioral Therapy",
"Dr. Sara Alavi": "Dr. Sara Alavi",
"Relationship & intimacy coach": "Relationship & intimacy coach",
"Emotional Health": "Emotional Health",
"Self-Awareness": "Self-Awareness",
"Communication Skills": "Communication Skills",
"Text": "Text",
"Voice Call": "Voice Call",
"Manager": "Manager",
"Team Manager": "Team Manager",
"Science": "Science",
"Key Applications of the Cattell Test in Partner Matching": "Key Applications of the Cattell Test in Partner Matching",
"Understanding Personality Traits": "Understanding Personality Traits",
"Helps provide an overall picture of traits such as sociability, independence, emotional sensitivity, and interpersonal style.": "Helps provide an overall picture of traits such as sociability, independence, emotional sensitivity, and interpersonal style.",
"Assessing Communication Style": "Assessing Communication Style",
"Shows how a person typically communicates, expresses emotions, and builds closeness in relationships.": "Shows how a person typically communicates, expresses emotions, and builds closeness in relationships.",
"Understanding Responses to Stress and Conflict": "Understanding Responses to Stress and Conflict",
"Offers insight into emotional stability, tension levels, and how a person may react in difficult or stressful situations.": "Offers insight into emotional stability, tension levels, and how a person may react in difficult or stressful situations.",
"Assessing Independence and Decision-Making": "Assessing Independence and Decision-Making",
"Helps identify the person’s level of independence, assertiveness, and preference for individual or shared decision-making.": "Helps identify the person’s level of independence, assertiveness, and preference for individual or shared decision-making.",
"Identifying Potential Differences and Challenges": "Identifying Potential Differences and Challenges",
"Comparing two individuals’ results can highlight personality differences that may require attention in a long-term relationship.": "Comparing two individuals’ results can highlight personality differences that may require attention in a long-term relationship.",
"Supporting Better Match Recommendations": "Supporting Better Match Recommendations",
"Alongside interviews and other relationship criteria, personality assessment can help make the matching process more targeted and improve the evaluation of compatibility.": "Alongside interviews and other relationship criteria, personality assessment can help make the matching process more targeted and improve the evaluation of compatibility.",
"Understanding Core Psychological Needs": "Understanding Core Psychological Needs",
"Helps identify the importance of the five basic needs—love and belonging, power, freedom, fun, and survival—in each person’s life.": "Helps identify the importance of the five basic needs—love and belonging, power, freedom, fun, and survival—in each person’s life.",
"Recognizing Relationship Expectations": "Recognizing Relationship Expectations",
"Provides insight into what each person expects from a relationship, such as closeness, independence, security, achievement, or shared enjoyment.": "Provides insight into what each person expects from a relationship, such as closeness, independence, security, achievement, or shared enjoyment.",
"Assessing Personality Compatibility": "Assessing Personality Compatibility",
"Helps compare personality traits, behavioral tendencies, and interaction styles to identify areas of compatibility between two individuals.": "Helps compare personality traits, behavioral tendencies, and interaction styles to identify areas of compatibility between two individuals.",
"Identifying Potential Sources of Conflict": "Identifying Potential Sources of Conflict",
"Differences in needs or personality styles can highlight areas where misunderstandings, tension, or disagreements may arise in the relationship.": "Differences in needs or personality styles can highlight areas where misunderstandings, tension, or disagreements may arise in the relationship.",
"Improving Mutual Understanding": "Improving Mutual Understanding",
"Helps individuals better understand their own needs as well as their partner’s motivations, preferences, and emotional priorities.": "Helps individuals better understand their own needs as well as their partner’s motivations, preferences, and emotional priorities.",
"Supporting More Suitable Match Recommendations": "Supporting More Suitable Match Recommendations",
"Combining needs and personality assessments with interviews and other marriage criteria can help make partner recommendations more personalized and well-matched.": "Combining needs and personality assessments with interviews and other marriage criteria can help make partner recommendations more personalized and well-matched."
}

48
src/translations/locales/fa.json

@ -758,5 +758,51 @@
"{completed} of {total} required steps completed": "{completed} از {total} مرحله ضروری کامل شده است",
"{days} days remaining of your subscription.": "{days} روز از اعتبار اشتراک شما باقی مانده است.",
"⚠️ This section is completely confidential and is only used for matching and review by advisors.": "⚠️ این بخش کاملاً محرمانه است و فقط برای مچینگ و بررسی کارشناسان استفاده میشود.",
"Please select the reason for cancellation:": "Please select the reason for cancellation:"
"Please select the reason for cancellation:": "Please select the reason for cancellation:",
"Marriage advisors": "مشاوران ازدواج",
"Dr. Hasti Masoudi": "دکتر هستی مسعودی",
"Specialist in clinical psychology": "متخصص روانشناسی بالینی",
"Personal Development": "رشد فردی",
"Family Counselor": "مشاور خانواده",
"Relationship Coach": "مربی روابط",
"Dr. Ali Rezaei": "دکتر علی رضایی",
"Family & marriage counselor": "مشاور خانواده و ازدواج",
"Premarital Counselling": "مشاوره پیش از ازدواج",
"Conflict Resolution": "حل تعارضات",
"Behavioral Therapy": "رفتار درمانی",
"Dr. Sara Alavi": "دکتر سارا علوی",
"Relationship & intimacy coach": "مربی روابط و صمیمیت",
"Emotional Health": "سلامت عاطفی",
"Self-Awareness": "خودآگاهی",
"Communication Skills": "مهارت‌های ارتباطی",
"Text": "متنی",
"Voice Call": "تماس صوتی",
"Manager": "مدیر",
"Team Manager": "مدیر تیم",
"Science": "علمی",
"Key Applications of the Cattell Test in Partner Matching": "کاربردهای کلیدی آزمون کتل در زوج‌یابی",
"Understanding Personality Traits": "شناخت ویژگی‌های شخصیتی",
"Helps provide an overall picture of traits such as sociability, independence, emotional sensitivity, and interpersonal style.": "به ارائه تصویری کلی از ویژگی‌هایی مانند اجتماعی بودن، استقلال، حساسیت عاطفی و سبک ارتباطی کمک می‌کند.",
"Assessing Communication Style": "ارزیابی سبک ارتباطی",
"Shows how a person typically communicates, expresses emotions, and builds closeness in relationships.": "نشان می‌دهد که فرد معمولاً چگونه ارتباط برقرار می‌کند، احساسات خود را ابراز می‌کند و در روابط صمیمیت ایجاد می‌کند.",
"Understanding Responses to Stress and Conflict": "درک واکنش‌ها به استرس و تعارض",
"Offers insight into emotional stability, tension levels, and how a person may react in difficult or stressful situations.": "بینشی در مورد ثبات عاطفی، میزان تنش و نحوه واکنش فرد در موقعیت‌های دشوار یا استرس‌زا ارائه می‌دهد.",
"Assessing Independence and Decision-Making": "ارزیابی استقلال و تصمیم‌گیری",
"Helps identify the person’s level of independence, assertiveness, and preference for individual or shared decision-making.": "به شناسایی سطح استقلال، قاطعیت و تمایل فرد به تصمیم‌گیری فردی یا مشترک کمک می‌کند.",
"Identifying Potential Differences and Challenges": "شناسایی تفاوت‌ها و چالش‌های بالقوه",
"Comparing two individuals’ results can highlight personality differences that may require attention in a long-term relationship.": "مقایسه نتایج دو فرد می‌تواند تفاوت‌های شخصیتی را نشان دهد که ممکن است در یک رابطه طولانی‌مدت نیاز به توجه داشته باشد.",
"Supporting Better Match Recommendations": "پشتیبانی از پیشنهادهای همسریابی بهتر",
"Alongside interviews and other relationship criteria, personality assessment can help make the matching process more targeted and improve the evaluation of compatibility.": "در کنار مصاحبه‌ها و سایر معیارهای رابطه، ارزیابی شخصیت می‌تواند به هدفمندتر شدن فرآیند همسریابی و بهبود ارزیابی سازگاری کمک کند.",
"Understanding Core Psychological Needs": "درک نیازهای روانی اساسی",
"Helps identify the importance of the five basic needs—love and belonging, power, freedom, fun, and survival—in each person’s life.": "به شناسایی اهمیت پنج نیاز اساسی — عشق و احساس تعلق، قدرت، آزادی، تفریح و بقا — در زندگی هر فرد کمک می‌کند.",
"Recognizing Relationship Expectations": "شناخت انتظارات از رابطه",
"Provides insight into what each person expects from a relationship, such as closeness, independence, security, achievement, or shared enjoyment.": "بینشی در مورد آنچه هر فرد از رابطه انتظار دارد، مانند صمیمیت، استقلال، امنیت، موفقیت یا لذت مشترک، ارائه می‌دهد.",
"Assessing Personality Compatibility": "ارزیابی سازگاری شخصیتی",
"Helps compare personality traits, behavioral tendencies, and interaction styles to identify areas of compatibility between two individuals.": "به مقایسه ویژگی‌های شخصیتی، گرایش‌های رفتاری و سبک‌های تعاملی برای شناسایی زمینه‌های سازگاری بین دو فرد کمک می‌کند.",
"Identifying Potential Sources of Conflict": "شناسایی منابع بالقوه تعارض",
"Differences in needs or personality styles can highlight areas where misunderstandings, tension, or disagreements may arise in the relationship.": "تفاوت در نیازها یا سبک‌های شخصیتی می‌تواند زمینه‌هایی را نشان دهد که در آن‌ها ممکن است سوءتفاهم، تنش یا اختلاف در رابطه ایجاد شود.",
"Improving Mutual Understanding": "بهبود درک متقابل",
"Helps individuals better understand their own needs as well as their partner’s motivations, preferences, and emotional priorities.": "به افراد کمک می‌کند تا نیازهای خود و همچنین انگیزه‌ها، ترجیحات و اولویت‌های عاطفی شریک زندگی خود را بهتر درک کنند.",
"Supporting More Suitable Match Recommendations": "پشتیبانی از پیشنهادهای همسریابی مناسب‌تر",
"Combining needs and personality assessments with interviews and other marriage criteria can help make partner recommendations more personalized and well-matched.": "ترکیب ارزیابی نیازها و شخصیت با مصاحبه‌ها و سایر معیارهای ازدواج می‌تواند به شخصی‌سازی و هماهنگی بیشتر پیشنهادهای شریک زندگی کمک کند."
}

1043
src/translations/locales/ru.json
File diff suppressed because it is too large
View File

Loading…
Cancel
Save