Browse Source

feat: add internationalization and RTL support across UI components and profile API requests

master
mortezaei 3 weeks ago
parent
commit
61678091b3
  1. 343
      src/app/new-match/new-match-client.tsx
  2. 37
      src/app/new-match/profile/page.tsx
  3. 95
      src/app/questions-list/[slug]/question-detail-client.tsx
  4. 153
      src/app/questions-list/questions-list-client.tsx
  5. 28
      src/components/Componentes/call-result-sheet.tsx
  6. 32
      src/components/Componentes/dismiss-reason-sheet.tsx
  7. 11
      src/components/Componentes/female-consent-sheet.tsx
  8. 45
      src/components/Componentes/female-outcome-sheet.test.tsx
  9. 391
      src/components/Componentes/female-outcome-sheet.tsx
  10. 21
      src/components/Componentes/outcome-selection-sheet.tsx
  11. 14
      src/components/Componentes/slider-slide-four.tsx
  12. 17
      src/components/Componentes/subscription-required-sheet.tsx
  13. 209
      src/components/Componentes/token-switcher.tsx
  14. 30
      src/hooks/marriage/use-profile-main.ts
  15. 65
      src/lib/marriage-field-formatter.test.ts
  16. 160
      src/lib/marriage-field-formatter.ts

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

@ -4,35 +4,35 @@ import Image from "next/image";
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 { Ic } from "@/icons";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import { DiscountWidget } from "@/components/Componentes/discount-widget";
import ErrorToast from "@/components/Componentes/error-toast";
import InformationSheet from "@/components/Componentes/information-sheet";
import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton";
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot";
import MarriageAdvisorsOverlay, {
useMarriageAdvisorsOverlay,
} from "@/components/Componentes/marriage-advisors-overlay";
import ErrorToast from "@/components/Componentes/error-toast";
import MatchProfileOverlay, {
useMatchProfileOverlay,
} from "@/components/Componentes/match-profile-overlay";
import PageHeader from "@/components/Componentes/page-header";
import { PageBackground } from "@/components/Componentes/page-background";
import InformationSheet from "@/components/Componentes/information-sheet";
import PageHeader from "@/components/Componentes/page-header";
import SwipeButton from "@/components/Componentes/swipe-button";
import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton";
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot";
import { DiscountWidget } from "@/components/Componentes/discount-widget";
import type { CheckDiscountResult } from "@/hooks/marriage/use-validate-discount";
import { useHabcoinPaymentMutation } from "@/hooks/marriage/use-habcoin-payment";
import { useHabcoinInventoryQuery } from "@/hooks/marriage/use-habcoin-inventory";
import { useRespondToMarriageCaseMutation } from "@/hooks/marriage/use-case-respond";
import type {
MarriageField,
MarriageFieldValue,
MarriageMatchSummary,
MarriagePhoneFieldValue,
} from "@/hooks/marriage/types";
import { useRespondToMarriageCaseMutation } from "@/hooks/marriage/use-case-respond";
import { useHabcoinInventoryQuery } from "@/hooks/marriage/use-habcoin-inventory";
import { useHabcoinPaymentMutation } from "@/hooks/marriage/use-habcoin-payment";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import type { CheckDiscountResult } from "@/hooks/marriage/use-validate-discount";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { useViewPaddings } from "@/hooks/use-view-paddings";
import { Ic } from "@/icons";
import { getSubmitPath } from "@/lib/get-submit-path";
import {
buyHabibCoinPackages,
@ -93,11 +93,7 @@ const fieldCandidateMatchers = {
"education_level",
"education",
],
fieldOfStudy: [
"field_of_study",
"study_field",
"study",
],
fieldOfStudy: ["field_of_study", "study_field", "study"],
jobTitle: [
"job_title",
"job_title_and_description",
@ -178,8 +174,8 @@ import {
formatOptionValue,
getOrderedSummaryFields,
isMarriagePhoneFieldValue,
titleFromKey,
type SummaryFieldItem,
titleFromKey,
} from "@/lib/marriage-field-formatter";
function toDisplayField(
@ -594,121 +590,126 @@ export default function NewMatchClient() {
}}
className="min-h-0 flex-1 flex flex-col gap-4 pt-4 py-2 text-center"
>
{/* 1. Header Section */}
<section className="flex flex-col items-center shrink-0">
<div
aria-hidden="true"
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={66}
height={66}
alt="notification"
/>
</div>
<h2 className="text-[20px] font-bold mt-2.5 text-[#171717] leading-tight">
{matchHeadingTitle}
</h2>
<p className="mt-1.5 max-w-[320px] text-[12.5px] leading-[1.4] font-medium text-[#7C7C7C]">
{matchHeadingDescription}
</p>
</section>
{/* 2. Match Summary Card Section */}
<div className="w-full shrink-0" suppressHydrationWarning>
{(isLoading || (!matchSummary && isFetching)) ? (
<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>
{/* 1. Header Section */}
<section className="flex flex-col items-center shrink-0">
<div
aria-hidden="true"
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={66}
height={66}
alt="notification"
/>
</div>
<h2 className="text-[20px] font-bold mt-2.5 text-[#171717] leading-tight">
{matchHeadingTitle}
</h2>
<p className="mt-1.5 max-w-[320px] text-[12.5px] leading-[1.4] font-medium text-[#7C7C7C]">
{matchHeadingDescription}
</p>
</section>
{/* 2. Match Summary Card Section */}
<div className="w-full shrink-0" suppressHydrationWarning>
{isLoading || (!matchSummary && isFetching) ? (
<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>
<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="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="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-center text-[13px] font-semibold text-[#8A8A8A]">
Unable to load match summary.
</p>
) : matchSummary ? (
<>
{/* 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 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="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-center text-[13px] font-semibold text-[#8A8A8A]">
Unable to load match summary.
</p>
) : matchSummary ? (
<>
{/* Top Curved Banner */}
<div className="relative h-[74px] w-full overflow-hidden bg-[linear-gradient(180deg,#F0445B_0%,#F54B64_100%)]">
{/* Bottom Curve */}
<svg
className="absolute bottom-0 left-0 w-full h-3 text-[#FAFBFD]"
preserveAspectRatio="none"
viewBox="0 0 100 20"
fill="currentColor"
>
<path d="M0 20 C30 0 70 0 100 20 Z" />
</svg>
</div>
{/* Circular Overlapping Avatar */}
<div className="relative -mt-[38px] mx-auto w-[72px] h-[72px] rounded-full p-[3px] bg-white shadow-[0_4px_12px_rgba(0,0,0,0.08)] flex items-center justify-center z-10">
<div className="w-full h-full rounded-full overflow-hidden bg-[#F0445B] flex items-center justify-center">
<Image
src={candidateAvatarSrc}
width={68}
height={68}
alt="candidate avatar"
className="w-full h-full object-cover"
/>
</div>
{/* Circular Overlapping Avatar */}
<div className="relative -mt-[38px] mx-auto w-[72px] h-[72px] rounded-full p-[3px] bg-white shadow-[0_4px_12px_rgba(0,0,0,0.08)] flex items-center justify-center z-10">
<div className="w-full h-full rounded-full overflow-hidden bg-[#F0445B] flex items-center justify-center">
<Image
src={candidateAvatarSrc}
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>
{/* 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" />
<Ic name="heart" className="text-[#F0445B] size-3.5" />
<span className="h-[1px] w-8 bg-[#FFCCD3] rounded-full" />
</div>
<div className="flex items-center justify-center gap-2 mt-1 mb-1.5">
<span className="h-[1px] w-8 bg-[#FFCCD3] rounded-full" />
<Ic name="heart" className="text-[#F0445B] size-3.5" />
<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.items.map((item) => (
<ProfileInfoItem
key={item.id}
field={item}
icon={<Ic name={item.iconName} className="size-4 text-[#747474]" />}
/>
))}
{/* Button */}
<button
type="button"
disabled={isOpeningProfile}
onClick={handleOpenProfile}
className="
{/* Info Items List */}
<div className="space-y-2 px-3.5 pt-0.5 pb-3.5">
{matchDisplay.items.map((item) => (
<ProfileInfoItem
key={item.id}
field={item}
icon={
<Ic
name={item.iconName}
className="size-4 text-[#747474]"
/>
}
/>
))}
{/* Button */}
<button
type="button"
disabled={isOpeningProfile}
onClick={handleOpenProfile}
className="
mt-2.5
flex
h-[46px]
@ -727,43 +728,43 @@ export default function NewMatchClient() {
cursor-pointer
border-none
"
>
{isOpeningProfile ? (
<LoadingThreeDot />
) : (
<>
<Ic name="eye" className="size-5 text-white" />
<span>{t["View more details"]}</span>
</>
)}
</button>
</div>
</>
) : (
<p className="py-8 text-center text-[13px] font-semibold text-[#8A8A8A]">
No match summary is available yet.
</p>
)}
</section>
)}
</div>
>
{isOpeningProfile ? (
<LoadingThreeDot />
) : (
<>
<Ic name="eye" className="size-5 text-white" />
<span>{t["View more details"]}</span>
</>
)}
</button>
</div>
</>
) : (
<p className="py-8 text-center text-[13px] font-semibold text-[#8A8A8A]">
No match summary is available yet.
</p>
)}
</section>
)}
</div>
{/* 3. Advisor Section */}
<div className="w-full shrink-0">
<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."
]
}
avatars={advisorAvatars}
extraCount={7}
getAdvisorLabel={t["Get Advisor"]}
onGetAdvisor={openAdvisors}
/>
</div>
</main>
{/* 3. Advisor Section */}
<div className="w-full shrink-0">
<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."
]
}
avatars={advisorAvatars}
extraCount={7}
getAdvisorLabel={t["Get Advisor"]}
onGetAdvisor={openAdvisors}
/>
</div>
</main>
{profile?.can_edit_profile === false && (
<div
@ -774,7 +775,11 @@ export default function NewMatchClient() {
className="inline-flex w-full items-center justify-center gap-2 rounded-[9px] border-none bg-[#D1D1D6] px-4 h-[52px] text-center text-[#747474] shadow-none"
role="status"
>
<Ic name="lock" aria-hidden="true" className="size-[18px] shrink-0" />
<Ic
name="lock"
aria-hidden="true"
className="size-[18px] shrink-0"
/>
<span className="group-16 leading-none font-semibold">
{t["Profile is locked"]}
</span>
@ -859,7 +864,7 @@ export default function NewMatchClient() {
disabled={
paymentMutation.isPending || respondMutation.isPending
}
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
className="appearance-none border-0 bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
onClick={openDeclineConfirm}
>
<div className="inline-flex w-full items-center justify-center rounded-[11px] border border-[#747474] bg-transparent px-2 h-[52px] text-[16px] font-semibold text-[#747474] transition-opacity active:opacity-90 min-w-0">
@ -876,7 +881,7 @@ export default function NewMatchClient() {
{!hasEnoughCoins ? (
<button
type="button"
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
className="appearance-none border-0 bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
onClick={() => buyHabibCoinPackages()}
>
<div className="inline-flex w-full items-center justify-center gap-2 rounded-[11px] 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">
@ -891,7 +896,7 @@ export default function NewMatchClient() {
disabled={
paymentMutation.isPending || respondMutation.isPending
}
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
className="appearance-none border-0 bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
onClick={handlePayment}
>
<div className="inline-flex w-full items-center justify-center gap-2 rounded-[11px] 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">
@ -949,8 +954,12 @@ export default function NewMatchClient() {
buttons={({ close }) => (
<SwipeButton
disabled={respondMutation.isPending}
text={t["Decline"] || (locale === "fa" ? "رد پیشنهاد" : "Decline")}
cancelText={t["Cancel"] || (locale === "fa" ? "انصراف" : "Cancel")}
text={
t["Decline"] || (locale === "fa" ? "رد پیشنهاد" : "Decline")
}
cancelText={
t["Cancel"] || (locale === "fa" ? "انصراف" : "Cancel")
}
onCancel={() => {
close();
cancelDeclineConfirm();

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

@ -23,10 +23,6 @@ import type {
import { useRespondToMarriageCaseMutation } from "@/hooks/marriage/use-case-respond";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { getSubmitPath } from "@/lib/get-submit-path";
import { markMatchStarted } from "@/lib/match-start-grace";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import {
formatFieldLabel,
formatFieldValue,
@ -35,6 +31,9 @@ import {
isMarriagePhoneFieldValue,
titleFromKey,
} from "@/lib/marriage-field-formatter";
import { markMatchStarted } from "@/lib/match-start-grace";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
function isImageField(field: MarriageField) {
return /(avatar|image|photo|picture|portrait|upload)/i.test(
@ -89,10 +88,10 @@ function MatchField({
return (
<div className="flex flex-col items-start w-full border-b border-black/10 pb-[14px] mt-[14px]">
<p className="text-[12px] font-semibold leading-[17px] text-[#978787]">
<p className="text-[12px] font-semibold leading-[17px] text-[#978787] text-start">
{label}
</p>
<p className="text-[16px] font-bold leading-[20px] text-[#111111] mt-[7px] text-left">
<p className="text-[16px] font-bold leading-[20px] text-[#111111] mt-[7px] text-start">
{value}
</p>
</div>
@ -117,7 +116,7 @@ function MatchPublicProfileFields({
) {
return false;
}
if ((field as any).private === true) {
if ((field as { private?: boolean }).private === true) {
return false;
}
return true;
@ -138,11 +137,7 @@ function MatchPublicProfileFields({
return (
<div className="flex flex-col w-full mt-6" style={{ gap: "14px" }}>
{visibleFields.map((field) => (
<MatchField
key={field.key}
field={field}
dictionary={dictionary}
/>
<MatchField key={field.key} field={field} dictionary={dictionary} />
))}
</div>
);
@ -173,9 +168,7 @@ function NewMatchProfileSkeleton({
) : (
<div className="size-10 shrink-0" />
)}
<h1
className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white"
>
<h1 className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white">
{t["More detail"]}
</h1>
<div className="size-10 shrink-0" />
@ -235,7 +228,7 @@ function formatBoldText(text: string) {
return parts.map((part, index) => {
if (index % 2 === 1) {
return (
<strong key={index} className="font-bold text-[#111111]">
<strong key={part} className="font-bold text-[#111111]">
{part}
</strong>
);
@ -307,9 +300,7 @@ export default function NewMatchProfilePage({
onClose?.();
if (variables.action === "accept") {
const nextPath =
profile?.gender === "female"
? "/request-accepted"
: "/request-sent";
profile?.gender === "female" ? "/request-accepted" : "/request-sent";
router.replace(localizePath(nextPath, locale));
return;
}
@ -431,7 +422,7 @@ export default function NewMatchProfilePage({
<button
type="button"
onClick={() => setIsFemaleConsentChecked((value) => !value)}
className="flex w-full items-start gap-4 rounded-[15px] border-2 border-white bg-white/50 px-5 py-5 text-left shadow-[0_8px_24px_rgba(0,0,0,0.05)]"
className="flex w-full items-start gap-4 rounded-[15px] border-2 border-white bg-white/50 px-5 py-5 text-start shadow-[0_8px_24px_rgba(0,0,0,0.05)]"
>
<span
className={[
@ -446,7 +437,7 @@ export default function NewMatchProfilePage({
<span className="h-3 w-3 rounded-full bg-[#F0445B]" />
) : null}
</span>
<span className="text-xs leading-[1.35] text-[#3F3F3F]">
<span className="text-xs leading-[1.35] text-[#3F3F3F] flex-1 text-start">
{formatBoldText(
t[
"I confirm the family has **reviewed this profile** and **consents to communicate**."
@ -616,9 +607,7 @@ export default function NewMatchProfilePage({
iconLabel={t["Go back"]}
onClick={onClose}
/>
<h1
className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white"
>
<h1 className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white">
{t["More detail"]}
</h1>
<div className="size-10 shrink-0" />

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

@ -1,62 +1,62 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useSectionOverlay } from "@/components/Componentes/section-overlay-host";
import Button from "@/components/Componentes/button";
import DataErrorState from "@/components/Componentes/data-error-state";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import NavigationButton from "@/components/Componentes/navigation-button";
import { PageBackground } from "@/components/Componentes/page-background";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import {
hasQuestionAnswerValue,
QuestionAnswersProvider,
useQuestionAnswers,
} from "@/components/Componentes/question-answer-storage";
import { parseValue as parseBirthplaceValue } from "@/components/Componentes/question-birthplace";
import QuestionExitNavigationButton from "@/components/Componentes/question-exit-navigation-button";
import QuestionRenderer from "@/components/Componentes/question-renderer";
import { parseValue as parseBirthplaceValue } from "@/components/Componentes/question-birthplace";
import QuestionSectionFlow from "@/components/Componentes/question-section-flow";
import { useSectionOverlay } from "@/components/Componentes/section-overlay-host";
import StickyHeader from "@/components/Componentes/sticky-header";
import TestIntroPage from "@/components/Componentes/test-intro-page";
import TestCompletedSheet from "@/components/Componentes/test-completed-sheet";
import TestIntroPage from "@/components/Componentes/test-intro-page";
import TestQuestionsFlow, {
type TestQuestion,
} from "@/components/Componentes/test-questions-flow";
import {
getGlasserQuestions,
useGlasserQuestionsQuery,
useSubmitGlasserAssessmentMutation,
} from "@/hooks/marriage/use-glasser";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import {
getCattellQuestions,
useCattellQuestionsQuery,
useSubmitCattellAssessmentMutation,
} from "@/hooks/marriage/use-cattell";
import { useQueryClient } from "@tanstack/react-query";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import {
useFormOverviewQuery,
useFormSectionQuery,
} from "@/hooks/marriage/use-form-schema";
import {
getGlasserQuestions,
useGlasserQuestionsQuery,
useSubmitGlasserAssessmentMutation,
} from "@/hooks/marriage/use-glasser";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useCurrentProfileId } from "@/hooks/use-current-profile-id";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { isQuestionRequired, isQuestionVisible } from "@/lib/conditional-rules";
import {
convertOverviewToFrontendItems,
mapBackendSectionToFrontend,
type QuestionField,
} from "@/lib/schema-adapter";
import { isQuestionVisible, isQuestionRequired } from "@/lib/conditional-rules";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { defaultLocale, type Locale } from "@/translations/config";
import { formatEstimateTime } from "@/translations/format-estimate";
import { useI18n } from "@/translations/provider";
import { useCurrentProfileId } from "@/hooks/use-current-profile-id";
import {
getScopedAssessmentDraftKey,
readScopedAssessmentDraft,
removeScopedAssessmentDraft,
} from "@/lib/user-scoped-storage";
import { defaultLocale, type Locale } from "@/translations/config";
import { formatEstimateTime } from "@/translations/format-estimate";
import { useI18n } from "@/translations/provider";
type QuestionDetailClientProps = {
closeLabel: string;
@ -120,7 +120,9 @@ function QuestionFlowWrapper({
)?.[1];
const dobVal =
typeof dobAnswer === "object" && dobAnswer !== null && "value" in dobAnswer
typeof dobAnswer === "object" &&
dobAnswer !== null &&
"value" in dobAnswer
? dobAnswer.value
: dobAnswer;
@ -168,7 +170,6 @@ function QuestionFlowWrapper({
};
}, [profile?.gender, answers, computedAge]);
const dynamicQuestions = useMemo(() => {
return questions
.filter((q) => isQuestionVisible(q, answers, userContext))
@ -323,10 +324,12 @@ export default function QuestionDetailClient({
const isAssessment = isCattellSlug || isGlasserSlug;
const [isCompletedSheetOpen, setIsCompletedSheetOpen] = useState(false);
const { data: overview, isLoading: isOverviewLoading, isError: isOverviewError, refetch: refetchOverview } = useFormOverviewQuery(
"profile",
locale,
);
const {
data: overview,
isLoading: isOverviewLoading,
isError: isOverviewError,
refetch: refetchOverview,
} = useFormOverviewQuery("profile", locale);
const isAssessmentCompleted = useMemo(() => {
if (!isAssessment) return false;
@ -387,7 +390,9 @@ export default function QuestionDetailClient({
: (isOverviewError || isSectionError) && !sectionResponse;
const isSchemaLoading = isAssessment
? isOverviewLoading
: !sectionResponse && !isSchemaError && (isOverviewLoading || isSectionLoading || isSectionPending);
: !sectionResponse &&
!isSchemaError &&
(isOverviewLoading || isSectionLoading || isSectionPending);
const cattellQuery = useCattellQuestionsQuery(locale, {
enabled: isCattellSlug && isTestStarted,
@ -445,7 +450,9 @@ export default function QuestionDetailClient({
return {
id: String(opt.id || opt.value || key),
value: opt.value ?? key,
label: String(opt.label || opt.text || opt.title || opt.name || key),
label: String(
opt.label || opt.text || opt.title || opt.name || key,
),
};
});
@ -467,7 +474,8 @@ export default function QuestionDetailClient({
"High",
"Very High",
];
const defaultLabels = locale === "fa" ? DEFAULT_LABELS_FA : DEFAULT_LABELS_EN;
const defaultLabels =
locale === "fa" ? DEFAULT_LABELS_FA : DEFAULT_LABELS_EN;
const tMap = t as Record<string, string | undefined>;
return questionsList
@ -479,21 +487,29 @@ export default function QuestionDetailClient({
? rawOptions.map((opt: any, idx: number) => {
const score = idx + 1;
if (typeof opt === "string") {
return { id: String(score), value: score, label: tMap[opt] || opt };
return {
id: String(score),
value: score,
label: tMap[opt] || opt,
};
}
const defaultLabel = tMap[DEFAULT_LABELS_EN[idx]] || defaultLabels[idx] || String(score);
const defaultLabel =
tMap[DEFAULT_LABELS_EN[idx]] ||
defaultLabels[idx] ||
String(score);
return {
id: String(opt.id || opt.value || score),
value: typeof opt.value === "number" ? opt.value : score,
label: String(
opt.label || opt.text || defaultLabel,
),
label: String(opt.label || opt.text || defaultLabel),
};
})
: [1, 2, 3, 4, 5].map((score, idx) => ({
id: String(score),
value: score,
label: tMap[DEFAULT_LABELS_EN[idx]] || defaultLabels[idx] || String(score),
label:
tMap[DEFAULT_LABELS_EN[idx]] ||
defaultLabels[idx] ||
String(score),
}));
return {
@ -510,8 +526,6 @@ export default function QuestionDetailClient({
});
}, [glasserQuery.data, locale, t]);
useEffect(() => {
if (!isSchemaLoading && !isSchemaError && !item) {
handleExit();
@ -746,7 +760,9 @@ export default function QuestionDetailClient({
questions={activeTestQuestions}
closeLabel={closeLabel}
informationLabel={informationLabel}
previousLabel={t["Previous"] || (locale === "fa" ? "قبلی" : "Previous")}
previousLabel={
t["Previous"] || (locale === "fa" ? "قبلی" : "Previous")
}
nextLabel={t["Next"] || (locale === "fa" ? "بعدی" : "Next")}
finishLabel={t["Finish"] || (locale === "fa" ? "پایان" : "Finish")}
stepsLabel={t["Steps"] || "Steps"}
@ -824,7 +840,8 @@ export default function QuestionDetailClient({
}
startLabel={
isAssessmentCompleted
? (t as Record<string, string>)["Completed"] || (locale === "fa" ? "تکمیل شده" : "Completed")
? (t as Record<string, string>)["Completed"] ||
(locale === "fa" ? "تکمیل شده" : "Completed")
: hasTestProgress
? t["Continue"]
: t["Start"]
@ -838,7 +855,7 @@ export default function QuestionDetailClient({
}}
>
{isCattellSlug ? (
<div className="mt-5 text-left">
<div className="mt-5 text-start">
<ul className="space-y-4">
{[
{
@ -892,7 +909,7 @@ export default function QuestionDetailClient({
</ul>
</div>
) : isGlasserSlug ? (
<div className="mt-5 text-left">
<div className="mt-5 text-start">
<ul className="space-y-4">
{[
{

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

@ -4,10 +4,10 @@ import { useQueryClient } from "@tanstack/react-query";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Ic } from "@/icons";
import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client";
import Button from "@/components/Componentes/button";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import DataErrorState from "@/components/Componentes/data-error-state";
import DevTapInstrumentation from "@/components/Componentes/dev-tap-instrumentation";
import ErrorToast from "@/components/Componentes/error-toast";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import InformationSheet from "@/components/Componentes/information-sheet";
@ -15,28 +15,34 @@ import NavigationButton from "@/components/Componentes/navigation-button";
import { PageBackground } from "@/components/Componentes/page-background";
import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage";
import QuestionCard from "@/components/Componentes/question-card";
import { fetchGeoCountryCode } from "@/components/Componentes/question-phone";
import RequiredStepsCard from "@/components/Componentes/required-steps-card";
import type { MarriageField } from "@/hooks/marriage/types";
import SectionOverlayHost from "@/components/Componentes/section-overlay-host";
import TestCompletedSheet from "@/components/Componentes/test-completed-sheet";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import { getCattellQuestions, useCattellResultQuery } from "@/hooks/marriage/use-cattell";
import { getGlasserQuestions, useGlasserResultQuery } from "@/hooks/marriage/use-glasser";
import type { MarriageField } from "@/hooks/marriage/types";
import {
getCattellQuestions,
useCattellResultQuery,
} from "@/hooks/marriage/use-cattell";
import {
getFormSection,
useFormOverviewQuery,
} from "@/hooks/marriage/use-form-schema";
import {
getGlasserQuestions,
useGlasserResultQuery,
} from "@/hooks/marriage/use-glasser";
import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import {
applyProfilePatchResultToCache,
updateMarriageSectionData,
} from "@/hooks/marriage/use-section-data";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { Ic } from "@/icons";
import { getAssessmentLocalProgress } from "@/lib/assessment-progress";
import {
readScopedAssessmentDraft,
readScopedSectionDraft,
removeScopedSectionDraft,
} from "@/lib/user-scoped-storage";
import {
getSubmitPath,
hasCompletedMarriageProfileBasics,
@ -48,15 +54,15 @@ import {
} from "@/lib/match-start-grace";
import type { QuestionListItem } from "@/lib/schema-adapter";
import { convertOverviewToFrontendItems } from "@/lib/schema-adapter";
import {
readScopedAssessmentDraft,
readScopedSectionDraft,
removeScopedSectionDraft,
} from "@/lib/user-scoped-storage";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch";
import DevTapInstrumentation from "@/components/Componentes/dev-tap-instrumentation";
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 type QuestionsListClientProps = {
onClose?: () => void;
@ -119,12 +125,19 @@ export default function QuestionsListClient({
}
}, [onClose, router, locale]);
const queryClient = useQueryClient();
const { data: profile, isLoading: isProfileLoading, isError: isProfileError, isFetched: isProfileFetched, refetch: refetchProfile } =
useMarriageProfileQuery();
const { data: overview, isLoading: isSchemaLoading, isError: isSchemaError, refetch: refetchOverview } = useFormOverviewQuery(
"profile",
locale,
);
const {
data: profile,
isLoading: isProfileLoading,
isError: isProfileError,
isFetched: isProfileFetched,
refetch: refetchProfile,
} = useMarriageProfileQuery();
const {
data: overview,
isLoading: isSchemaLoading,
isError: isSchemaError,
refetch: refetchOverview,
} = useFormOverviewQuery("profile", locale);
const { data: glasserResult } = useGlasserResultQuery(locale, {
enabled: Boolean(profile?.id),
retry: false,
@ -158,14 +171,31 @@ export default function QuestionsListClient({
if (isProfileFetched && isProfileRedirecting && profileTargetPath) {
router.replace(localizePath(profileTargetPath, locale));
}
}, [isProfileFetched, isProfileRedirecting, locale, profileTargetPath, router]);
}, [
isProfileFetched,
isProfileRedirecting,
locale,
profileTargetPath,
router,
]);
// Notify parent overlay when initial overview data / skeleton is rendered
useEffect(() => {
if ((!isSchemaLoading && !isProfileLoading && isProfileFetched) || isSchemaError || isProfileError) {
if (
(!isSchemaLoading && !isProfileLoading && isProfileFetched) ||
isSchemaError ||
isProfileError
) {
onReady?.();
}
}, [isSchemaLoading, isProfileLoading, isProfileFetched, isSchemaError, isProfileError, onReady]);
}, [
isSchemaLoading,
isProfileLoading,
isProfileFetched,
isSchemaError,
isProfileError,
onReady,
]);
// Background prefetch user's geo country code so phone question is pre-warmed
useEffect(() => {
@ -271,7 +301,9 @@ export default function QuestionsListClient({
// 2. Check local completion flag
if (typeof window !== "undefined") {
const completionKeys = [
profileId ? `marriage:user:${profileId}:sections:${slug}:completed` : null,
profileId
? `marriage:user:${profileId}:sections:${slug}:completed`
: null,
`marriage:sections:${slug}:completed`,
`marriage:sections:${slug}:answers`,
].filter(Boolean) as string[];
@ -299,7 +331,9 @@ export default function QuestionsListClient({
draft = readScopedAssessmentDraft(profileId, slug);
}
if (!draft && typeof window !== "undefined") {
const raw = window.localStorage.getItem(`marriage:tests:${slug}:draft`);
const raw = window.localStorage.getItem(
`marriage:tests:${slug}:draft`,
);
draft = raw ? JSON.parse(raw) : null;
}
const progress = getAssessmentLocalProgress(draft, false);
@ -444,7 +478,8 @@ export default function QuestionsListClient({
const syncPromiseRef = useRef<Promise<void> | null>(null);
const syncPendingAnswers = useCallback(async () => {
if (!overview || !profile?.id || profile?.can_edit_profile === false) return;
if (!overview || !profile?.id || profile?.can_edit_profile === false)
return;
if (syncPromiseRef.current) {
return syncPromiseRef.current;
}
@ -468,7 +503,9 @@ export default function QuestionsListClient({
// 1. Read scoped draft for current profile
const scopedDraft = readScopedSectionDraft(profileId, item.slug);
if (scopedDraft && Object.keys(scopedDraft.pending).length > 0) {
const fields: MarriageField[] = Object.values(scopedDraft.pending).map(
const fields: MarriageField[] = Object.values(
scopedDraft.pending,
).map(
(p) =>
({
key: p.key,
@ -517,7 +554,9 @@ export default function QuestionsListClient({
if (typeof window === "undefined") return;
const preloadDetailModule = () => {
// Lazily preload question-detail-client bundle & its subcomponents into memory
import("@/app/questions-list/[slug]/question-detail-client").catch(() => {});
import("@/app/questions-list/[slug]/question-detail-client").catch(
() => {},
);
};
if ("requestIdleCallback" in window) {
const handle = (window as any).requestIdleCallback(preloadDetailModule, {
@ -606,8 +645,13 @@ export default function QuestionsListClient({
// data right away so navigation + mount is instant.
if (profileSections[0]) {
void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.formSection("profile", profileSections[0].slug, locale),
queryFn: () => getFormSection("profile", profileSections[0].slug, locale),
queryKey: marriageQueryKeys.formSection(
"profile",
profileSections[0].slug,
locale,
),
queryFn: () =>
getFormSection("profile", profileSections[0].slug, locale),
staleTime: 3 * 60 * 1000,
});
}
@ -624,7 +668,11 @@ export default function QuestionsListClient({
remaining,
(item) =>
queryClient.fetchQuery({
queryKey: marriageQueryKeys.formSection("profile", item.slug, locale),
queryKey: marriageQueryKeys.formSection(
"profile",
item.slug,
locale,
),
queryFn: () => getFormSection("profile", item.slug, locale),
staleTime: 3 * 60 * 1000,
}),
@ -632,16 +680,27 @@ export default function QuestionsListClient({
);
};
const idle = typeof requestIdleCallback === "function"
? requestIdleCallback(startRemainingPrefetch, { timeout: 3000 })
: setTimeout(startRemainingPrefetch, 200);
const idle =
typeof requestIdleCallback === "function"
? requestIdleCallback(startRemainingPrefetch, { timeout: 3000 })
: setTimeout(startRemainingPrefetch, 200);
return () => {
cancelled = true;
if (typeof cancelIdleCallback === "function" && typeof idle === "number") {
if (
typeof cancelIdleCallback === "function" &&
typeof idle === "number"
) {
cancelIdleCallback(idle);
}
};
}, [locale, overview, queryClient, questionListItems, router, sectionProgressBySlug]);
}, [
locale,
overview,
queryClient,
questionListItems,
router,
sectionProgressBySlug,
]);
useEffect(() => {
void syncPendingAnswers().catch((err) => {
@ -703,7 +762,8 @@ export default function QuestionsListClient({
setToastMessage(
t[
"Sending the match request failed. Please check your connection and try again."
] ?? "Sending the match request failed. Please check your connection and try again."
] ??
"Sending the match request failed. Please check your connection and try again.",
);
} finally {
setIsSyncing(false);
@ -881,8 +941,8 @@ export default function QuestionsListClient({
<InformationSheet
icon={null}
title={({ close }) => (
<span className="flex w-full items-start justify-between gap-3 text-left">
<span className="group-14 leading-5 font-bold tracking-normal text-[#8B8B8B]">
<span className="flex w-full items-start justify-between gap-3 text-start">
<span className="group-14 leading-5 font-bold tracking-normal text-[#8B8B8B] flex-1 text-start">
{selectedSection.title}
</span>
<button
@ -896,12 +956,12 @@ export default function QuestionsListClient({
</span>
)}
description={
<p className="px-0.5 group-14 leading-[1.45] text-[#2D2D2D]">
<p className="px-0.5 group-14 leading-[1.45] text-[#2D2D2D] text-start">
{selectedSection.summary}
</p>
}
onClose={() => setSelectedSection(null)}
className="text-left"
className="text-start"
/>
) : null}
<SectionsRequest
@ -909,7 +969,9 @@ export default function QuestionsListClient({
isOpen={isTermsSheetOpen}
onClose={() => setIsTermsSheetOpen(false)}
/>
{process.env.NODE_ENV === "development" ? <DevTapInstrumentation /> : null}
{process.env.NODE_ENV === "development" ? (
<DevTapInstrumentation />
) : null}
<PageBackground disabled />
<main
@ -949,7 +1011,10 @@ export default function QuestionsListClient({
<div className="mt-4">
<RequiredStepsCard
completed={displayedRequiredSections}
total={requiredQuestionListItems.length || REQUIRED_PROFILE_SECTION_COUNT}
total={
requiredQuestionListItems.length ||
REQUIRED_PROFILE_SECTION_COUNT
}
/>
</div>

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

@ -1,7 +1,15 @@
"use client";
import type { HTMLAttributes } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { localeDirections } from "@/translations/config";
import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 200;
@ -24,7 +32,8 @@ export function CallResultSheet({
className,
...props
}: CallResultSheetProps) {
const { dictionary: t } = useI18n();
const { dictionary: t, locale } = useI18n();
const direction = localeDirections[locale] ?? "ltr";
const callResultOptions = useMemo(
() => [
t["Not a good personal fit"],
@ -97,6 +106,7 @@ export function CallResultSheet({
role="dialog"
aria-modal="true"
aria-label={t["Call result"]}
dir={direction}
tabIndex={-1}
onClick={(event) => {
if (closeOnOutside && event.target === event.currentTarget) {
@ -117,15 +127,15 @@ export function CallResultSheet({
<section
{...props}
className={[
"w-full max-w-[834px] sm:max-w-[540px] rounded-t-[22px] bg-white p-3.5 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)]",
"w-full max-w-[834px] sm:max-w-[540px] rounded-t-[22px] bg-white p-3.5 text-start shadow-[0_20px_60px_rgba(15,23,42,0.08)]",
isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
className,
]
.filter(Boolean)
.join(" ")}
>
<div className="mx-auto flex flex-col items-center">
<h2 className="text-[18px] leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]">
<div className="mx-auto flex flex-col">
<h2 className="w-full text-start text-[18px] leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]">
{t["Call result"]}
</h2>
@ -145,7 +155,7 @@ export function CallResultSheet({
return (
<label
key={option}
className="flex cursor-pointer items-center gap-3 rounded-[14px] bg-[#ECECEC] px-[14px] py-[18px] text-left"
className="flex cursor-pointer items-center gap-3 rounded-[14px] bg-[#ECECEC] px-[14px] py-[18px] text-start"
>
<input
checked={checked}
@ -178,7 +188,7 @@ export function CallResultSheet({
<span className="h-3 w-3 rounded-full bg-[#F0445B]" />
) : null}
</span>
<span className="text-[16px] leading-none font-semibold text-[#262626]">
<span className="text-[16px] leading-snug font-semibold text-[#262626] flex-1 text-start">
{option}
</span>
</label>
@ -190,7 +200,7 @@ export function CallResultSheet({
<div className="mt-8 grid w-full grid-cols-[33fr_67fr] gap-3">
<button
type="button"
className="appearance-none border-0 bg-transparent p-0 text-left min-w-0"
className="appearance-none border-0 bg-transparent p-0 min-w-0"
onClick={closeSheet}
>
<div className="inline-flex w-full items-center justify-center rounded-[14px] border border-[#9A9A9A] bg-[#F7F7F7] px-2 h-[52px] text-[16px] font-bold text-[#8B8B8B] shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] transition-opacity active:opacity-90 min-w-0">
@ -200,7 +210,7 @@ export function CallResultSheet({
<button
type="button"
className="appearance-none border-0 bg-transparent p-0 text-left min-w-0"
className="appearance-none border-0 bg-transparent p-0 min-w-0"
onClick={() => {
onSubmit?.(selectedReason);
closeSheet();

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

@ -1,8 +1,16 @@
"use client";
import type { HTMLAttributes } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { localeDirections } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import Button from "./button";
@ -24,7 +32,8 @@ export function DismissReasonSheet({
className,
...props
}: DismissReasonSheetProps) {
const { dictionary: t } = useI18n();
const { dictionary: t, locale } = useI18n();
const direction = localeDirections[locale] ?? "ltr";
const options = useMemo(
() => [
t["Not a good personal fit"],
@ -112,6 +121,7 @@ export function DismissReasonSheet({
role="dialog"
aria-modal="true"
aria-label={t["Dismiss reasons"]}
dir={direction}
tabIndex={-1}
onClick={(event) => {
if (closeOnOutside && event.target === event.currentTarget) {
@ -132,20 +142,20 @@ export function DismissReasonSheet({
<section
{...props}
className={[
"flex h-[80vh] w-full max-w-[834px] sm:max-w-[540px] flex-col overflow-hidden rounded-t-[22px] bg-white p-3.5 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)]",
"flex h-[80vh] w-full max-w-[834px] sm:max-w-[540px] flex-col overflow-hidden rounded-t-[22px] bg-white p-3.5 text-start shadow-[0_20px_60px_rgba(15,23,42,0.08)]",
isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
className,
]
.filter(Boolean)
.join(" ")}
>
<div className="mx-auto flex h-full min-h-0 w-full flex-col items-center">
<div className="flex min-h-0 w-full flex-1 flex-col items-center">
<h2 className="text-[18px] leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]">
<div className="mx-auto flex h-full min-h-0 w-full flex-col">
<div className="flex min-h-0 w-full flex-1 flex-col">
<h2 className="w-full text-start text-[18px] leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]">
{t["Dismiss reasons"]}
</h2>
<p className="mt-3.5 w-full text-start text-[14px] leading-[1.45] text-[#2C2C2C] dir-auto">
<p className="mt-3.5 w-full text-start text-[14px] leading-[1.45] text-[#2C2C2C]">
{t[
"Please provide the full reason for declining the submitted item"
] ||
@ -162,7 +172,7 @@ export function DismissReasonSheet({
{t["Dismiss reasons"]}
</legend>
<div className="flex min-h-0 flex-1 flex-col gap-[14px] overflow-y-auto pr-1">
<div className="flex min-h-0 flex-1 flex-col gap-[14px] overflow-y-auto pe-1">
{options.map((option) => {
const checked = selectedReasons.includes(option);
const showTextArea = option === options[options.length - 1];
@ -214,8 +224,8 @@ export function DismissReasonSheet({
) : null}
</span>
<span className="min-w-0 flex-1">
<span className="text-[16px] leading-[1.2] font-semibold">
<span className="min-w-0 flex-1 text-start">
<span className="text-[16px] leading-[1.2] font-semibold text-start">
{option}
</span>
</span>
@ -224,7 +234,7 @@ export function DismissReasonSheet({
{checked && showTextArea ? (
<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]"
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] text-start"
placeholder={
t["Feel free to briefly explain your decision..."]
}

11
src/components/Componentes/female-consent-sheet.tsx

@ -4,6 +4,7 @@ import Image from "next/image";
import type { HTMLAttributes, ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { localeDirections } from "@/translations/config";
import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 200;
@ -28,7 +29,8 @@ export function FemaleConsentSheet({
className,
...props
}: FemaleConsentSheetProps) {
const { dictionary: t } = useI18n();
const { dictionary: t, locale } = useI18n();
const direction = localeDirections[locale] ?? "ltr";
const [isVisible, setIsVisible] = useState(true);
const [isClosing, setIsClosing] = useState(false);
const isMountedRef = useRef(true);
@ -89,6 +91,7 @@ export function FemaleConsentSheet({
role="dialog"
aria-modal="true"
aria-label={t["Information sheet"]}
dir={direction}
tabIndex={-1}
onClick={(event) => {
if (closeOnOutside && event.target === event.currentTarget) {
@ -109,7 +112,7 @@ export function FemaleConsentSheet({
<section
{...props}
className={[
"w-full max-w-[834px] sm:max-w-[540px] rounded-t-[22px] bg-white py-5 px-4 text-left shadow-[0_20px_60px_rgba(15,23,42,0.08)]",
"w-full max-w-[834px] sm:max-w-[540px] rounded-t-[22px] bg-white py-5 px-4 text-start shadow-[0_20px_60px_rgba(15,23,42,0.08)]",
isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
className,
]
@ -117,7 +120,7 @@ export function FemaleConsentSheet({
.join(" ")}
>
<div className="flex items-center justify-between gap-4">
<h2 className="text-sm font-bold leading-[1.2] tracking-[-0.03em] text-[#8B8B8B]">
<h2 className="text-sm font-bold leading-[1.2] tracking-[-0.03em] text-[#8B8B8B] text-start">
{title}
</h2>
<button
@ -136,7 +139,7 @@ export function FemaleConsentSheet({
</div>
{description ? (
<div className="mt-5 text-sm leading-[1.45] text-[#2B2B2B]">
<div className="mt-5 text-sm leading-[1.45] text-[#2B2B2B] text-start">
{description}
</div>
) : null}

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

@ -81,4 +81,49 @@ describe("FemaleOutcomeSheet", () => {
);
expect(confirmation).toHaveProperty("disabled", false);
});
it("applies rtl direction when locale is Arabic (ar)", () => {
render(
<I18nProvider locale="ar">
<FemaleOutcomeSheet />
</I18nProvider>,
);
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveAttribute("dir", "rtl");
});
it("adapts dynamically from compact static to flutter-draggable-sheet when option 2 is chosen, and collapses back", async () => {
renderSheet();
const dialog = screen.getByRole("dialog");
const section = dialog.querySelector("section");
expect(section).not.toBeNull();
// Initially in ongoing (Option A): compact static mode
expect(section?.className).toContain("h-auto");
expect(section?.className).not.toContain("flutter-draggable-sheet");
// Click Option B (Canceled / Decline): switches to full expandable draggable mode
await userEvent.click(
screen.getByLabelText(
/We did not reach the agreement needed to continue acquaintance/i,
),
);
expect(section?.className).toContain("flutter-draggable-sheet");
expect(section?.className).not.toContain("h-auto");
expect(screen.getByText("Please select the reason for cancellation:")).toBeInTheDocument();
// Click Option A (Ongoing) again: reverts to compact mode
await userEvent.click(
screen.getByLabelText(
/We are in the acquaintance\/proposal process/i,
),
);
expect(section?.className).toContain("h-auto");
expect(section?.className).not.toContain("flutter-draggable-sheet");
expect(screen.queryByText("Please select the reason for cancellation:")).not.toBeInTheDocument();
});
});

391
src/components/Componentes/female-outcome-sheet.tsx

@ -1,11 +1,26 @@
"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { localeDirections } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import { useSheetScrollLock } from "./use-sheet-scroll-lock";
const EXIT_ANIMATION_MS = 200;
// Parity with Flutter DraggableScrollableSheet:
// initialChildSize: 0.75, minChildSize: 0.6, maxChildSize: 1.0, shouldCloseOnMinExtent: true
const SHEET_INITIAL_SIZE = 0.75;
const SHEET_MIN_SIZE = 0.6;
const SHEET_MAX_SIZE = 1;
const GESTURE_ENGAGE_PX = 10;
export type FemaleOutcomeSheetProps = {
closeOnOutside?: boolean;
onClose?: () => void;
@ -17,13 +32,25 @@ export function FemaleOutcomeSheet({
onClose,
onSubmit,
}: FemaleOutcomeSheetProps) {
const { dictionary: t } = useI18n();
const { dictionary: t, locale } = useI18n();
const direction = localeDirections[locale] ?? "ltr";
const groupId = useId();
const [isVisible, setIsVisible] = useState(true);
const [isClosing, setIsClosing] = useState(false);
const isClosingRef = useRef(false);
const isMountedRef = useRef(true);
// Outcome selection state: "ongoing" (Option A) or "canceled" (Option B)
const [outcome, setOutcome] = useState<"ongoing" | "canceled">("ongoing");
const isExpanded = outcome === "canceled";
const sheetRef = useRef<HTMLElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const sheetSizeRef = useRef(SHEET_INITIAL_SIZE);
const restingStateRef = useRef<number>(SHEET_INITIAL_SIZE);
useEffect(() => {
isMountedRef.current = true;
return () => {
@ -31,9 +58,6 @@ export function FemaleOutcomeSheet({
};
}, []);
// Outcome selection state: "ongoing" (Option A) or "canceled" (Option B)
const [outcome, setOutcome] = useState<"ongoing" | "canceled">("ongoing");
// Rejection/Cancellation reason states
const reasons = useMemo(
() => [
@ -64,36 +88,302 @@ export function FemaleOutcomeSheet({
}, [selectedReasons, reasons]);
const closeSheet = useCallback(() => {
if (isClosing) {
if (isClosingRef.current) {
return;
}
isClosingRef.current = true;
setIsClosing(true);
window.setTimeout(() => {
if (isMountedRef.current) {
setIsVisible(false);
}
isClosingRef.current = false;
onClose?.();
}, EXIT_ANIMATION_MS);
}, [isClosing, onClose]);
}, [onClose]);
useHardwareBackHandler(closeSheet, isVisible && !isClosing);
useSheetScrollLock(isVisible && !isClosing, { onBack: closeSheet });
// Handle escape key
useEffect(() => {
if (!isVisible) {
return;
if (!isVisible) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeSheet();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isVisible, closeSheet]);
// Adaptive height transition when switching between ongoing and canceled states
useEffect(() => {
if (!sheetRef.current) return;
if (isExpanded) {
sheetSizeRef.current = SHEET_INITIAL_SIZE;
restingStateRef.current = SHEET_INITIAL_SIZE;
sheetRef.current.style.transition =
"height 260ms cubic-bezier(0.16, 1, 0.3, 1)";
sheetRef.current.style.setProperty(
"--sheet-size",
SHEET_INITIAL_SIZE.toFixed(4),
);
const timer = window.setTimeout(() => {
if (sheetRef.current) {
sheetRef.current.style.transition = "";
}
}, 270);
return () => clearTimeout(timer);
} else {
sheetRef.current.style.transition =
"height 260ms cubic-bezier(0.16, 1, 0.3, 1)";
sheetRef.current.style.removeProperty("--sheet-size");
const timer = window.setTimeout(() => {
if (sheetRef.current) {
sheetRef.current.style.transition = "";
}
}, 270);
return () => clearTimeout(timer);
}
}, [isExpanded]);
// Drag-to-resize gesture handlers matching Flutter DraggableScrollableSheet
// Active only when isExpanded is true (option 2 chosen)
useEffect(() => {
if (!isVisible || isClosing || !isExpanded) return;
const content = contentRef.current;
const sheet = sheetRef.current;
const header = headerRef.current;
if (!sheet) return;
let active = false;
let engaged = false;
let closing = false;
let isHeaderDrag = false;
let startX = 0;
let startY = 0;
let lastY = 0;
let snapTimeout: ReturnType<typeof setTimeout> | null = null;
let wheelSnapTimeout: ReturnType<typeof setTimeout> | null = null;
const contentOverflows = () =>
content ? content.scrollHeight > content.clientHeight + 1 : false;
const snapTo = (targetSize: number) => {
sheetSizeRef.current = targetSize;
restingStateRef.current = targetSize;
sheet.style.transition = "height 260ms cubic-bezier(0.16, 1, 0.3, 1)";
sheet.style.setProperty("--sheet-size", targetSize.toFixed(4));
if (snapTimeout) clearTimeout(snapTimeout);
snapTimeout = setTimeout(() => {
if (sheet) {
sheet.style.transition = "";
}
}, 270);
};
const resize = (deltaPx: number) => {
const viewportHeight = window.innerHeight || 1;
const nextSize = Math.min(
SHEET_MAX_SIZE,
Math.max(
SHEET_MIN_SIZE,
sheetSizeRef.current + deltaPx / viewportHeight,
),
);
sheetSizeRef.current = nextSize;
sheet.style.setProperty("--sheet-size", nextSize.toFixed(4));
};
const handleTouchStart = (event: TouchEvent, fromHeader: boolean) => {
if (event.touches.length !== 1) {
active = false;
return;
}
if (snapTimeout) {
clearTimeout(snapTimeout);
snapTimeout = null;
}
sheet.style.transition = "none";
active = true;
engaged = fromHeader;
isHeaderDrag = fromHeader;
startX = event.touches[0].clientX;
startY = event.touches[0].clientY;
lastY = startY;
};
const handleTouchMove = (event: TouchEvent) => {
if (!active || closing || event.touches.length !== 1) return;
const touch = event.touches[0];
const deltaY = lastY - touch.clientY; // finger up = grow sheet
const pulled = startY - touch.clientY; // cumulative distance
lastY = touch.clientY;
if (!engaged) {
if (Math.abs(pulled) < GESTURE_ENGAGE_PX) return;
if (Math.abs(touch.clientX - startX) >= Math.abs(pulled)) return;
const atTop = !content || content.scrollTop <= 0;
const canGrow =
pulled > 0 &&
atTop &&
contentOverflows() &&
sheetSizeRef.current < SHEET_MAX_SIZE - 0.001;
const canShrink = pulled < 0 && atTop;
if (!canGrow && !canShrink) return;
engaged = true;
}
const atTop = !content || content.scrollTop <= 0;
const size = sheetSizeRef.current;
const consume = isHeaderDrag
? true
: deltaY > 0
? atTop && size < SHEET_MAX_SIZE - 0.001
: atTop;
if (!consume) return; // at ceiling or scrolling inside content
if (event.cancelable) {
event.preventDefault();
}
if (deltaY > 0) {
resize(deltaY);
} else if (size <= SHEET_MIN_SIZE + 0.001) {
closing = true; // shouldCloseOnMinExtent: true
closeSheet();
} else {
resize(deltaY);
}
};
const handleTouchEnd = () => {
if (!active || closing) {
active = false;
engaged = false;
isHeaderDrag = false;
return;
}
active = false;
engaged = false;
isHeaderDrag = false;
const currentSize = sheetSizeRef.current;
const baseline = restingStateRef.current;
const totalPulled = startY - lastY; // > 0 finger pulled up, < 0 finger pulled down
// Flutter shouldCloseOnMinExtent: true when dragged near or below 60% floor
if (currentSize <= SHEET_MIN_SIZE + 0.01) {
closing = true;
closeSheet();
return;
}
// Snapping between only two states: 0.75 (default) and 1.0 (expanded)
if (baseline === SHEET_MAX_SIZE) {
// Dragging down from 1.0: if pulled down >= 30px or size <= 0.94, snap to 0.75
if (totalPulled <= -30 || currentSize <= 0.94) {
snapTo(SHEET_INITIAL_SIZE);
} else {
snapTo(SHEET_MAX_SIZE);
}
} else {
// Dragging from 0.75:
// If pulled down strongly, close
if (totalPulled <= -60) {
closing = true;
closeSheet();
} else if (totalPulled >= 30 || currentSize >= 0.78) {
// Dragged up enough to expand to 1.0
snapTo(SHEET_MAX_SIZE);
} else {
snapTo(SHEET_INITIAL_SIZE);
}
}
};
const handleWheel = (event: WheelEvent) => {
if (closing) return;
const delta = event.deltaY;
const atTop = !content || content.scrollTop <= 0;
const size = sheetSizeRef.current;
const consume =
delta > 0
? atTop && contentOverflows() && size < SHEET_MAX_SIZE - 0.001
: atTop;
if (!consume) return;
if (event.cancelable) {
event.preventDefault();
}
if (delta > 0) {
resize(delta);
} else if (size <= SHEET_MIN_SIZE + 0.001) {
closing = true;
closeSheet();
} else {
resize(delta);
}
const previousBodyOverflow = document.body.style.overflow;
const previousHtmlOverflow = document.documentElement.style.overflow;
if (wheelSnapTimeout) clearTimeout(wheelSnapTimeout);
wheelSnapTimeout = setTimeout(() => {
if (closing) return;
const current = sheetSizeRef.current;
if (current <= SHEET_MIN_SIZE + 0.01) {
closing = true;
closeSheet();
} else if (current >= 0.82) {
snapTo(SHEET_MAX_SIZE);
} else {
snapTo(SHEET_INITIAL_SIZE);
}
}, 160);
};
document.body.style.overflow = "hidden";
document.documentElement.style.overflow = "hidden";
const onHeaderTouchStart = (e: TouchEvent) => handleTouchStart(e, true);
const onContentTouchStart = (e: TouchEvent) => handleTouchStart(e, false);
if (header) {
header.addEventListener("touchstart", onHeaderTouchStart, {
passive: true,
});
}
if (content) {
content.addEventListener("touchstart", onContentTouchStart, {
passive: true,
});
content.addEventListener("touchmove", handleTouchMove, {
passive: false,
});
content.addEventListener("touchend", handleTouchEnd, { passive: true });
content.addEventListener("touchcancel", handleTouchEnd, { passive: true });
content.addEventListener("wheel", handleWheel, { passive: false });
}
sheet.addEventListener("touchmove", handleTouchMove, { passive: false });
sheet.addEventListener("touchend", handleTouchEnd, { passive: true });
sheet.addEventListener("touchcancel", handleTouchEnd, { passive: true });
return () => {
document.body.style.overflow = previousBodyOverflow;
document.documentElement.style.overflow = previousHtmlOverflow;
if (snapTimeout) clearTimeout(snapTimeout);
if (wheelSnapTimeout) clearTimeout(wheelSnapTimeout);
if (header) {
header.removeEventListener("touchstart", onHeaderTouchStart);
}
if (content) {
content.removeEventListener("touchstart", onContentTouchStart);
content.removeEventListener("touchmove", handleTouchMove);
content.removeEventListener("touchend", handleTouchEnd);
content.removeEventListener("touchcancel", handleTouchEnd);
content.removeEventListener("wheel", handleWheel);
}
sheet.removeEventListener("touchmove", handleTouchMove);
sheet.removeEventListener("touchend", handleTouchEnd);
sheet.removeEventListener("touchcancel", handleTouchEnd);
};
}, [isVisible]);
}, [isVisible, isClosing, isExpanded, closeSheet]);
if (!isVisible) {
return null;
@ -115,6 +405,7 @@ export function FemaleOutcomeSheet({
role="dialog"
aria-modal="true"
aria-label={t["What was the outcome of your contact?"]}
dir={direction}
tabIndex={-1}
onClick={(event) => {
if (closeOnOutside && event.target === event.currentTarget) {
@ -133,20 +424,39 @@ export function FemaleOutcomeSheet({
}}
>
<section
ref={sheetRef}
style={{
height: isExpanded
? "calc(var(--sheet-size, 0.75) * (100svh - max(var(--safe-top, 0px), env(safe-area-inset-top, 0px))))"
: undefined,
maxHeight:
"calc(100svh - max(var(--safe-top, 0px), env(safe-area-inset-top, 0px)))",
}}
className={[
"flex max-h-[85vh] w-full max-w-[834px] sm:max-w-[540px] flex-col overflow-hidden rounded-t-[22px] bg-white p-4 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)]",
"flex w-full max-w-[834px] sm:max-w-[540px] flex-col overflow-hidden rounded-t-[22px] bg-white p-4 pt-2.5 text-start shadow-[0_20px_60px_rgba(15,23,42,0.08)]",
isExpanded ? "flutter-draggable-sheet" : "h-auto max-h-[82svh]",
isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
]
.filter(Boolean)
.join(" ")}
>
<div className="mx-auto flex h-full min-h-0 w-full flex-col items-center">
<div className="flex min-h-0 w-full flex-1 flex-col items-center">
<h2 className="text-[18px] leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]">
{/* Drag handle */}
<div
ref={headerRef}
className="flex flex-col select-none touch-none shrink-0"
>
<div className="flex justify-center pt-0 pb-2.5 cursor-grab active:cursor-grabbing">
<div className="h-1.2 w-10 rounded-full bg-[#D0D5DD]" />
</div>
</div>
<div className="mx-auto flex h-full min-h-0 w-full flex-col">
<div className="flex min-h-0 w-full flex-1 flex-col">
<h2 className="w-full text-start text-[18px] leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]">
{t["What was the outcome of your contact?"]}
</h2>
<p className="mt-3.5 w-full text-start text-[14px] leading-[1.45] text-[#2C2C2C] dir-auto font-medium">
<p className="mt-3.5 w-full text-start text-[14px] leading-[1.45] text-[#2C2C2C] font-medium">
{
t[
"We hope the acquaintance process is going well. Please let us know if you want to continue the acquaintance process or if the match has been canceled."
@ -162,11 +472,14 @@ export function FemaleOutcomeSheet({
{t["What was the outcome of your contact?"]}
</legend>
<div className="flex min-h-0 flex-1 flex-col gap-[14px] overflow-y-auto pr-1">
<div
ref={contentRef}
className="flex min-h-0 flex-1 flex-col gap-[14px] overflow-y-auto pe-1"
>
{/* Option A: Ongoing acquaintance */}
<label
className={[
"flex cursor-pointer items-center gap-3 rounded-[14px] px-[14px] py-[18px] text-left border transition-all",
"flex cursor-pointer items-center gap-3 rounded-[14px] px-[14px] py-[18px] text-start border transition-all",
outcome === "ongoing"
? "bg-[#FFECEF] border-[#F0445B]/30"
: "bg-[#ECECEC] border-transparent",
@ -195,7 +508,7 @@ export function FemaleOutcomeSheet({
<span className="h-3 w-3 rounded-full bg-[#F0445B]" />
) : null}
</span>
<span className="text-[15px] leading-snug font-bold text-[#262626] flex-1">
<span className="text-[15px] leading-snug font-bold text-[#262626] flex-1 text-start">
{
t[
"We are in the acquaintance/proposal process and nothing is finalized yet"
@ -207,7 +520,7 @@ export function FemaleOutcomeSheet({
{/* Option B: Canceled / Decline */}
<label
className={[
"flex cursor-pointer items-center gap-3 rounded-[14px] px-[14px] py-[18px] text-left border transition-all",
"flex cursor-pointer items-center gap-3 rounded-[14px] px-[14px] py-[18px] text-start border transition-all",
outcome === "canceled"
? "bg-[#FFECEF] border-[#F0445B]/30"
: "bg-[#ECECEC] border-transparent",
@ -242,20 +555,18 @@ export function FemaleOutcomeSheet({
<span className="h-3 w-3 rounded-full bg-[#F0445B]" />
) : null}
</span>
<span className="text-[15px] leading-snug font-bold text-[#262626] flex-1">
{
t[
"We did not reach the agreement needed to continue acquaintance and decided not to proceed"
] ||
<span className="text-[15px] leading-snug font-bold text-[#262626] flex-1 text-start">
{t[
"We did not reach the agreement needed to continue acquaintance and decided not to proceed"
}
] ||
"We did not reach the agreement needed to continue acquaintance and decided not to proceed"}
</span>
</label>
{/* Reasons List (if Canceled is chosen) */}
{outcome === "canceled" ? (
<div className="mt-3 flex flex-col gap-3 pl-2 text-start animate-fadeIn">
<p className="text-[13px] font-bold text-[#555] mb-1">
<div className="mt-3 flex flex-col gap-3 ps-2 text-start animate-fadeIn">
<p className="text-[13px] font-bold text-[#555] mb-1 text-start">
{t["Please select the reason for cancellation:"]}
</p>
<div className="flex flex-col gap-[10px]">
@ -317,8 +628,8 @@ export function FemaleOutcomeSheet({
) : null}
</span>
<span className="min-w-0 flex-1">
<span className="text-[14px] leading-none font-bold">
<span className="min-w-0 flex-1 text-start">
<span className="text-[14px] leading-none font-bold text-start">
{option}
</span>
</span>
@ -327,7 +638,7 @@ export function FemaleOutcomeSheet({
{isReasonChecked && isOtherReason ? (
<textarea
ref={textareaRef}
className="mt-1 h-[90px] w-full resize-none rounded-[12px] border border-[#BFBFBF] bg-transparent px-3 py-2 text-[14px] text-[#171717] outline-none placeholder:text-[#AAAAAA]"
className="mt-1 h-[90px] w-full resize-none rounded-[12px] border border-[#BFBFBF] bg-transparent px-3 py-2 text-[14px] text-[#171717] outline-none placeholder:text-[#AAAAAA] text-start"
placeholder={
t[
"Feel free to briefly explain your decision..."
@ -377,7 +688,9 @@ export function FemaleOutcomeSheet({
onClick={() => {
const activeReasons = selectedReasons.map((r) => {
if (r === reasons[reasons.length - 1]) {
return reasonText.trim() ? `${r}: ${reasonText.trim()}` : r;
return reasonText.trim()
? `${r}: ${reasonText.trim()}`
: r;
}
return r;
});

21
src/components/Componentes/outcome-selection-sheet.tsx

@ -1,8 +1,9 @@
"use client";
import type { HTMLAttributes } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { localeDirections } from "@/translations/config";
import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 200;
@ -23,7 +24,8 @@ export function OutcomeSelectionSheet({
className,
...props
}: OutcomeSelectionSheetProps) {
const { dictionary: t } = useI18n();
const { dictionary: t, locale } = useI18n();
const direction = localeDirections[locale] ?? "ltr";
const groupId = useId();
const [isVisible, setIsVisible] = useState(true);
const [isClosing, setIsClosing] = useState(false);
@ -91,6 +93,7 @@ export function OutcomeSelectionSheet({
role="dialog"
aria-modal="true"
aria-label={t["What was the outcome of your contact?"]}
dir={direction}
tabIndex={-1}
onClick={(event) => {
if (closeOnOutside && event.target === event.currentTarget) {
@ -111,15 +114,15 @@ export function OutcomeSelectionSheet({
<section
{...props}
className={[
"w-full max-w-[834px] sm:max-w-[540px] rounded-t-[22px] bg-white p-3.5 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)]",
"w-full max-w-[834px] sm:max-w-[540px] rounded-t-[22px] bg-white p-3.5 text-start shadow-[0_20px_60px_rgba(15,23,42,0.08)]",
isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
className,
]
.filter(Boolean)
.join(" ")}
>
<div className="mx-auto flex flex-col items-center">
<h2 className="text-[18px] leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]">
<div className="mx-auto flex flex-col">
<h2 className="w-full text-start text-[18px] leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]">
{t["What was the outcome of your contact?"]}
</h2>
@ -139,7 +142,7 @@ export function OutcomeSelectionSheet({
return (
<label
key={option.value}
className="flex cursor-pointer items-center gap-3 rounded-[14px] bg-[#ECECEC] px-[14px] py-[18px] text-left"
className="flex cursor-pointer items-center gap-3 rounded-[14px] bg-[#ECECEC] px-[14px] py-[18px] text-start"
>
<input
checked={checked}
@ -164,7 +167,7 @@ export function OutcomeSelectionSheet({
<span className="h-3 w-3 rounded-full bg-[#F0445B]" />
) : null}
</span>
<span className="text-[16px] leading-none font-semibold text-[#262626]">
<span className="text-[16px] leading-snug font-semibold text-[#262626] flex-1 text-start">
{option.label}
</span>
</label>
@ -176,7 +179,7 @@ export function OutcomeSelectionSheet({
<div className="mt-8 grid w-full grid-cols-[33fr_67fr] gap-3">
<button
type="button"
className="appearance-none border-0 bg-transparent p-0 text-left min-w-0"
className="appearance-none border-0 bg-transparent p-0 min-w-0"
onClick={closeSheet}
>
<div className="inline-flex w-full items-center justify-center rounded-[14px] border border-[#9A9A9A] bg-[#F7F7F7] px-2 h-[52px] text-[16px] font-bold text-[#8B8B8B] shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] transition-opacity active:opacity-90 min-w-0">
@ -186,7 +189,7 @@ export function OutcomeSelectionSheet({
<button
type="button"
className="appearance-none border-0 bg-transparent p-0 text-left min-w-0"
className="appearance-none border-0 bg-transparent p-0 min-w-0"
onClick={() => {
onSubmit?.(selectedStatus);
closeSheet();

14
src/components/Componentes/slider-slide-four.tsx

@ -26,7 +26,11 @@ export function SliderSlideFour({
className="flex h-full min-h-0 w-full shrink-0 flex-col"
>
<div className="min-h-0 flex-1 overflow-y-auto pb-28">
<SliderHeader index={index} totalSteps={totalSteps} title="Registration Type" />
<SliderHeader
index={index}
totalSteps={totalSteps}
title="Registration Type"
/>
<NoticeBox className="mt-8" align="justify">
You can register both for yourself and for your loved ones, but
obtaining the explicit consent and permission of the person you are
@ -46,10 +50,10 @@ export function SliderSlideFour({
type="button"
aria-pressed={isSelected}
className={[
"flex w-full items-center gap-3 rounded-[18px] px-4 py-5 text-left transition-colors",
"flex w-full items-center gap-3 rounded-[18px] px-4 py-5 text-start transition-colors",
isSelected
? "bg-[#F4435C] text-white"
: "bg-[#ECE9E9] text-[#2A2A2A]",
: "bg-[#ECECEC] text-[#2A2A2A]",
].join(" ")}
onClick={() => onRegistrationChange(option.id)}
>
@ -78,7 +82,9 @@ export function SliderSlideFour({
/>
</svg>
</span>
<span className="group-14 font-semibold">{option.label}</span>
<span className="group-14 font-semibold text-start flex-1">
{option.label}
</span>
</button>
);
})}

17
src/components/Componentes/subscription-required-sheet.tsx

@ -1,13 +1,10 @@
import { useState } from "react";
import Image from "next/image";
import { useState } from "react";
import { useHabcoinInventoryQuery } from "@/hooks/marriage/use-habcoin-inventory";
import { DiscountWidget } from "./discount-widget";
import type { CheckDiscountResult } from "@/hooks/marriage/use-validate-discount";
import {
buyHabibCoinPackages,
isInFlutterWebView,
} from "@/lib/webview-actions";
import { buyHabibCoinPackages } from "@/lib/webview-actions";
import { useI18n } from "@/translations/provider";
import { DiscountWidget } from "./discount-widget";
import InformationSheet from "./information-sheet";
import { LoadingThreeDot } from "./loading-three-dot";
@ -67,7 +64,7 @@ export function SubscriptionRequiredSheet({
"Please add more Habib Coins to your balance (or use discount code), so that you can use them to access this content"}
</p>
) : (
<div className="space-y-4 text-left">
<div className="space-y-4 text-start">
<p className="text-sm text-center leading-[1.45] font-medium text-[#2B2B2B]">
{t[
"To view profiles, you need a subscription. Each subscription includes up to {count} matches."
@ -112,7 +109,7 @@ export function SubscriptionRequiredSheet({
<div className="grid w-full grid-cols-[33fr_67fr] gap-3">
<button
type="button"
className="appearance-none border-0 bg-transparent p-0 text-left min-w-0"
className="appearance-none border-0 bg-transparent p-0 min-w-0"
onClick={close}
>
<div className="inline-flex w-full items-center justify-center rounded-[11px] border border-[#747474] bg-transparent px-2 h-[52px] text-[16px] font-semibold text-[#747474] transition-opacity active:opacity-90 min-w-0">
@ -123,7 +120,7 @@ export function SubscriptionRequiredSheet({
{!hasEnoughCoins ? (
<button
type="button"
className="appearance-none border-0 bg-transparent p-0 text-left min-w-0"
className="appearance-none border-0 bg-transparent p-0 min-w-0"
onClick={() => buyHabibCoinPackages()}
>
<div className="inline-flex w-full items-center justify-center gap-2 rounded-[11px] 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">
@ -136,7 +133,7 @@ export function SubscriptionRequiredSheet({
<button
type="button"
disabled={isPaymentPending}
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
className="appearance-none border-0 bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
onClick={() =>
onPayment(
appliedDiscount?.valid ? appliedDiscount.code : undefined,

209
src/components/Componentes/token-switcher.tsx

@ -4,6 +4,14 @@ import { useEffect, useState } from "react";
import { Ic } from "@/icons";
import { getClientCookie, setClientCookie } from "@/lib/cookies";
import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache";
import {
isLocale,
LANGUAGE_MAP,
type LanguageCode,
localeDirections,
locales,
localizePath,
} from "@/translations/config";
// ─────────────────────────────────────────────────────────────────────────────
// ۱. توکن‌های ثابت سیستم (System Preset Tokens)
@ -17,9 +25,51 @@ export const MALE_EMAIL = "[email protected]";
export const MALE_2_EMAIL = "[email protected]";
const TOKEN_COOKIE_NAME = "HABIB_TOKEN";
const LANGUAGE_COOKIE_NAME = "HABIB_LANGUAGE";
const DEV_CUSTOM_TOKENS_STORAGE_KEY = "HABIB_DEV_CUSTOM_TOKENS_V1";
const DEV_ACTIVE_NAME_STORAGE_KEY = "HABIB_DEV_ACTIVE_NAME_V1";
const DEV_ACTIVE_EMOJI_STORAGE_KEY = "HABIB_DEV_ACTIVE_EMOJI_V1";
const DEV_LOCALE_STORAGE_KEY = "HABIB_DEV_LOCALE_V1";
const LANGUAGE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
export const LOCALE_FLAGS: Record<string, string> = {
fa: "🇮🇷",
en: "🇺🇸",
ar: "🇸🇦",
tr: "🇹🇷",
ur: "🇵🇰",
az: "🇦🇿",
bn: "🇧🇩",
zh: "🇨🇳",
da: "🇩🇰",
de: "🇩🇪",
es: "🇪🇸",
fr: "🇫🇷",
gu: "🇮🇳",
ha: "🇳🇬",
he: "🇮🇱",
hi: "🇮🇳",
id: "🇮🇩",
ks: "🇮🇳",
pt: "🇵🇹",
ru: "🇷🇺",
sw: "🇰🇪",
tg: "🇹🇯",
ul: "🇵🇰",
uz: "🇺🇿",
};
export const POPULAR_DEV_LOCALES: {
code: LanguageCode;
label: string;
flag: string;
}[] = [
{ code: "fa", label: "فارسی", flag: "🇮🇷" },
{ code: "en", label: "English", flag: "🇺🇸" },
{ code: "ar", label: "العربية", flag: "🇸🇦" },
{ code: "tr", label: "Türkçe", flag: "🇹🇷" },
{ code: "ur", label: "اردو", flag: "🇵🇰" },
];
type WindowWithHabibToken = Window & {
HABIB_TOKEN?: string;
@ -99,6 +149,7 @@ export function TokenSwitcher({
const [currentToken, setCurrentToken] = useState<string>("");
const [activeStoredName, setActiveStoredName] = useState<string>("");
const [activeStoredEmoji, setActiveStoredEmoji] = useState<string>("");
const [activeLocale, setActiveLocale] = useState<string>("fa");
const [savedCustomAccounts, setSavedCustomAccounts] = useState<
DevAccountItem[]
>([]);
@ -119,6 +170,41 @@ export function TokenSwitcher({
const norm = normalizeToken(rawToken);
setCurrentToken(norm);
// Sync and detect dev locale for SSR
const pathLocale = window.location.pathname.split("/")[1];
const cookieLocale =
getClientCookie(LANGUAGE_COOKIE_NAME) ??
getClientCookie("habib_language");
let savedDevLocale: string | null = null;
try {
savedDevLocale = window.localStorage.getItem(DEV_LOCALE_STORAGE_KEY);
} catch {}
const resolvedLocale = isLocale(pathLocale)
? pathLocale
: isLocale(cookieLocale)
? cookieLocale
: isLocale(savedDevLocale)
? savedDevLocale
: "fa";
setActiveLocale(resolvedLocale);
// In dev mode, ensure cookie is actively set so SSR & proxy.ts have an authoritative locale
const localeCookieOptions = {
maxAge: LANGUAGE_COOKIE_MAX_AGE,
path: "/",
};
setClientCookie(
LANGUAGE_COOKIE_NAME,
resolvedLocale,
localeCookieOptions,
);
setClientCookie("habib_language", resolvedLocale, localeCookieOptions);
try {
window.localStorage.setItem(DEV_LOCALE_STORAGE_KEY, resolvedLocale);
} catch {}
try {
const savedName = window.localStorage.getItem(
DEV_ACTIVE_NAME_STORAGE_KEY,
@ -148,6 +234,38 @@ export function TokenSwitcher({
return null;
}
const persistLocale = (localeCode: string) => {
if (!isLocale(localeCode)) return;
const options = { maxAge: LANGUAGE_COOKIE_MAX_AGE, path: "/" };
setClientCookie(LANGUAGE_COOKIE_NAME, localeCode, options);
setClientCookie("habib_language", localeCode, options);
try {
window.localStorage.setItem(DEV_LOCALE_STORAGE_KEY, localeCode);
} catch (e) {
console.warn("Failed to persist dev locale:", e);
}
};
const handleSelectLocale = (newLocale: string) => {
if (!isLocale(newLocale)) return;
persistLocale(newLocale);
setActiveLocale(newLocale);
if (typeof document !== "undefined") {
document.documentElement.lang = newLocale;
document.documentElement.dir = localeDirections[newLocale] ?? "ltr";
document.body.setAttribute("data-lang", newLocale);
}
if (typeof window !== "undefined") {
const currentPath = window.location.pathname;
const localizedPath = localizePath(currentPath, newLocale);
window.location.replace(
`${localizedPath}${window.location.search}${window.location.hash}`,
);
}
};
const persistCustomAccounts = (accounts: DevAccountItem[]) => {
setSavedCustomAccounts(accounts);
if (typeof window !== "undefined") {
@ -168,11 +286,16 @@ export function TokenSwitcher({
const savedTokens = window.localStorage.getItem(
DEV_CUSTOM_TOKENS_STORAGE_KEY,
);
const savedLocale =
window.localStorage.getItem(DEV_LOCALE_STORAGE_KEY) || activeLocale;
window.localStorage.clear();
window.sessionStorage.clear();
if (savedTokens) {
window.localStorage.setItem(DEV_CUSTOM_TOKENS_STORAGE_KEY, savedTokens);
}
if (savedLocale) {
window.localStorage.setItem(DEV_LOCALE_STORAGE_KEY, savedLocale);
}
if (keepName) {
window.localStorage.setItem(DEV_ACTIVE_NAME_STORAGE_KEY, keepName);
}
@ -221,6 +344,10 @@ export function TokenSwitcher({
if (typeof window !== "undefined") {
safeClearStorage(resolvedTitle, resolvedEmoji);
const targetLocale =
activeLocale && isLocale(activeLocale) ? activeLocale : "fa";
persistLocale(targetLocale);
setCachedMarriageEntryPath(null);
setClientCookie(TOKEN_COOKIE_NAME, cleanToken);
setClientCookie("habib_token", cleanToken);
@ -235,7 +362,7 @@ export function TokenSwitcher({
setActiveStoredEmoji(resolvedEmoji);
setIsOpen(false);
window.location.replace("/");
window.location.replace(`/${targetLocale}`);
}
};
@ -368,14 +495,14 @@ export function TokenSwitcher({
return (
<>
{/* Floating Badge (وقتی بسته است فقط نام حساب و ایموجی را نمایش می‌دهد) */}
{/* Floating Badge (وقتی بسته است نام حساب، ایموجی و زبان فعال SSR را نمایش می‌دهد) */}
<button
type="button"
onClick={() => setIsOpen(true)}
aria-label="تغییر توکن کاربر (تست)"
title="تغییر توکن کاربر (محیط توسعه)"
aria-label="تغییر توکن کاربر و زبان (تست)"
title="تغییر توکن کاربر و زبان مریج (محیط توسعه)"
className={[
"fixed top-4 left-4 z-[99999] inline-flex items-center gap-2 rounded-[15px] px-3.5 py-2 text-xs font-extrabold transition-all active:scale-95 shadow-md border border-slate-200/80 dark:border-slate-700/80 backdrop-blur-md max-w-[260px] cursor-pointer",
"fixed top-4 left-4 z-[99999] inline-flex items-center gap-2 rounded-[15px] px-3.5 py-2 text-xs font-extrabold transition-all active:scale-95 shadow-md border border-slate-200/80 dark:border-slate-700/80 backdrop-blur-md max-w-[320px] cursor-pointer",
variant === "transparent"
? "bg-black/40 text-white hover:bg-black/50"
: "bg-white/95 text-slate-800 hover:bg-white dark:bg-slate-900/95 dark:text-slate-100",
@ -386,6 +513,10 @@ export function TokenSwitcher({
>
<Ic name="switchAccount" className="size-5 text-rose-500 shrink-0" />
<span className="truncate">{getFloatingButtonLabel()}</span>
<span className="inline-flex items-center gap-1 rounded-md bg-rose-500/10 dark:bg-rose-500/20 px-1.5 py-0.5 text-[10px] font-bold text-rose-600 dark:text-rose-400 border border-rose-500/20 uppercase shrink-0">
<span>{LOCALE_FLAGS[activeLocale] || "🌐"}</span>
<span>{activeLocale}</span>
</span>
</button>
{isOpen && (
@ -413,10 +544,10 @@ export function TokenSwitcher({
</div>
<div>
<h3 className="font-extrabold text-slate-900 dark:text-white text-base leading-snug">
انتخاب حساب کاربری (تست)
پنل توسعه مریج (اکانت و زبان)
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
تغییر سریع پروفایل، نام‌گذاری و مدیریت توکن‌ها
مدیریت توکن احراز هویت، زبان فعال SSR و تست پروفایل‌ها
</p>
</div>
</div>
@ -431,6 +562,70 @@ export function TokenSwitcher({
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4 min-h-0">
{/* Dev Active Locale Selector Section */}
<div className="rounded-2xl border border-rose-200/80 dark:border-rose-900/50 bg-gradient-to-br from-rose-50/60 to-white dark:from-rose-950/20 dark:to-slate-900 p-3.5 space-y-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-base">
{LOCALE_FLAGS[activeLocale] || "🌐"}
</span>
<span className="text-xs font-extrabold text-slate-900 dark:text-white">
زبان فعال در حالت توسعه (SSR / Locale)
</span>
</div>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-rose-500 text-white font-bold">
{activeLocale.toUpperCase()}
</span>
</div>
<p className="text-[11px] text-slate-500 dark:text-slate-400 leading-relaxed">
تعیین زبان سراسری مریج برای SSR، ریدایرکت‌های proxy.ts،
فراخوانی‌های بک‌اند و دیکشنری‌ها:
</p>
{/* Quick Selection Pills */}
<div className="flex items-center gap-1.5 flex-wrap">
{POPULAR_DEV_LOCALES.map((item) => {
const isSelected = activeLocale === item.code;
return (
<button
key={item.code}
type="button"
onClick={() => handleSelectLocale(item.code)}
className={[
"px-2.5 py-1.5 rounded-xl text-xs font-bold flex items-center gap-1.5 transition-all cursor-pointer",
isSelected
? "bg-rose-500 text-white shadow-xs scale-105"
: "bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-750",
].join(" ")}
>
<span>{item.flag}</span>
<span>{item.label}</span>
</button>
);
})}
</div>
{/* Dropdown for all 24 supported languages */}
<div className="flex items-center gap-2 pt-1 border-t border-rose-100 dark:border-slate-800">
<span className="text-[11px] text-slate-500 dark:text-slate-400 shrink-0">
سایر زبان‌ها:
</span>
<select
value={activeLocale}
onChange={(e) => handleSelectLocale(e.target.value)}
className="flex-1 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-2.5 py-1.5 text-xs text-slate-800 dark:text-slate-200 outline-none focus:border-rose-500 cursor-pointer font-medium"
>
{locales.map((code) => (
<option key={code} value={code}>
{LOCALE_FLAGS[code] || "🌐"} {LANGUAGE_MAP[code]} (
{code})
</option>
))}
</select>
</div>
</div>
{/* Predefined System Accounts */}
<div className="space-y-2">
<div className="text-[11px] font-bold text-slate-400 dark:text-slate-500 px-1">

30
src/hooks/marriage/use-profile-main.ts

@ -14,6 +14,8 @@ import {
getScopedMarriageProfile,
saveScopedMarriageProfile,
} from "@/lib/user-scoped-storage";
import { getClientCookie } from "@/lib/cookies";
import { isLocale } from "@/translations/config";
import type { QueryOptions } from "./options";
import { marriageQueryKeys } from "./query-keys";
import type { MarriageProfileResponse } from "./types";
@ -57,11 +59,31 @@ export function getInitialMarriageProfile(
}
export async function getMarriageProfile() {
console.log("🌐 [Profile API] Sending GET /api/marriage/profile/main/ ...");
const pathSegment =
typeof window !== "undefined"
? window.location.pathname.split("/")[1]
: undefined;
const docLang =
typeof document !== "undefined" ? document.documentElement.lang : undefined;
const cookieLanguage =
getClientCookie("HABIB_LANGUAGE") ??
getClientCookie("habib_language") ??
undefined;
const lang = isLocale(pathSegment)
? pathSegment
: isLocale(cookieLanguage)
? cookieLanguage
: isLocale(docLang)
? docLang
: undefined;
const url = lang
? `/api/marriage/profile/main/?lang=${encodeURIComponent(lang)}`
: "/api/marriage/profile/main/";
console.log(`🌐 [Profile API] Sending GET ${url} ...`);
try {
const { data: rawData } = await http.get<MarriageProfileResponse>(
"/api/marriage/profile/main/",
);
const { data: rawData } = await http.get<MarriageProfileResponse>(url);
const data = normalizeMarriageProfile(rawData) || rawData;

65
src/lib/marriage-field-formatter.test.ts

@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { formatFieldLabel, titleFromKey } from "./marriage-field-formatter";
import { dictionaries } from "@/translations/dictionaries";
describe("marriage-field-formatter: formatFieldLabel", () => {
it("directly respects dynamic Arabic labels returned from backend DB without hardcoded JSON dependency", () => {
const field = {
key: "personal_identity.place_of_birth",
label: "مكان الميلاد (الدولة والمدينة)",
value: { city: "Mashhad", country: "Iran" },
};
// When viewing with Arabic dictionary, must directly display backend's Arabic title
const labelAr = formatFieldLabel(field, dictionaries.ar as Record<string, string>);
expect(labelAr).toBe("مكان الميلاد (الدولة والمدينة)");
// Must NOT be overridden with English "Place Of Birth"
expect(labelAr).not.toBe("Place Of Birth");
});
it("directly respects dynamic Arabic labels for place_of_upbringing", () => {
const field = {
key: "personal_identity.place_of_upbringing",
label: "مكان النشأة والترعرع (الدولة والمدينة)",
value: { city: "Mashhad", country: "Iran" },
};
const labelAr = formatFieldLabel(field, dictionaries.ar as Record<string, string>);
expect(labelAr).toBe("مكان النشأة والترعرع (الدولة والمدينة)");
expect(labelAr).not.toBe("Place Of Upbringing");
});
it("directly respects dynamically updated DB question text", () => {
const field = {
key: "personal_identity.place_of_birth",
label: "في أي دولة ومدينة ولدت؟",
value: "...",
};
const labelAr = formatFieldLabel(field, dictionaries.ar as Record<string, string>);
expect(labelAr).toBe("في أي دولة ومدينة ولدت؟");
});
it("converts Persian/Arabic labels to English title when dictionary is English", () => {
const field = {
key: "personal_identity.place_of_birth",
label: "مكان الميلاد (الدولة والمدينة)",
value: { city: "Mashhad", country: "Iran" },
};
const labelEn = formatFieldLabel(field, dictionaries.en as Record<string, string>);
expect(labelEn).toBe("Place Of Birth");
});
it("falls back to canonical / titleFromKey when backend label is missing", () => {
const field = {
key: "personal_identity.place_of_birth",
label: "",
value: "...",
};
const labelAr = formatFieldLabel(field, dictionaries.ar as Record<string, string>);
expect(labelAr).toBe("مكان الميلاد");
});
});

160
src/lib/marriage-field-formatter.ts

@ -104,50 +104,168 @@ export function titleFromKey(key: string): string {
.replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function isEnglishDictionary(dictionary?: Record<string, string>): boolean {
if (!dictionary) return false;
return (
dictionary === (dictionaries.en as Record<string, string>) ||
(dictionary["Yes"] === "Yes" &&
dictionary["No"] === "No" &&
dictionary["Cancel"] === "Cancel")
);
}
export const FIELD_KEY_TO_CANONICAL_EN: Record<string, string[]> = {
"personal_identity.date_of_birth": ["Date of Birth"],
date_of_birth: ["Date of Birth"],
"personal_identity.place_of_birth": [
"Place of Birth (Country and City)",
"Place of Birth",
"Birthplace",
],
place_of_birth: [
"Place of Birth (Country and City)",
"Place of Birth",
"Birthplace",
],
birthplace: ["Birthplace", "Place of Birth"],
"personal_identity.place_of_upbringing": [
"Place of Upbringing / Where You Grew Up (Country and City)",
"Place of Upbringing",
"City of Upbringing / Growing Up",
],
place_of_upbringing: [
"Place of Upbringing / Where You Grew Up (Country and City)",
"Place of Upbringing",
"City of Upbringing / Growing Up",
],
"personal_identity.current_nationality_citizenship": [
"Current Nationality / Citizenship",
"Current Nationality / Citizenship (or Countries of Citizenship)",
"Nationality",
"Citizenship",
],
current_nationality_citizenship: [
"Current Nationality / Citizenship",
"Current Nationality / Citizenship (or Countries of Citizenship)",
"Nationality",
"Citizenship",
],
"personal_identity.ethnicity_family_origin_race": [
"Ethnicity / Family Origin / Race",
"Ethnicity",
],
ethnicity_family_origin_race: [
"Ethnicity / Family Origin / Race",
"Ethnicity",
],
"personal_identity.mother_tongue": [
"Mother Tongue",
"Native Language",
"Language",
],
mother_tongue: [
"Mother Tongue",
"Native Language",
"Language",
],
"personal_identity.religion": [
"What is your religion?",
"Religion",
],
religion: [
"What is your religion?",
"Religion",
],
"personal_identity.islamic_madhhab": [
"What is your branch / school of thought in Islam?",
"Islamic Madhhab",
"School of Thought",
],
islamic_madhhab: [
"What is your branch / school of thought in Islam?",
"Islamic Madhhab",
"School of Thought",
],
"contact_residence.current_residence_location": [
"Current Place of Residence (Country, City / State)",
"Current Place of Residence",
"Current Residence",
],
current_residence_location: [
"Current Place of Residence (Country, City / State)",
"Current Place of Residence",
"Current Residence",
],
};
export function formatFieldLabel(
field: MarriageField,
dictionary?: Record<string, string>,
): string {
const englishTitle = titleFromKey(field.key);
const rawLabel = (field.label || "").trim();
const isEnglish = isEnglishDictionary(dictionary);
// If rawLabel is provided from the backend (the dynamic source of truth)
if (rawLabel) {
// 1. Direct dictionary match for field.label (e.g. static UI overrides)
if (dictionary?.[rawLabel]) {
return dictionary[rawLabel];
}
// 2. If rawLabel is in Persian reverse map, lookup translation in dictionary
const enKeyFromFa = persianToEnglishMap[rawLabel];
if (enKeyFromFa) {
if (dictionary?.[enKeyFromFa]) {
return dictionary[enKeyFromFa];
}
if (isEnglish) {
return enKeyFromFa;
}
}
// 1. Direct dictionary match for field.label
if (rawLabel && dictionary?.[rawLabel]) {
return dictionary[rawLabel];
// 3. If active dictionary is English and rawLabel contains non-Latin (Persian/Arabic) characters, fallback to englishTitle
const hasPersianOrArabic = /[؀-ۿ]/.test(rawLabel);
if (hasPersianOrArabic && isEnglish) {
return englishTitle || rawLabel;
}
// 4. In all non-English locales (Arabic, Persian, etc.), trust the backend's dynamic label directly!
return rawLabel;
}
// 2. If rawLabel is in Persian, convert it to its canonical English key via reverse map
const enKeyFromFa = rawLabel ? persianToEnglishMap[rawLabel] : null;
if (enKeyFromFa) {
if (dictionary?.[enKeyFromFa]) {
return dictionary[enKeyFromFa];
// If rawLabel is missing, synthesize from canonical keys or key name
const canonicalCandidates =
FIELD_KEY_TO_CANONICAL_EN[field.key] ||
FIELD_KEY_TO_CANONICAL_EN[field.key.split(".").pop() || ""] ||
[];
for (const candidate of canonicalCandidates) {
if (dictionary?.[candidate]) {
return dictionary[candidate];
}
return enKeyFromFa;
}
// 3. Direct match for English title derived from key
if (dictionary?.[englishTitle]) {
return dictionary[englishTitle];
}
// 4. Normalized match for key or label (e.g. ignoring punctuation/spaces like apostrophes and parentheses)
const normKey = (field.key || "").split(".").pop()?.toLowerCase().replace(/[^a-z0-9]/g, "") || "";
const normKey =
(field.key || "")
.split(".")
.pop()
?.toLowerCase()
.replace(/[^a-z0-9]/g, "") || "";
const matchedCanonicalKey = normalizedEnglishKeys[normKey];
if (matchedCanonicalKey) {
if (dictionary?.[matchedCanonicalKey]) {
return dictionary[matchedCanonicalKey];
}
return matchedCanonicalKey;
}
// 5. If rawLabel contains Persian/Arabic characters and active dictionary is English, use englishTitle
const hasPersian = /[؀-ۿ]/.test(rawLabel);
if (hasPersian && dictionary) {
return englishTitle || rawLabel;
if (isEnglish) {
return matchedCanonicalKey;
}
}
// 6. Fallback
return rawLabel || englishTitle;
return englishTitle || field.key;
}
export function formatOptionValue(

Loading…
Cancel
Save