Browse Source

feat: implement marriage case contact and outcome management flows with related UI and translations

front-test-2
ghorbani 3 weeks ago
parent
commit
2f17a0f367
  1. 104
      src/app/candidate-contact/page.tsx
  2. 122
      src/app/questions-list/[slug]/question-detail-client.tsx
  3. 8
      src/app/request-accepted/page.tsx
  4. 217
      src/components/Componentes/outcome-selection-sheet.tsx
  5. 23
      src/components/Componentes/question-answer-storage.tsx
  6. 6
      src/components/Componentes/question-renderer.tsx
  7. 10
      src/components/Componentes/question-text.tsx
  8. 20
      src/data/question-data.ts
  9. 2
      src/hooks/marriage/types.ts
  10. 44
      src/hooks/marriage/use-contact-status.ts
  11. 31
      src/hooks/marriage/use-section-data.ts
  12. 4
      src/lib/get-submit-path.ts
  13. 9
      src/translations/locales/en.json
  14. 9
      src/translations/locales/fa.json

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

@ -6,11 +6,15 @@ import { useEffect, useState } from "react";
import { DotsLoader } from "@/components/Componentes/button"; import { DotsLoader } from "@/components/Componentes/button";
import CallResultSheet from "@/components/Componentes/call-result-sheet"; import CallResultSheet from "@/components/Componentes/call-result-sheet";
import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet"; import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet";
import OutcomeSelectionSheet from "@/components/Componentes/outcome-selection-sheet";
import PageHeader from "@/components/Componentes/page-header"; import PageHeader from "@/components/Componentes/page-header";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import { PageBackground } from "@/components/Componentes/page-background"; import { PageBackground } from "@/components/Componentes/page-background";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useSubmitMarriageContactStatusMutation } from "@/hooks/marriage/use-contact-status";
import {
useSubmitMarriageContactStatusMutation,
useSubmitMarriageOutcomeMutation,
} from "@/hooks/marriage/use-contact-status";
import { getSubmitPath } from "@/lib/get-submit-path"; import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
@ -24,6 +28,7 @@ const advisorAvatars = [
export default function CandidateContactPage() { export default function CandidateContactPage() {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const router = useRouter(); const router = useRouter();
const [isOutcomeSheetOpen, setIsOutcomeSheetOpen] = useState(false);
const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false); const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false);
const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] = const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] =
useState(false); useState(false);
@ -43,15 +48,16 @@ export default function CandidateContactPage() {
}, [profile, router, locale]); }, [profile, router, locale]);
const caseId = profile?.active_case?.case_id; const caseId = profile?.active_case?.case_id;
const caseStatus = profile?.active_case?.status;
const isFemale = profile?.gender === "female"; const isFemale = profile?.gender === "female";
const isFinalized = caseStatus === "finalized" || profile?.status === "matched";
const contactStatusMutation = useSubmitMarriageContactStatusMutation( const contactStatusMutation = useSubmitMarriageContactStatusMutation(
caseId ?? "", caseId ?? "",
{
onSuccess: () => {
router.push(localizePath("/finding-match", locale));
},
},
);
const outcomeMutation = useSubmitMarriageOutcomeMutation(
caseId ?? "",
); );
const handleNoContactReport = async () => { const handleNoContactReport = async () => {
@ -75,21 +81,44 @@ export default function CandidateContactPage() {
// If female, render the beautiful, customized layout matching the design // If female, render the beautiful, customized layout matching the design
if (isFemale) { if (isFemale) {
if (isFinalized) {
return ( return (
<> <>
<PageBackground /> <PageBackground />
<main className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(16px,calc(var(--safe-top)+8px))] pb-[calc(20px+var(--safe-bottom))] justify-center items-center">
<section className="flex flex-col items-center max-w-[320px] text-center">
<div className="relative flex items-center justify-center text-[70px] animate-bounce">
🎉
</div>
<h1 className="mt-8 text-center text-[24px] leading-[1.25] font-bold text-[#E03950]">
{t.candidateContact.congratsTitle}
</h1>
<p className="mt-6 text-[#4A4A4A] group-14 font-medium leading-relaxed">
{t.candidateContact.congratsMessage}
</p>
</section>
</main>
</>
);
}
{isCallResultSheetOpen ? (
<CallResultSheet
onClose={() => setIsCallResultSheetOpen(false)}
onOtherReasonsClick={() => setIsDismissReasonSheetOpen(true)}
onSubmit={async (value) => {
return (
<>
<PageBackground />
{isOutcomeSheetOpen ? (
<OutcomeSelectionSheet
onClose={() => setIsOutcomeSheetOpen(false)}
onSubmit={async (status) => {
if (status === "success") {
if (caseId) { if (caseId) {
await contactStatusMutation.mutateAsync({
action: "contacted",
custom_note: value,
await outcomeMutation.mutateAsync({
status: "success",
}); });
} }
} else {
setIsDismissReasonSheetOpen(true);
}
}} }}
/> />
) : null} ) : null}
@ -99,8 +128,8 @@ export default function CandidateContactPage() {
onClose={() => setIsDismissReasonSheetOpen(false)} onClose={() => setIsDismissReasonSheetOpen(false)}
onSubmit={async (value) => { onSubmit={async (value) => {
if (caseId) { if (caseId) {
await contactStatusMutation.mutateAsync({
action: "contacted",
await outcomeMutation.mutateAsync({
status: "failure",
custom_note: value, custom_note: value,
}); });
} }
@ -129,6 +158,30 @@ export default function CandidateContactPage() {
{t.candidateContact.title} {t.candidateContact.title}
</h1> </h1>
{caseStatus === "contacted" ? (
<>
{/* Outcome instructions and button */}
<div className="w-full border border-[#ECECEC] bg-white rounded-[15px] mt-8 px-4 py-4 text-center shadow-sm">
<p className="text-[#555555] group-14 font-medium leading-relaxed">
{t.candidateContact.outcomeGuide}
</p>
</div>
<button
type="button"
onClick={() => setIsOutcomeSheetOpen(true)}
disabled={outcomeMutation.isPending}
className="mt-6 w-full h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50"
>
{outcomeMutation.isPending ? (
<DotsLoader className="text-white" />
) : (
t.candidateContact.submitOutcome
)}
</button>
</>
) : (
<>
{/* Action Buttons Row */} {/* Action Buttons Row */}
<div className="flex mt-8 w-full gap-3"> <div className="flex mt-8 w-full gap-3">
<button <button
@ -146,10 +199,21 @@ export default function CandidateContactPage() {
<button <button
type="button" type="button"
onClick={() => setIsCallResultSheetOpen(true)}
className="flex-1 h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95"
onClick={async () => {
if (caseId) {
await contactStatusMutation.mutateAsync({
action: "contacted",
});
}
}}
disabled={contactStatusMutation.isPending}
className="flex-1 h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50"
> >
{t.candidateContact.contacted}
{contactStatusMutation.isPending ? (
<DotsLoader className="text-white" />
) : (
t.candidateContact.contacted
)}
</button> </button>
</div> </div>
@ -159,6 +223,8 @@ export default function CandidateContactPage() {
{t.candidateContact.contactWarning} {t.candidateContact.contactWarning}
</p> </p>
</div> </div>
</>
)}
</section> </section>
<div className="space-y-4"> <div className="space-y-4">

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

@ -172,9 +172,9 @@ function QuestionFlowWrapper({
const dynamicQuestions = useMemo(() => { const dynamicQuestions = useMemo(() => {
// Find employment status question by title or by choices length (10) // Find employment status question by title or by choices length (10)
const employmentQuestionIndex = visibleQuestions.findIndex((q) => { const employmentQuestionIndex = visibleQuestions.findIndex((q) => {
const enTitle = q.englishTitle || q.title;
return ( return (
q.title === "Employment Status" ||
q.title === "وضعیت اشتغال" ||
enTitle === "Employment Status" ||
(q.type === "dropdown" && q.extras.options?.length === 10) (q.type === "dropdown" && q.extras.options?.length === 10)
); );
}); });
@ -191,10 +191,8 @@ function QuestionFlowWrapper({
// Find Parents' Survival Status question index and check selected index // Find Parents' Survival Status question index and check selected index
const survivalStatusQuestionIndex = visibleQuestions.findIndex((q) => { const survivalStatusQuestionIndex = visibleQuestions.findIndex((q) => {
return (
q.title === "Parents' Survival Status" ||
q.title === "وضعیت حیات والدین"
);
const enTitle = q.englishTitle || q.title;
return enTitle === "Parents' Survival Status";
}); });
let survivalSelectedOptionIndex = -1; let survivalSelectedOptionIndex = -1;
@ -209,9 +207,8 @@ function QuestionFlowWrapper({
// Find Parents' Marital Status question index and check selected index // Find Parents' Marital Status question index and check selected index
const maritalStatusQuestionIndex = visibleQuestions.findIndex((q) => { const maritalStatusQuestionIndex = visibleQuestions.findIndex((q) => {
return (
q.title === "Parents' Marital Status" || q.title === "وضعیت تأهل والدین"
);
const enTitle = q.englishTitle || q.title;
return enTitle === "Parents' Marital Status";
}); });
let isCircumstancesSelected = false; let isCircumstancesSelected = false;
@ -227,9 +224,8 @@ function QuestionFlowWrapper({
// Find Current Marital Status question index and check selected index // Find Current Marital Status question index and check selected index
const currentMaritalQuestionIndex = visibleQuestions.findIndex((q) => { const currentMaritalQuestionIndex = visibleQuestions.findIndex((q) => {
return (
q.title === "Current Marital Status" || q.title === "وضعیت تأهل فعلی"
);
const enTitle = q.englishTitle || q.title;
return enTitle === "Current Marital Status";
}); });
let maritalSelectedIndex = -1; let maritalSelectedIndex = -1;
@ -244,10 +240,8 @@ function QuestionFlowWrapper({
// Find Children and Guardianship Status question index // Find Children and Guardianship Status question index
const custodyQuestionIndex = visibleQuestions.findIndex((q) => { const custodyQuestionIndex = visibleQuestions.findIndex((q) => {
return (
q.title === "Children and Guardianship Status" ||
q.title === "وضعیت فرزند و تکفل"
);
const enTitle = q.englishTitle || q.title;
return enTitle === "Children and Guardianship Status";
}); });
let hasChildrenSelected = false; let hasChildrenSelected = false;
@ -258,46 +252,41 @@ function QuestionFlowWrapper({
const ans = getAnswerValue(custodyQuestion, custodyQuestionIndex); const ans = getAnswerValue(custodyQuestion, custodyQuestionIndex);
if (ans) { if (ans) {
const ansList = Array.isArray(ans) ? ans.map(String) : [String(ans)]; const ansList = Array.isArray(ans) ? ans.map(String) : [String(ans)];
hasChildrenSelected = ansList.some(
(val) => val.includes("Have children") || val.includes("فرزند دارم"),
const selectedIndices = ansList.map((val) =>
custodyQuestion.extras.options?.indexOf(val),
);
hasChildrenSelected = selectedIndices.some(
(idx) => idx === 1 || idx === 2,
); );
hasAnyGuardianshipSelected = ansList.some(
(val) =>
val.includes("Have children") ||
val.includes("فرزند دارم") ||
val.includes("under my guardianship") ||
val.includes("تحت تکفل"),
hasAnyGuardianshipSelected = selectedIndices.some(
(idx) => idx === 2 || idx === 3,
); );
} }
} }
const filtered = visibleQuestions.filter((question, index) => { const filtered = visibleQuestions.filter((question, index) => {
const enTitle = question.englishTitle || question.title;
// Check if this is one of the marital status/children questions // Check if this is one of the marital status/children questions
if (currentMaritalQuestionIndex !== -1) { if (currentMaritalQuestionIndex !== -1) {
const isDuration = const isDuration =
index === currentMaritalQuestionIndex + 1 || index === currentMaritalQuestionIndex + 1 ||
question.title === "Previous Marriage Duration" ||
question.title === "مدت ازدواج یا عقد قبلی";
enTitle === "Previous Marriage Duration";
const isSeparation = const isSeparation =
index === currentMaritalQuestionIndex + 2 || index === currentMaritalQuestionIndex + 2 ||
question.title === "Reason for Separation" ||
question.title === "علت جدایی، در صورت وجود";
enTitle === "Reason for Separation";
const isCustody = const isCustody =
index === currentMaritalQuestionIndex + 3 || index === currentMaritalQuestionIndex + 3 ||
question.title === "Children and Guardianship Status" ||
question.title === "وضعیت فرزند و تکفل";
enTitle === "Children and Guardianship Status";
const isChildrenCount = const isChildrenCount =
index === currentMaritalQuestionIndex + 4 || index === currentMaritalQuestionIndex + 4 ||
question.title === "Number of Children" ||
question.title === "تعداد فرزندان";
enTitle === "Number of Children";
const isChildrenExplanation = const isChildrenExplanation =
index === currentMaritalQuestionIndex + 5 || index === currentMaritalQuestionIndex + 5 ||
question.title === "Short Children/Guardianship Explanation" ||
question.title === "توضیح کوتاه درباره شرایط فرزند یا تکفل";
enTitle === "Short Children/Guardianship Explanation";
if (isDuration) { if (isDuration) {
return [1, 2, 3].includes(maritalSelectedIndex); return [1, 2, 3].includes(maritalSelectedIndex);
@ -321,19 +310,13 @@ function QuestionFlowWrapper({
// Check if this is one of the three job-related questions // Check if this is one of the three job-related questions
if (employmentQuestionIndex !== -1) { if (employmentQuestionIndex !== -1) {
const isJobTitle = const isJobTitle =
index === employmentQuestionIndex + 1 ||
question.title === "Job Title" ||
question.title === "عنوان شغلی";
index === employmentQuestionIndex + 1 || enTitle === "Job Title";
const isWorkLocation = const isWorkLocation =
index === employmentQuestionIndex + 2 ||
question.title === "Work Location" ||
question.title === "محل فعالیت";
index === employmentQuestionIndex + 2 || enTitle === "Work Location";
const isMonthlyIncome = const isMonthlyIncome =
index === employmentQuestionIndex + 3 ||
question.title === "Monthly Income" ||
question.title === "میزان درآمد ماهانه";
index === employmentQuestionIndex + 3 || enTitle === "Monthly Income";
if (isJobTitle || isWorkLocation || isMonthlyIncome) { if (isJobTitle || isWorkLocation || isMonthlyIncome) {
// If no employment status is selected yet, hide them by default // If no employment status is selected yet, hide them by default
@ -371,9 +354,7 @@ function QuestionFlowWrapper({
// Check Parents' Survival Status to decide if Parents' Marital Status is visible // Check Parents' Survival Status to decide if Parents' Marital Status is visible
if (survivalStatusQuestionIndex !== -1) { if (survivalStatusQuestionIndex !== -1) {
const isParentsMaritalStatus =
question.title === "Parents' Marital Status" ||
question.title === "وضعیت تأهل والدین";
const isParentsMaritalStatus = enTitle === "Parents' Marital Status";
if (isParentsMaritalStatus) { if (isParentsMaritalStatus) {
return survivalSelectedOptionIndex === 0; return survivalSelectedOptionIndex === 0;
@ -404,10 +385,8 @@ function QuestionFlowWrapper({
}); });
return filtered.map((question) => { return filtered.map((question) => {
if (
question.title === "Short Family Description" ||
question.title === "توضیح کوتاه درباره خانواده"
) {
const enTitle = question.englishTitle || question.title;
if (enTitle === "Short Family Description") {
return { return {
...question, ...question,
required: isCircumstancesSelected, required: isCircumstancesSelected,
@ -415,30 +394,17 @@ function QuestionFlowWrapper({
} }
if ( if (
question.title === "Previous Marriage Duration" ||
question.title === "مدت ازدواج یا عقد قبلی" ||
question.title === "Number of Children" ||
question.title === "تعداد فرزندان" ||
question.title === "Short Children/Guardianship Explanation" ||
question.title === "توضیح کوتاه درباره شرایط فرزند یا تکفل" ||
question.title === "Additional details about family responsibility" ||
question.title === "توضیحات تکمیلی درباره مسئولیت خانوادگی" ||
question.title === "Do the supported individual(s) live with you?" ||
question.title === "آیا فرد یا افراد تحت حمایت با شما زندگی میکنند؟" ||
question.title === "What is the custody status of your child(ren)?" ||
question.title === "وضعیت حضانت فرزند یا فرزندان شما چگونه است؟" ||
question.title ===
enTitle === "Previous Marriage Duration" ||
enTitle === "Number of Children" ||
enTitle === "Short Children/Guardianship Explanation" ||
enTitle === "Additional details about family responsibility" ||
enTitle === "Do the supported individual(s) live with you?" ||
enTitle === "What is the custody status of your child(ren)?" ||
enTitle ===
"Does the custody, visitation, or relocation schedule impact your residence or immigration?" || "Does the custody, visitation, or relocation schedule impact your residence or immigration?" ||
question.title ===
"آیا برنامه حضانت، ملاقات یا جابهجایی فرزند بر محل زندگی یا امکان مهاجرت شما تأثیر میگذارد؟" ||
question.title ===
"What is the payment or receipt status of child support?" ||
question.title ===
"وضعیت پرداخت یا دریافت نفقه و حمایت مالی فرزند چگونه است؟" ||
question.title ===
"Acceptance of necessary communication between future spouse and the other parent" ||
question.title === "پذیرش ارتباط ضروری همسر آینده با والد دیگرِ فرزند" ||
question.title === "پذیرش ارتباط ضروری همسر آینده با والد دیگر فرزند"
enTitle === "What is the payment or receipt status of child support?" ||
enTitle ===
"Acceptance of necessary communication between future spouse and the other parent"
) { ) {
return { return {
...question, ...question,
@ -470,7 +436,9 @@ function QuestionFlowWrapper({
if (originalIndex === -1) { if (originalIndex === -1) {
// Spread-copied questions lose reference equality; fall back to title // Spread-copied questions lose reference equality; fall back to title
originalIndex = visibleQuestions.findIndex( originalIndex = visibleQuestions.findIndex(
(q) => q.title === question.title,
(q) =>
(q.englishTitle || q.title) ===
(question.englishTitle || question.title),
); );
} }
const answer = getAnswerValue(question, originalIndex); const answer = getAnswerValue(question, originalIndex);
@ -478,9 +446,9 @@ function QuestionFlowWrapper({
let isAnswered = hasAnswer; let isAnswered = hasAnswer;
if (hasAnswer) { if (hasAnswer) {
const isEmailQuestion =
question.title.toLowerCase().includes("email") ||
question.title.includes("ایمیل");
const isEmailQuestion = (question.englishTitle || question.title)
.toLowerCase()
.includes("email");
if (isEmailQuestion) { if (isEmailQuestion) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
isAnswered = emailRegex.test(String(answer).trim()); isAnswered = emailRegex.test(String(answer).trim());

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

@ -180,7 +180,7 @@ export default function RequestAcceptedPage() {
}); });
const titleText = isFemaleProfile const titleText = isFemaleProfile
? t.requestAccepted.titleFemale ? t.requestAccepted.titleFemale
: caseStatus === "payment_done"
: (caseStatus === "payment_done" || caseStatus === "contacted")
? t.requestAccepted.titleMalePaymentDone ? t.requestAccepted.titleMalePaymentDone
: t.requestAccepted.titleMalePaymentPending; : t.requestAccepted.titleMalePaymentPending;
const primaryActionText = isFemaleProfile const primaryActionText = isFemaleProfile
@ -188,7 +188,7 @@ export default function RequestAcceptedPage() {
: t.requestAccepted.primaryMale; : t.requestAccepted.primaryMale;
const secondaryActionText = isFemaleProfile const secondaryActionText = isFemaleProfile
? t.requestAccepted.secondaryFemale ? t.requestAccepted.secondaryFemale
: caseStatus === "payment_done"
: (caseStatus === "payment_done" || caseStatus === "contacted")
? t.requestAccepted.secondaryMalePaymentDone ? t.requestAccepted.secondaryMalePaymentDone
: t.requestAccepted.secondaryMalePaymentPending; : t.requestAccepted.secondaryMalePaymentPending;
const contactInfoPhoneItems = getContactInfoPhoneItems( const contactInfoPhoneItems = getContactInfoPhoneItems(
@ -206,7 +206,7 @@ export default function RequestAcceptedPage() {
return; return;
} }
if (caseStatus === "payment_done") {
if (caseStatus === "payment_done" || caseStatus === "contacted") {
if (!caseId) { if (!caseId) {
return; return;
} }
@ -337,7 +337,7 @@ export default function RequestAcceptedPage() {
</h1> </h1>
<p className="mt-4 max-w-[315px] group-12 leading-[1.45] font-semibold text-[#777777]"> <p className="mt-4 max-w-[315px] group-12 leading-[1.45] font-semibold text-[#777777]">
{t.requestAccepted.description}
{isFemaleProfile ? t.candidateContact.title : t.requestAccepted.description}
</p> </p>
<div className="flex mt-9 w-full justify-center gap-4"> <div className="flex mt-9 w-full justify-center gap-4">

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

@ -0,0 +1,217 @@
"use client";
import type { HTMLAttributes } from "react";
import { useEffect, useId, useState } from "react";
import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 220;
export type OutcomeSelectionSheetProps = Omit<
HTMLAttributes<HTMLDivElement>,
"title" | "onSubmit"
> & {
closeOnOutside?: boolean;
onClose?: () => void;
onSubmit?: (status: "success" | "failure") => void;
};
export function OutcomeSelectionSheet({
closeOnOutside = true,
onClose,
onSubmit,
className,
...props
}: OutcomeSelectionSheetProps) {
const { dictionary: t } = useI18n();
const groupId = useId();
const [isVisible, setIsVisible] = useState(true);
const [isEntering, setIsEntering] = useState(true);
const [isClosing, setIsClosing] = useState(false);
const [selectedStatus, setSelectedStatus] = useState<"success" | "failure">(
"success",
);
const closeSheet = () => {
if (isClosing) {
return;
}
setIsClosing(true);
};
useEffect(() => {
const frameId = window.requestAnimationFrame(() => {
setIsEntering(false);
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, []);
useEffect(() => {
if (!isVisible) {
return;
}
const previousBodyOverflow = document.body.style.overflow;
const previousHtmlOverflow = document.documentElement.style.overflow;
document.body.style.overflow = "hidden";
document.documentElement.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousBodyOverflow;
document.documentElement.style.overflow = previousHtmlOverflow;
};
}, [isVisible]);
useEffect(() => {
if (!isClosing) {
return;
}
const timeoutId = window.setTimeout(() => {
setIsVisible(false);
onClose?.();
}, EXIT_ANIMATION_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [isClosing, onClose]);
if (!isVisible) {
return null;
}
const options = [
{ value: "success" as const, label: t.candidateContact.marriageSuccess },
{ value: "failure" as const, label: t.candidateContact.marriageFailure },
];
return (
<div
className={[
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms]",
isClosing || isEntering
? "bg-[#171717]/0 opacity-0"
: "bg-[#171717]/55 opacity-100",
]
.filter(Boolean)
.join(" ")}
role="dialog"
aria-modal="true"
aria-label={t.candidateContact.outcomeTitle}
tabIndex={-1}
onClick={(event) => {
if (closeOnOutside && event.target === event.currentTarget) {
closeSheet();
}
}}
onKeyDown={(event) => {
if (
closeOnOutside &&
event.target === event.currentTarget &&
(event.key === "Escape" || event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
closeSheet();
}
}}
>
<section
{...props}
className={[
"w-full sm:max-w-[375px] rounded-t-[34px] bg-[#F9F8F8] p-3.5 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform",
isClosing || isEntering ? "translate-y-full" : "translate-y-0",
className,
]
.filter(Boolean)
.join(" ")}
>
<div className="mx-auto flex flex-col items-center">
<h2 className="text-[18px] leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]">
{t.candidateContact.outcomeTitle}
</h2>
<div
className="mt-5 w-full"
role="radiogroup"
aria-labelledby={groupId}
>
<span id={groupId} className="sr-only">
{t.candidateContact.outcomeTitle}
</span>
<div className="flex flex-col gap-[14px]">
{options.map((option) => {
const checked = selectedStatus === option.value;
return (
<label
key={option.value}
className="flex cursor-pointer items-center gap-3 rounded-[14px] bg-[#ECECEC] px-[14px] py-[18px] text-left"
>
<input
checked={checked}
className="sr-only"
name="outcome-status"
type="radio"
value={option.value}
onChange={() => {
setSelectedStatus(option.value);
}}
/>
<span
aria-hidden="true"
className={[
"flex h-6 w-6 shrink-0 items-center justify-center rounded-full border",
checked ? "border-[#F0445B]" : "border-[#9E9E9E]",
]
.filter(Boolean)
.join(" ")}
>
{checked ? (
<span className="h-3 w-3 rounded-full bg-[#F0445B]" />
) : null}
</span>
<span className="text-[16px] leading-none font-semibold text-[#262626]">
{option.label}
</span>
</label>
);
})}
</div>
</div>
<div className="mt-8 grid w-full grid-cols-[1fr_2fr] gap-3">
<button
type="button"
className="appearance-none border-0 bg-transparent p-0 text-left"
onClick={closeSheet}
>
<div className="inline-flex w-full items-center justify-center rounded-[14px] border border-[#9A9A9A] bg-[#F7F7F7] px-4 py-[18px] text-[16px] font-bold text-[#8B8B8B] shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] transition-opacity active:opacity-90">
{t.common.cancel}
</div>
</button>
<button
type="button"
className="appearance-none border-0 bg-transparent p-0 text-left"
onClick={() => {
onSubmit?.(selectedStatus);
closeSheet();
}}
>
<div className="inline-flex w-full items-center justify-center rounded-[14px] bg-[#F0445B] px-4 py-[18px] text-[16px] font-semibold text-white shadow-[0_10px_18px_rgba(240,68,91,0.28)] transition-opacity active:opacity-90">
{t.common.submit}
</div>
</button>
</div>
</div>
</section>
</div>
);
}
export default OutcomeSelectionSheet;

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

@ -1,5 +1,6 @@
"use client"; "use client";
import { useQueryClient } from "@tanstack/react-query";
import { import {
createContext, createContext,
type ReactNode, type ReactNode,
@ -13,19 +14,18 @@ import {
import type { QuestionField } from "@/data/question-data"; import type { QuestionField } from "@/data/question-data";
import { toBackendSlug } from "@/data/section-slug-map"; import { toBackendSlug } from "@/data/section-slug-map";
import { pathParam } from "@/hooks/marriage/path-param"; import { pathParam } from "@/hooks/marriage/path-param";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import type { import type {
MarriageField, MarriageField,
MarriageFieldValue, MarriageFieldValue,
MarriagePhoneFieldValue, MarriagePhoneFieldValue,
UpdateMarriageSectionDataPayload, UpdateMarriageSectionDataPayload,
} from "@/hooks/marriage/types"; } from "@/hooks/marriage/types";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { import {
useMarriageSectionDataQuery, useMarriageSectionDataQuery,
useUpdateMarriageSectionDataMutation, useUpdateMarriageSectionDataMutation,
} from "@/hooks/marriage/use-section-data"; } from "@/hooks/marriage/use-section-data";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useQueryClient } from "@tanstack/react-query";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import { getApiRequestUrl } from "@/lib/http"; import { getApiRequestUrl } from "@/lib/http";
const STORAGE_VERSION = 1; const STORAGE_VERSION = 1;
@ -96,7 +96,7 @@ function getQuestionFieldKey(question: QuestionField, questionIndex: number) {
question.originalIndex !== undefined question.originalIndex !== undefined
? question.originalIndex ? question.originalIndex
: questionIndex; : questionIndex;
return `q${index + 1}_${slugifyQuestionTitle(question.title)}`;
return `q${index + 1}_${slugifyQuestionTitle(question.englishTitle || question.title)}`;
} }
export function getQuestionAnswersStorageKey(slug: string) { export function getQuestionAnswersStorageKey(slug: string) {
@ -160,6 +160,7 @@ function createQuestionField(
label: question.title, label: question.title,
type: question.type, type: question.type,
value, value,
private: question.private,
}; };
} }
@ -320,7 +321,8 @@ export function QuestionAnswersProvider({
const canEdit = profile?.can_edit_profile !== false; const canEdit = profile?.can_edit_profile !== false;
const backendSlug = useMemo(() => toBackendSlug(slug), [slug]); const backendSlug = useMemo(() => toBackendSlug(slug), [slug]);
const { data: serverSectionData, isLoading: isLoadingData } = useMarriageSectionDataQuery(backendSlug);
const { data: serverSectionData, isLoading: isLoadingData } =
useMarriageSectionDataQuery(backendSlug);
useEffect(() => { useEffect(() => {
questionsRef.current = questions; questionsRef.current = questions;
@ -527,7 +529,7 @@ export function QuestionAnswersProvider({
window.removeEventListener("pagehide", flushWithKeepalive); window.removeEventListener("pagehide", flushWithKeepalive);
flushWithKeepalive(); flushWithKeepalive();
}; };
}, [canEdit]);
}, [canEdit, queryClient.invalidateQueries]);
const contextValue = useMemo<QuestionAnswersContextValue>( const contextValue = useMemo<QuestionAnswersContextValue>(
() => ({ () => ({
@ -538,7 +540,14 @@ export function QuestionAnswersProvider({
isLoading: isLoadingData, isLoading: isLoadingData,
setAnswerValue, setAnswerValue,
}), }),
[flushAnswers, getAnswerValue, hasPendingSync, isSaving, isLoadingData, setAnswerValue],
[
flushAnswers,
getAnswerValue,
hasPendingSync,
isSaving,
isLoadingData,
setAnswerValue,
],
); );
return ( return (

6
src/components/Componentes/question-renderer.tsx

@ -30,11 +30,9 @@ export function QuestionRenderer({
dobQuestion, dobQuestion,
dobQuestionIndex, dobQuestionIndex,
}: QuestionRendererProps) { }: QuestionRendererProps) {
const enTitle = (question.englishTitle || question.title).toLowerCase();
const compactTextHeight = const compactTextHeight =
question.title.toLowerCase().includes("email") ||
question.title.includes("ایمیل") ||
question.title.toLowerCase().includes("duration") ||
question.title.includes("مدت")
enTitle.includes("email") || enTitle.includes("duration")
? "h-[54px]" ? "h-[54px]"
: undefined; : undefined;

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

@ -26,9 +26,9 @@ export default function QuestionText({
const isMuted = value === "-"; const isMuted = value === "-";
const stringValue = String(value ?? "").trim(); const stringValue = String(value ?? "").trim();
const isEmailQuestion =
question.title.toLowerCase().includes("email") ||
question.title.includes("ایمیل");
const isEmailQuestion = (question.englishTitle || question.title)
.toLowerCase()
.includes("email");
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isValidEmail = !isEmailQuestion || emailRegex.test(stringValue); const isValidEmail = !isEmailQuestion || emailRegex.test(stringValue);
@ -40,8 +40,8 @@ export default function QuestionText({
: isValidEmail && stringValue.length > 0; : isValidEmail && stringValue.length > 0;
const isMarjaQuestion = const isMarjaQuestion =
question.title === "Marja' al-Taqlid (Religious Authority)" ||
question.title === "مرجع تقلید";
(question.englishTitle || question.title) ===
"Marja' al-Taqlid (Religious Authority)";
if (isEmailQuestion) { if (isEmailQuestion) {
return ( return (

20
src/data/question-data.ts

@ -52,6 +52,7 @@ export type QuestionField = {
showGuardianNotice?: boolean; showGuardianNotice?: boolean;
originalSlug?: string; originalSlug?: string;
originalIndex?: number; originalIndex?: number;
englishTitle?: string;
}; };
export type QuestionListItem = { export type QuestionListItem = {
@ -118,7 +119,24 @@ export function getQuestionListItems(locale: Locale = defaultLocale) {
questionsByLocale[defaultLocale] ?? questionsByLocale[defaultLocale] ??
questionsByLocale.en ?? questionsByLocale.en ??
[]; [];
const items = rawItems.map(mapQuestionListItem);
const enItems = questionsByLocale.en || [];
const mappedRawItems = rawItems.map((item) => {
const enItem = enItems.find((e) => e.slug === item.slug);
if (!enItem) return item;
return {
...item,
questions: item.questions.map((q, idx) => {
const enQ = enItem.questions[idx];
return {
...q,
englishTitle: enQ ? enQ.title : q.title,
};
}),
};
});
const items = mappedRawItems.map(mapQuestionListItem);
const fbIndex = items.findIndex((item) => item.slug === "family_background"); const fbIndex = items.findIndex((item) => item.slug === "family_background");
const mhIndex = items.findIndex( const mhIndex = items.findIndex(

2
src/hooks/marriage/types.ts

@ -23,6 +23,7 @@ export type MarriageCaseStatus =
| "payment_pending" | "payment_pending"
| "payment_done" | "payment_done"
| "finalized" | "finalized"
| "contacted"
| "dismissed"; | "dismissed";
export type MarriagePhoneFieldValue = { export type MarriagePhoneFieldValue = {
@ -43,6 +44,7 @@ export type MarriageField = {
label: string; label: string;
type: string; type: string;
value: MarriageFieldValue; value: MarriageFieldValue;
private?: boolean;
}; };
export type MarriageRecommendedPlan = { export type MarriageRecommendedPlan = {

44
src/hooks/marriage/use-contact-status.ts

@ -48,3 +48,47 @@ export function useSubmitMarriageContactStatusMutation(
}, },
}); });
} }
export type SubmitMarriageOutcomePayload = {
status: "success" | "failure";
reason_code?: string;
custom_note?: string;
};
export async function submitMarriageOutcome(
caseId: CaseId,
payload: SubmitMarriageOutcomePayload,
) {
const { data } = await http.post<MarriageCaseActionResponse>(
`/api/marriage/cases/${pathParam(caseId)}/outcome/`,
payload,
);
return data;
}
export function useSubmitMarriageOutcomeMutation(
caseId: CaseId,
options?: MutationOptions<
MarriageCaseActionResponse,
SubmitMarriageOutcomePayload
>,
) {
const queryClient = useQueryClient();
return useMutation({
...options,
mutationFn: (payload) => submitMarriageOutcome(caseId, payload),
onSuccess: async (data, variables, onMutateResult, context) => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: marriageQueryKeys.profile(),
}),
queryClient.invalidateQueries({
queryKey: marriageQueryKeys.contactInfo(caseId),
}),
]);
await options?.onSuccess?.(data, variables, onMutateResult, context);
},
});
}

31
src/hooks/marriage/use-section-data.ts

@ -30,8 +30,13 @@ function slugifyQuestionTitle(title: string) {
return slug || `field_${hashString(title)}`; return slug || `field_${hashString(title)}`;
} }
function getQuestionFieldKey(title: string, index: number) {
return `q${index + 1}_${slugifyQuestionTitle(title)}`;
function getQuestionFieldKey(
title: string,
index: number,
englishTitle?: string,
) {
const finalTitle = englishTitle || title;
return `q${index + 1}_${slugifyQuestionTitle(finalTitle)}`;
} }
function hasQuestionAnswerValue(value: unknown) { function hasQuestionAnswerValue(value: unknown) {
@ -108,18 +113,18 @@ export async function updateMarriageSectionData(
const fbKeys = new Set([ const fbKeys = new Set([
...fbQuestionsEn.map((q, idx) => ...fbQuestionsEn.map((q, idx) =>
getQuestionFieldKey(q.title, q.originalIndex ?? idx),
getQuestionFieldKey(q.title, q.originalIndex ?? idx, q.englishTitle),
), ),
...fbQuestionsFa.map((q, idx) => ...fbQuestionsFa.map((q, idx) =>
getQuestionFieldKey(q.title, q.originalIndex ?? idx),
getQuestionFieldKey(q.title, q.originalIndex ?? idx, q.englishTitle),
), ),
]); ]);
const mhKeys = new Set([ const mhKeys = new Set([
...mhQuestionsEn.map((q, idx) => ...mhQuestionsEn.map((q, idx) =>
getQuestionFieldKey(q.title, q.originalIndex ?? idx),
getQuestionFieldKey(q.title, q.originalIndex ?? idx, q.englishTitle),
), ),
...mhQuestionsFa.map((q, idx) => ...mhQuestionsFa.map((q, idx) =>
getQuestionFieldKey(q.title, q.originalIndex ?? idx),
getQuestionFieldKey(q.title, q.originalIndex ?? idx, q.englishTitle),
), ),
]); ]);
@ -138,10 +143,15 @@ export async function updateMarriageSectionData(
const fbCurrentStep = fbQuestionsEn.filter((q, idx) => { const fbCurrentStep = fbQuestionsEn.filter((q, idx) => {
if (!q.required || q.logic?.dependsOn) return false; if (!q.required || q.logic?.dependsOn) return false;
const keyEn = getQuestionFieldKey(q.title, q.originalIndex ?? idx);
const keyEn = getQuestionFieldKey(
q.title,
q.originalIndex ?? idx,
q.englishTitle,
);
const keyFa = getQuestionFieldKey( const keyFa = getQuestionFieldKey(
fbQuestionsFa[idx]?.title || q.title, fbQuestionsFa[idx]?.title || q.title,
fbQuestionsFa[idx]?.originalIndex ?? q.originalIndex ?? idx, fbQuestionsFa[idx]?.originalIndex ?? q.originalIndex ?? idx,
fbQuestionsFa[idx]?.englishTitle || q.englishTitle,
); );
const field = fbFields.find((f) => f.key === keyEn || f.key === keyFa); const field = fbFields.find((f) => f.key === keyEn || f.key === keyFa);
return field && hasQuestionAnswerValue(field.value); return field && hasQuestionAnswerValue(field.value);
@ -149,10 +159,15 @@ export async function updateMarriageSectionData(
const mhCurrentStep = mhQuestionsEn.filter((q, idx) => { const mhCurrentStep = mhQuestionsEn.filter((q, idx) => {
if (!q.required || q.logic?.dependsOn) return false; if (!q.required || q.logic?.dependsOn) return false;
const keyEn = getQuestionFieldKey(q.title, q.originalIndex ?? idx);
const keyEn = getQuestionFieldKey(
q.title,
q.originalIndex ?? idx,
q.englishTitle,
);
const keyFa = getQuestionFieldKey( const keyFa = getQuestionFieldKey(
mhQuestionsFa[idx]?.title || q.title, mhQuestionsFa[idx]?.title || q.title,
mhQuestionsFa[idx]?.originalIndex ?? q.originalIndex ?? idx, mhQuestionsFa[idx]?.originalIndex ?? q.originalIndex ?? idx,
mhQuestionsFa[idx]?.englishTitle || q.englishTitle,
); );
const field = mhFields.find((f) => f.key === keyEn || f.key === keyFa); const field = mhFields.find((f) => f.key === keyEn || f.key === keyFa);
return field && hasQuestionAnswerValue(field.value); return field && hasQuestionAnswerValue(field.value);

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

@ -18,7 +18,7 @@ export function getSubmitPath(profile: MarriageProfileResponse | undefined) {
const myAction = activeCase.my_action; const myAction = activeCase.my_action;
const isFemale = profile.gender === "female"; const isFemale = profile.gender === "female";
if (caseStatus === "payment_done" || caseStatus === "finalized") {
if (caseStatus === "payment_done" || caseStatus === "finalized" || caseStatus === "contacted") {
if (isFemale) { if (isFemale) {
return "/candidate-contact"; return "/candidate-contact";
} }
@ -60,7 +60,7 @@ export function getSubmitPath(profile: MarriageProfileResponse | undefined) {
} }
if (profile.status === "matched") { if (profile.status === "matched") {
return "/candidate-contact";
return "/request-accepted";
} }
if (profile.status === "in_case") { if (profile.status === "in_case") {

9
src/translations/locales/en.json

@ -168,7 +168,14 @@
"contacted": "contacted", "contacted": "contacted",
"noContactYet": "no contact yet ?", "noContactYet": "no contact yet ?",
"afterTwoDays": "(after 2 days)", "afterTwoDays": "(after 2 days)",
"contactWarning": "If they don't contact you within 2 days, please inform us."
"contactWarning": "If they don't contact you within 2 days, please inform us.",
"submitOutcome": "Submit Final Outcome",
"outcomeGuide": "Please report the final outcome of the proposal and communication to the system.",
"outcomeTitle": "Declare Proposal Outcome",
"marriageSuccess": "Agreed to marry (Success)",
"marriageFailure": "Did not agree (No fit)",
"congratsTitle": "Congratulations! 🎉",
"congratsMessage": "Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed."
}, },
"sheets": { "sheets": {
"informationSheet": "Information sheet", "informationSheet": "Information sheet",

9
src/translations/locales/fa.json

@ -168,7 +168,14 @@
"contacted": "تماس گرفته شد", "contacted": "تماس گرفته شد",
"noContactYet": "هنوز تماس نگرفته؟", "noContactYet": "هنوز تماس نگرفته؟",
"afterTwoDays": "(بعد از ۲ روز)", "afterTwoDays": "(بعد از ۲ روز)",
"contactWarning": "اگر ظرف ۲ روز با شما تماس نگرفتند، لطفاً به ما اطلاع دهید."
"contactWarning": "اگر ظرف ۲ روز با شما تماس نگرفتند، لطفاً به ما اطلاع دهید.",
"submitOutcome": "اعلام نتیجه نهایی خواستگاری و ارتباط",
"outcomeGuide": "لطفاً نتیجه نهایی خواستگاری و ارتباط خود را به سیستم اعلام کنید تا وضعیت پرونده شما بروزرسانی شود.",
"outcomeTitle": "اعلام نتیجه نهایی خواستگاری",
"marriageSuccess": "توافق برای ازدواج (اعلام موفقیت)",
"marriageFailure": "عدم توافق (رد کیس)",
"congratsTitle": "پیوندتان مبارک! 🎉",
"congratsMessage": "با آرزوی شادمانی، خوشبختی و سلامتی برای ادامه زندگی مشترک و زیبای شما. پرونده کاربری شما با موفقیت بسته شد."
}, },
"sheets": { "sheets": {
"informationSheet": "پنل اطلاعات", "informationSheet": "پنل اطلاعات",

Loading…
Cancel
Save