Browse Source

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

master
mortezaei 3 weeks ago
parent
commit
61678091b3
  1. 61
      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. 149
      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. 387
      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. 146
      src/lib/marriage-field-formatter.ts

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

@ -4,35 +4,35 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; 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 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, { import MarriageAdvisorsOverlay, {
useMarriageAdvisorsOverlay, useMarriageAdvisorsOverlay,
} from "@/components/Componentes/marriage-advisors-overlay"; } from "@/components/Componentes/marriage-advisors-overlay";
import ErrorToast from "@/components/Componentes/error-toast";
import MatchProfileOverlay, { import MatchProfileOverlay, {
useMatchProfileOverlay, useMatchProfileOverlay,
} from "@/components/Componentes/match-profile-overlay"; } from "@/components/Componentes/match-profile-overlay";
import PageHeader from "@/components/Componentes/page-header";
import { PageBackground } from "@/components/Componentes/page-background"; 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 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 { import type {
MarriageField, MarriageField,
MarriageFieldValue, MarriageFieldValue,
MarriageMatchSummary, MarriageMatchSummary,
MarriagePhoneFieldValue, MarriagePhoneFieldValue,
} from "@/hooks/marriage/types"; } 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 { 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 { useViewPaddings } from "@/hooks/use-view-paddings";
import { Ic } from "@/icons";
import { getSubmitPath } from "@/lib/get-submit-path"; import { getSubmitPath } from "@/lib/get-submit-path";
import { import {
buyHabibCoinPackages, buyHabibCoinPackages,
@ -93,11 +93,7 @@ const fieldCandidateMatchers = {
"education_level", "education_level",
"education", "education",
], ],
fieldOfStudy: [
"field_of_study",
"study_field",
"study",
],
fieldOfStudy: ["field_of_study", "study_field", "study"],
jobTitle: [ jobTitle: [
"job_title", "job_title",
"job_title_and_description", "job_title_and_description",
@ -178,8 +174,8 @@ import {
formatOptionValue, formatOptionValue,
getOrderedSummaryFields, getOrderedSummaryFields,
isMarriagePhoneFieldValue, isMarriagePhoneFieldValue,
titleFromKey,
type SummaryFieldItem, type SummaryFieldItem,
titleFromKey,
} from "@/lib/marriage-field-formatter"; } from "@/lib/marriage-field-formatter";
function toDisplayField( function toDisplayField(
@ -617,7 +613,7 @@ export default function NewMatchClient() {
{/* 2. Match Summary Card Section */} {/* 2. Match Summary Card Section */}
<div className="w-full shrink-0" suppressHydrationWarning> <div className="w-full shrink-0" suppressHydrationWarning>
{(isLoading || (!matchSummary && isFetching)) ? (
{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"> <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 h-[74px] w-full bg-[linear-gradient(180deg,#FCE2E6_0%,#FFD6DC_100%)]" />
@ -699,7 +695,12 @@ export default function NewMatchClient() {
<ProfileInfoItem <ProfileInfoItem
key={item.id} key={item.id}
field={item} field={item}
icon={<Ic name={item.iconName} className="size-4 text-[#747474]" />}
icon={
<Ic
name={item.iconName}
className="size-4 text-[#747474]"
/>
}
/> />
))} ))}
@ -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" 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" 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"> <span className="group-16 leading-none font-semibold">
{t["Profile is locked"]} {t["Profile is locked"]}
</span> </span>
@ -859,7 +864,7 @@ export default function NewMatchClient() {
disabled={ disabled={
paymentMutation.isPending || respondMutation.isPending 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} 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"> <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 ? ( {!hasEnoughCoins ? (
<button <button
type="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()} 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"> <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={ disabled={
paymentMutation.isPending || respondMutation.isPending 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} 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"> <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 }) => ( buttons={({ close }) => (
<SwipeButton <SwipeButton
disabled={respondMutation.isPending} 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={() => { onCancel={() => {
close(); close();
cancelDeclineConfirm(); cancelDeclineConfirm();

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

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

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

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

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

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

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

@ -1,7 +1,15 @@
"use client"; "use client";
import type { HTMLAttributes } from "react"; 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"; import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 200; const EXIT_ANIMATION_MS = 200;
@ -24,7 +32,8 @@ export function CallResultSheet({
className, className,
...props ...props
}: CallResultSheetProps) { }: CallResultSheetProps) {
const { dictionary: t } = useI18n();
const { dictionary: t, locale } = useI18n();
const direction = localeDirections[locale] ?? "ltr";
const callResultOptions = useMemo( const callResultOptions = useMemo(
() => [ () => [
t["Not a good personal fit"], t["Not a good personal fit"],
@ -97,6 +106,7 @@ export function CallResultSheet({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t["Call result"]} aria-label={t["Call result"]}
dir={direction}
tabIndex={-1} tabIndex={-1}
onClick={(event) => { onClick={(event) => {
if (closeOnOutside && event.target === event.currentTarget) { if (closeOnOutside && event.target === event.currentTarget) {
@ -117,15 +127,15 @@ export function CallResultSheet({
<section <section
{...props} {...props}
className={[ 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", isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
className, className,
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .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"]} {t["Call result"]}
</h2> </h2>
@ -145,7 +155,7 @@ export function CallResultSheet({
return ( return (
<label <label
key={option} 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 <input
checked={checked} checked={checked}
@ -178,7 +188,7 @@ export function CallResultSheet({
<span className="h-3 w-3 rounded-full bg-[#F0445B]" /> <span className="h-3 w-3 rounded-full bg-[#F0445B]" />
) : null} ) : null}
</span> </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} {option}
</span> </span>
</label> </label>
@ -190,7 +200,7 @@ export function CallResultSheet({
<div className="mt-8 grid w-full grid-cols-[33fr_67fr] gap-3"> <div className="mt-8 grid w-full grid-cols-[33fr_67fr] gap-3">
<button <button
type="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} 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"> <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 <button
type="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={() => { onClick={() => {
onSubmit?.(selectedReason); onSubmit?.(selectedReason);
closeSheet(); closeSheet();

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

@ -1,8 +1,16 @@
"use client"; "use client";
import type { HTMLAttributes } from "react"; 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 { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { localeDirections } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import Button from "./button"; import Button from "./button";
@ -24,7 +32,8 @@ export function DismissReasonSheet({
className, className,
...props ...props
}: DismissReasonSheetProps) { }: DismissReasonSheetProps) {
const { dictionary: t } = useI18n();
const { dictionary: t, locale } = useI18n();
const direction = localeDirections[locale] ?? "ltr";
const options = useMemo( const options = useMemo(
() => [ () => [
t["Not a good personal fit"], t["Not a good personal fit"],
@ -112,6 +121,7 @@ export function DismissReasonSheet({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t["Dismiss reasons"]} aria-label={t["Dismiss reasons"]}
dir={direction}
tabIndex={-1} tabIndex={-1}
onClick={(event) => { onClick={(event) => {
if (closeOnOutside && event.target === event.currentTarget) { if (closeOnOutside && event.target === event.currentTarget) {
@ -132,20 +142,20 @@ export function DismissReasonSheet({
<section <section
{...props} {...props}
className={[ 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", isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
className, className,
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .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"]} {t["Dismiss reasons"]}
</h2> </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[ {t[
"Please provide the full reason for declining the submitted item" "Please provide the full reason for declining the submitted item"
] || ] ||
@ -162,7 +172,7 @@ export function DismissReasonSheet({
{t["Dismiss reasons"]} {t["Dismiss reasons"]}
</legend> </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) => { {options.map((option) => {
const checked = selectedReasons.includes(option); const checked = selectedReasons.includes(option);
const showTextArea = option === options[options.length - 1]; const showTextArea = option === options[options.length - 1];
@ -214,8 +224,8 @@ export function DismissReasonSheet({
) : null} ) : null}
</span> </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} {option}
</span> </span>
</span> </span>
@ -224,7 +234,7 @@ export function DismissReasonSheet({
{checked && showTextArea ? ( {checked && showTextArea ? (
<textarea <textarea
ref={textareaRef} 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={ placeholder={
t["Feel free to briefly explain your decision..."] 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 type { HTMLAttributes, ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { localeDirections } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 200; const EXIT_ANIMATION_MS = 200;
@ -28,7 +29,8 @@ export function FemaleConsentSheet({
className, className,
...props ...props
}: FemaleConsentSheetProps) { }: FemaleConsentSheetProps) {
const { dictionary: t } = useI18n();
const { dictionary: t, locale } = useI18n();
const direction = localeDirections[locale] ?? "ltr";
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
@ -89,6 +91,7 @@ export function FemaleConsentSheet({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t["Information sheet"]} aria-label={t["Information sheet"]}
dir={direction}
tabIndex={-1} tabIndex={-1}
onClick={(event) => { onClick={(event) => {
if (closeOnOutside && event.target === event.currentTarget) { if (closeOnOutside && event.target === event.currentTarget) {
@ -109,7 +112,7 @@ export function FemaleConsentSheet({
<section <section
{...props} {...props}
className={[ 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", isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
className, className,
] ]
@ -117,7 +120,7 @@ export function FemaleConsentSheet({
.join(" ")} .join(" ")}
> >
<div className="flex items-center justify-between gap-4"> <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} {title}
</h2> </h2>
<button <button
@ -136,7 +139,7 @@ export function FemaleConsentSheet({
</div> </div>
{description ? ( {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} {description}
</div> </div>
) : null} ) : null}

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

@ -81,4 +81,49 @@ describe("FemaleOutcomeSheet", () => {
); );
expect(confirmation).toHaveProperty("disabled", false); 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();
});
}); });

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

@ -1,11 +1,26 @@
"use client"; "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 { useI18n } from "@/translations/provider";
import { useSheetScrollLock } from "./use-sheet-scroll-lock";
const EXIT_ANIMATION_MS = 200; 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 = { export type FemaleOutcomeSheetProps = {
closeOnOutside?: boolean; closeOnOutside?: boolean;
onClose?: () => void; onClose?: () => void;
@ -17,13 +32,25 @@ export function FemaleOutcomeSheet({
onClose, onClose,
onSubmit, onSubmit,
}: FemaleOutcomeSheetProps) { }: FemaleOutcomeSheetProps) {
const { dictionary: t } = useI18n();
const { dictionary: t, locale } = useI18n();
const direction = localeDirections[locale] ?? "ltr";
const groupId = useId(); const groupId = useId();
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const isClosingRef = useRef(false);
const isMountedRef = useRef(true); 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(() => { useEffect(() => {
isMountedRef.current = true; isMountedRef.current = true;
return () => { 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 // Rejection/Cancellation reason states
const reasons = useMemo( const reasons = useMemo(
() => [ () => [
@ -64,36 +88,302 @@ export function FemaleOutcomeSheet({
}, [selectedReasons, reasons]); }, [selectedReasons, reasons]);
const closeSheet = useCallback(() => { const closeSheet = useCallback(() => {
if (isClosing) {
if (isClosingRef.current) {
return; return;
} }
isClosingRef.current = true;
setIsClosing(true); setIsClosing(true);
window.setTimeout(() => { window.setTimeout(() => {
if (isMountedRef.current) { if (isMountedRef.current) {
setIsVisible(false); setIsVisible(false);
} }
isClosingRef.current = false;
onClose?.(); onClose?.();
}, EXIT_ANIMATION_MS); }, EXIT_ANIMATION_MS);
}, [isClosing, onClose]);
}, [onClose]);
useHardwareBackHandler(closeSheet, isVisible && !isClosing);
useSheetScrollLock(isVisible && !isClosing, { onBack: closeSheet });
// Handle escape key
useEffect(() => { useEffect(() => {
if (!isVisible) {
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; 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);
}
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);
};
const previousBodyOverflow = document.body.style.overflow;
const previousHtmlOverflow = document.documentElement.style.overflow;
const onHeaderTouchStart = (e: TouchEvent) => handleTouchStart(e, true);
const onContentTouchStart = (e: TouchEvent) => handleTouchStart(e, false);
document.body.style.overflow = "hidden";
document.documentElement.style.overflow = "hidden";
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 () => { 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) { if (!isVisible) {
return null; return null;
@ -115,6 +405,7 @@ export function FemaleOutcomeSheet({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t["What was the outcome of your contact?"]} aria-label={t["What was the outcome of your contact?"]}
dir={direction}
tabIndex={-1} tabIndex={-1}
onClick={(event) => { onClick={(event) => {
if (closeOnOutside && event.target === event.currentTarget) { if (closeOnOutside && event.target === event.currentTarget) {
@ -133,20 +424,39 @@ export function FemaleOutcomeSheet({
}} }}
> >
<section <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={[ 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", isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .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?"]} {t["What was the outcome of your contact?"]}
</h2> </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[ 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." "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?"]} {t["What was the outcome of your contact?"]}
</legend> </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 */} {/* Option A: Ongoing acquaintance */}
<label <label
className={[ 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" outcome === "ongoing"
? "bg-[#FFECEF] border-[#F0445B]/30" ? "bg-[#FFECEF] border-[#F0445B]/30"
: "bg-[#ECECEC] border-transparent", : "bg-[#ECECEC] border-transparent",
@ -195,7 +508,7 @@ export function FemaleOutcomeSheet({
<span className="h-3 w-3 rounded-full bg-[#F0445B]" /> <span className="h-3 w-3 rounded-full bg-[#F0445B]" />
) : null} ) : null}
</span> </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[ t[
"We are in the acquaintance/proposal process and nothing is finalized yet" "We are in the acquaintance/proposal process and nothing is finalized yet"
@ -207,7 +520,7 @@ export function FemaleOutcomeSheet({
{/* Option B: Canceled / Decline */} {/* Option B: Canceled / Decline */}
<label <label
className={[ 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" outcome === "canceled"
? "bg-[#FFECEF] border-[#F0445B]/30" ? "bg-[#FFECEF] border-[#F0445B]/30"
: "bg-[#ECECEC] border-transparent", : "bg-[#ECECEC] border-transparent",
@ -242,20 +555,18 @@ export function FemaleOutcomeSheet({
<span className="h-3 w-3 rounded-full bg-[#F0445B]" /> <span className="h-3 w-3 rounded-full bg-[#F0445B]" />
) : null} ) : null}
</span> </span>
<span className="text-[15px] leading-snug font-bold text-[#262626] flex-1">
{
t[
<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"
] || ] ||
"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> </span>
</label> </label>
{/* Reasons List (if Canceled is chosen) */} {/* Reasons List (if Canceled is chosen) */}
{outcome === "canceled" ? ( {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:"]} {t["Please select the reason for cancellation:"]}
</p> </p>
<div className="flex flex-col gap-[10px]"> <div className="flex flex-col gap-[10px]">
@ -317,8 +628,8 @@ export function FemaleOutcomeSheet({
) : null} ) : null}
</span> </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} {option}
</span> </span>
</span> </span>
@ -327,7 +638,7 @@ export function FemaleOutcomeSheet({
{isReasonChecked && isOtherReason ? ( {isReasonChecked && isOtherReason ? (
<textarea <textarea
ref={textareaRef} 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={ placeholder={
t[ t[
"Feel free to briefly explain your decision..." "Feel free to briefly explain your decision..."
@ -377,7 +688,9 @@ export function FemaleOutcomeSheet({
onClick={() => { onClick={() => {
const activeReasons = selectedReasons.map((r) => { const activeReasons = selectedReasons.map((r) => {
if (r === reasons[reasons.length - 1]) { if (r === reasons[reasons.length - 1]) {
return reasonText.trim() ? `${r}: ${reasonText.trim()}` : r;
return reasonText.trim()
? `${r}: ${reasonText.trim()}`
: r;
} }
return r; return r;
}); });

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

@ -1,8 +1,9 @@
"use client"; "use client";
import type { HTMLAttributes } from "react"; 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 { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { localeDirections } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 200; const EXIT_ANIMATION_MS = 200;
@ -23,7 +24,8 @@ export function OutcomeSelectionSheet({
className, className,
...props ...props
}: OutcomeSelectionSheetProps) { }: OutcomeSelectionSheetProps) {
const { dictionary: t } = useI18n();
const { dictionary: t, locale } = useI18n();
const direction = localeDirections[locale] ?? "ltr";
const groupId = useId(); const groupId = useId();
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
@ -91,6 +93,7 @@ export function OutcomeSelectionSheet({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t["What was the outcome of your contact?"]} aria-label={t["What was the outcome of your contact?"]}
dir={direction}
tabIndex={-1} tabIndex={-1}
onClick={(event) => { onClick={(event) => {
if (closeOnOutside && event.target === event.currentTarget) { if (closeOnOutside && event.target === event.currentTarget) {
@ -111,15 +114,15 @@ export function OutcomeSelectionSheet({
<section <section
{...props} {...props}
className={[ 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", isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
className, className,
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .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?"]} {t["What was the outcome of your contact?"]}
</h2> </h2>
@ -139,7 +142,7 @@ export function OutcomeSelectionSheet({
return ( return (
<label <label
key={option.value} 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 <input
checked={checked} checked={checked}
@ -164,7 +167,7 @@ export function OutcomeSelectionSheet({
<span className="h-3 w-3 rounded-full bg-[#F0445B]" /> <span className="h-3 w-3 rounded-full bg-[#F0445B]" />
) : null} ) : null}
</span> </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} {option.label}
</span> </span>
</label> </label>
@ -176,7 +179,7 @@ export function OutcomeSelectionSheet({
<div className="mt-8 grid w-full grid-cols-[33fr_67fr] gap-3"> <div className="mt-8 grid w-full grid-cols-[33fr_67fr] gap-3">
<button <button
type="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} 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"> <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 <button
type="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={() => { onClick={() => {
onSubmit?.(selectedStatus); onSubmit?.(selectedStatus);
closeSheet(); 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" 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"> <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"> <NoticeBox className="mt-8" align="justify">
You can register both for yourself and for your loved ones, but You can register both for yourself and for your loved ones, but
obtaining the explicit consent and permission of the person you are obtaining the explicit consent and permission of the person you are
@ -46,10 +50,10 @@ export function SliderSlideFour({
type="button" type="button"
aria-pressed={isSelected} aria-pressed={isSelected}
className={[ 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 isSelected
? "bg-[#F4435C] text-white" ? "bg-[#F4435C] text-white"
: "bg-[#ECE9E9] text-[#2A2A2A]",
: "bg-[#ECECEC] text-[#2A2A2A]",
].join(" ")} ].join(" ")}
onClick={() => onRegistrationChange(option.id)} onClick={() => onRegistrationChange(option.id)}
> >
@ -78,7 +82,9 @@ export function SliderSlideFour({
/> />
</svg> </svg>
</span> </span>
<span className="group-14 font-semibold">{option.label}</span>
<span className="group-14 font-semibold text-start flex-1">
{option.label}
</span>
</button> </button>
); );
})} })}

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

@ -1,13 +1,10 @@
import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { useState } from "react";
import { useHabcoinInventoryQuery } from "@/hooks/marriage/use-habcoin-inventory"; import { useHabcoinInventoryQuery } from "@/hooks/marriage/use-habcoin-inventory";
import { DiscountWidget } from "./discount-widget";
import type { CheckDiscountResult } from "@/hooks/marriage/use-validate-discount"; 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 { useI18n } from "@/translations/provider";
import { DiscountWidget } from "./discount-widget";
import InformationSheet from "./information-sheet"; import InformationSheet from "./information-sheet";
import { LoadingThreeDot } from "./loading-three-dot"; 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"} "Please add more Habib Coins to your balance (or use discount code), so that you can use them to access this content"}
</p> </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]"> <p className="text-sm text-center leading-[1.45] font-medium text-[#2B2B2B]">
{t[ {t[
"To view profiles, you need a subscription. Each subscription includes up to {count} matches." "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"> <div className="grid w-full grid-cols-[33fr_67fr] gap-3">
<button <button
type="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} 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"> <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 ? ( {!hasEnoughCoins ? (
<button <button
type="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()} 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"> <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 <button
type="button" type="button"
disabled={isPaymentPending} 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={() => onClick={() =>
onPayment( onPayment(
appliedDiscount?.valid ? appliedDiscount.code : undefined, 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 { Ic } from "@/icons";
import { getClientCookie, setClientCookie } from "@/lib/cookies"; import { getClientCookie, setClientCookie } from "@/lib/cookies";
import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache"; import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache";
import {
isLocale,
LANGUAGE_MAP,
type LanguageCode,
localeDirections,
locales,
localizePath,
} from "@/translations/config";
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// ۱. توکن‌های ثابت سیستم (System Preset Tokens) // ۱. توکن‌های ثابت سیستم (System Preset Tokens)
@ -17,9 +25,51 @@ export const MALE_EMAIL = "muhammadamin.ghorbani@gmail.com";
export const MALE_2_EMAIL = "habibwabackup@gmail.com"; export const MALE_2_EMAIL = "habibwabackup@gmail.com";
const TOKEN_COOKIE_NAME = "HABIB_TOKEN"; 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_CUSTOM_TOKENS_STORAGE_KEY = "HABIB_DEV_CUSTOM_TOKENS_V1";
const DEV_ACTIVE_NAME_STORAGE_KEY = "HABIB_DEV_ACTIVE_NAME_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_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 & { type WindowWithHabibToken = Window & {
HABIB_TOKEN?: string; HABIB_TOKEN?: string;
@ -99,6 +149,7 @@ export function TokenSwitcher({
const [currentToken, setCurrentToken] = useState<string>(""); const [currentToken, setCurrentToken] = useState<string>("");
const [activeStoredName, setActiveStoredName] = useState<string>(""); const [activeStoredName, setActiveStoredName] = useState<string>("");
const [activeStoredEmoji, setActiveStoredEmoji] = useState<string>(""); const [activeStoredEmoji, setActiveStoredEmoji] = useState<string>("");
const [activeLocale, setActiveLocale] = useState<string>("fa");
const [savedCustomAccounts, setSavedCustomAccounts] = useState< const [savedCustomAccounts, setSavedCustomAccounts] = useState<
DevAccountItem[] DevAccountItem[]
>([]); >([]);
@ -119,6 +170,41 @@ export function TokenSwitcher({
const norm = normalizeToken(rawToken); const norm = normalizeToken(rawToken);
setCurrentToken(norm); 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 { try {
const savedName = window.localStorage.getItem( const savedName = window.localStorage.getItem(
DEV_ACTIVE_NAME_STORAGE_KEY, DEV_ACTIVE_NAME_STORAGE_KEY,
@ -148,6 +234,38 @@ export function TokenSwitcher({
return null; 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[]) => { const persistCustomAccounts = (accounts: DevAccountItem[]) => {
setSavedCustomAccounts(accounts); setSavedCustomAccounts(accounts);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -168,11 +286,16 @@ export function TokenSwitcher({
const savedTokens = window.localStorage.getItem( const savedTokens = window.localStorage.getItem(
DEV_CUSTOM_TOKENS_STORAGE_KEY, DEV_CUSTOM_TOKENS_STORAGE_KEY,
); );
const savedLocale =
window.localStorage.getItem(DEV_LOCALE_STORAGE_KEY) || activeLocale;
window.localStorage.clear(); window.localStorage.clear();
window.sessionStorage.clear(); window.sessionStorage.clear();
if (savedTokens) { if (savedTokens) {
window.localStorage.setItem(DEV_CUSTOM_TOKENS_STORAGE_KEY, savedTokens); window.localStorage.setItem(DEV_CUSTOM_TOKENS_STORAGE_KEY, savedTokens);
} }
if (savedLocale) {
window.localStorage.setItem(DEV_LOCALE_STORAGE_KEY, savedLocale);
}
if (keepName) { if (keepName) {
window.localStorage.setItem(DEV_ACTIVE_NAME_STORAGE_KEY, keepName); window.localStorage.setItem(DEV_ACTIVE_NAME_STORAGE_KEY, keepName);
} }
@ -221,6 +344,10 @@ export function TokenSwitcher({
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
safeClearStorage(resolvedTitle, resolvedEmoji); safeClearStorage(resolvedTitle, resolvedEmoji);
const targetLocale =
activeLocale && isLocale(activeLocale) ? activeLocale : "fa";
persistLocale(targetLocale);
setCachedMarriageEntryPath(null); setCachedMarriageEntryPath(null);
setClientCookie(TOKEN_COOKIE_NAME, cleanToken); setClientCookie(TOKEN_COOKIE_NAME, cleanToken);
setClientCookie("habib_token", cleanToken); setClientCookie("habib_token", cleanToken);
@ -235,7 +362,7 @@ export function TokenSwitcher({
setActiveStoredEmoji(resolvedEmoji); setActiveStoredEmoji(resolvedEmoji);
setIsOpen(false); setIsOpen(false);
window.location.replace("/");
window.location.replace(`/${targetLocale}`);
} }
}; };
@ -368,14 +495,14 @@ export function TokenSwitcher({
return ( return (
<> <>
{/* Floating Badge (وقتی بسته است فقط نام حساب و ایموجی را نمایش می‌دهد) */}
{/* Floating Badge (وقتی بسته است نام حساب، ایموجی و زبان فعال SSR را نمایش می‌دهد) */}
<button <button
type="button" type="button"
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
aria-label="تغییر توکن کاربر (تست)"
title="تغییر توکن کاربر (محیط توسعه)"
aria-label="تغییر توکن کاربر و زبان (تست)"
title="تغییر توکن کاربر و زبان مریج (محیط توسعه)"
className={[ 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" variant === "transparent"
? "bg-black/40 text-white hover:bg-black/50" ? "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", : "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" /> <Ic name="switchAccount" className="size-5 text-rose-500 shrink-0" />
<span className="truncate">{getFloatingButtonLabel()}</span> <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> </button>
{isOpen && ( {isOpen && (
@ -413,10 +544,10 @@ export function TokenSwitcher({
</div> </div>
<div> <div>
<h3 className="font-extrabold text-slate-900 dark:text-white text-base leading-snug"> <h3 className="font-extrabold text-slate-900 dark:text-white text-base leading-snug">
انتخاب حساب کاربری (تست)
پنل توسعه مریج (اکانت و زبان)
</h3> </h3>
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="text-xs text-slate-500 dark:text-slate-400">
تغییر سریع پروفایل، نام‌گذاری و مدیریت توکن‌ها
مدیریت توکن احراز هویت، زبان فعال SSR و تست پروفایل‌ها
</p> </p>
</div> </div>
</div> </div>
@ -431,6 +562,70 @@ export function TokenSwitcher({
{/* Scrollable Content */} {/* Scrollable Content */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4 min-h-0"> <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 */} {/* Predefined System Accounts */}
<div className="space-y-2"> <div className="space-y-2">
<div className="text-[11px] font-bold text-slate-400 dark:text-slate-500 px-1"> <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, getScopedMarriageProfile,
saveScopedMarriageProfile, saveScopedMarriageProfile,
} from "@/lib/user-scoped-storage"; } from "@/lib/user-scoped-storage";
import { getClientCookie } from "@/lib/cookies";
import { isLocale } from "@/translations/config";
import type { QueryOptions } from "./options"; import type { QueryOptions } from "./options";
import { marriageQueryKeys } from "./query-keys"; import { marriageQueryKeys } from "./query-keys";
import type { MarriageProfileResponse } from "./types"; import type { MarriageProfileResponse } from "./types";
@ -57,11 +59,31 @@ export function getInitialMarriageProfile(
} }
export async function getMarriageProfile() { 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 { 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; 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("مكان الميلاد");
});
});

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

@ -104,50 +104,168 @@ export function titleFromKey(key: string): string {
.replace(/\b\w/g, (letter) => letter.toUpperCase()); .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( export function formatFieldLabel(
field: MarriageField, field: MarriageField,
dictionary?: Record<string, string>, dictionary?: Record<string, string>,
): string { ): string {
const englishTitle = titleFromKey(field.key); const englishTitle = titleFromKey(field.key);
const rawLabel = (field.label || "").trim(); const rawLabel = (field.label || "").trim();
const isEnglish = isEnglishDictionary(dictionary);
// 1. Direct dictionary match for field.label
if (rawLabel && dictionary?.[rawLabel]) {
// 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]; return dictionary[rawLabel];
} }
// 2. If rawLabel is in Persian, convert it to its canonical English key via reverse map
const enKeyFromFa = rawLabel ? persianToEnglishMap[rawLabel] : null;
// 2. If rawLabel is in Persian reverse map, lookup translation in dictionary
const enKeyFromFa = persianToEnglishMap[rawLabel];
if (enKeyFromFa) { if (enKeyFromFa) {
if (dictionary?.[enKeyFromFa]) { if (dictionary?.[enKeyFromFa]) {
return dictionary[enKeyFromFa]; return dictionary[enKeyFromFa];
} }
if (isEnglish) {
return enKeyFromFa; return enKeyFromFa;
} }
}
// 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;
}
// 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];
}
}
// 3. Direct match for English title derived from key
if (dictionary?.[englishTitle]) { if (dictionary?.[englishTitle]) {
return 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]; const matchedCanonicalKey = normalizedEnglishKeys[normKey];
if (matchedCanonicalKey) { if (matchedCanonicalKey) {
if (dictionary?.[matchedCanonicalKey]) { if (dictionary?.[matchedCanonicalKey]) {
return dictionary[matchedCanonicalKey]; return dictionary[matchedCanonicalKey];
} }
if (isEnglish) {
return 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;
} }
// 6. Fallback
return rawLabel || englishTitle;
return englishTitle || field.key;
} }
export function formatOptionValue( export function formatOptionValue(

Loading…
Cancel
Save