14 changed files with 3195 additions and 2842 deletions
-
387src/app/candidate-contact/candidate-contact-client.tsx
-
405src/app/candidate-contact/page.tsx
-
187src/app/finding-match/finding-match-client.tsx
-
205src/app/finding-match/page.tsx
-
30src/app/layout.tsx
-
675src/app/new-match/new-match-client.tsx
-
689src/app/new-match/page.tsx
-
796src/app/questions-list/page.tsx
-
775src/app/questions-list/questions-list-client.tsx
-
774src/app/request-accepted/page.tsx
-
760src/app/request-accepted/request-accepted-client.tsx
-
165src/app/request-sent/page.tsx
-
147src/app/request-sent/request-sent-client.tsx
-
42src/lib/ssr-fetch.ts
@ -0,0 +1,387 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import Image from "next/image"; |
||||
|
import { useRouter } from "next/navigation"; |
||||
|
import { useEffect, useMemo, useState } from "react"; |
||||
|
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot"; |
||||
|
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; |
||||
|
import CallResultSheet from "@/components/Componentes/call-result-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 AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; |
||||
|
import { PageBackground } from "@/components/Componentes/page-background"; |
||||
|
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
||||
|
import { |
||||
|
useSubmitMarriageContactStatusMutation, |
||||
|
useSubmitMarriageOutcomeMutation, |
||||
|
} from "@/hooks/marriage/use-contact-status"; |
||||
|
import { getSubmitPath } from "@/lib/get-submit-path"; |
||||
|
import { localizePath } from "@/translations/config"; |
||||
|
import { useI18n } from "@/translations/provider"; |
||||
|
|
||||
|
const advisorAvatars = [ |
||||
|
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" }, |
||||
|
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" }, |
||||
|
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" }, |
||||
|
]; |
||||
|
|
||||
|
export default function CandidateContactClient() { |
||||
|
const { dictionary: t, locale } = useI18n(); |
||||
|
const router = useRouter(); |
||||
|
const [isOutcomeSheetOpen, setIsOutcomeSheetOpen] = useState(false); |
||||
|
const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false); |
||||
|
const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] = |
||||
|
useState(false); |
||||
|
|
||||
|
const { data: profile, isLoading: isProfileLoading } = |
||||
|
useMarriageProfileQuery({ |
||||
|
refetchInterval: 3000, |
||||
|
}); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (!profile) { |
||||
|
return; |
||||
|
} |
||||
|
const targetPath = getSubmitPath(profile); |
||||
|
if (targetPath !== "/candidate-contact") { |
||||
|
router.replace(localizePath(targetPath, locale)); |
||||
|
} |
||||
|
}, [profile, router, locale]); |
||||
|
|
||||
|
// Signal Flutter to lift its loading cover once the profile is available.
|
||||
|
useEffect(() => { |
||||
|
if (profile && !isProfileLoading) { |
||||
|
window.__announceHabibWebReady?.(); |
||||
|
} |
||||
|
}, [profile, isProfileLoading]); |
||||
|
|
||||
|
const isRedirecting = useMemo(() => { |
||||
|
if (!profile) return false; |
||||
|
return getSubmitPath(profile) !== "/candidate-contact"; |
||||
|
}, [profile]); |
||||
|
|
||||
|
const caseId = profile?.active_case?.case_id; |
||||
|
const caseStatus = profile?.active_case?.status; |
||||
|
const isFemale = profile?.gender === "female"; |
||||
|
const isFinalized = |
||||
|
caseStatus === "finalized" || profile?.status === "matched"; |
||||
|
|
||||
|
const contactStatusMutation = useSubmitMarriageContactStatusMutation( |
||||
|
caseId ?? "", |
||||
|
); |
||||
|
|
||||
|
const outcomeMutation = useSubmitMarriageOutcomeMutation(caseId ?? ""); |
||||
|
|
||||
|
const handleNoContactReport = async () => { |
||||
|
if (!caseId || contactStatusMutation.isPending) return; |
||||
|
await contactStatusMutation.mutateAsync({ |
||||
|
action: "no_contact", |
||||
|
custom_note: |
||||
|
"No contact reported by female candidate after decision window", |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
if (isProfileLoading || isRedirecting) { |
||||
|
return <PageLoadingSkeleton />; |
||||
|
} |
||||
|
|
||||
|
// If female, render the beautiful, customized layout matching the design
|
||||
|
if (isFemale) { |
||||
|
return ( |
||||
|
<> |
||||
|
<PageBackground /> |
||||
|
|
||||
|
{isOutcomeSheetOpen ? ( |
||||
|
<OutcomeSelectionSheet |
||||
|
onClose={() => setIsOutcomeSheetOpen(false)} |
||||
|
onSubmit={async (status) => { |
||||
|
if (status === "success") { |
||||
|
if (caseId) { |
||||
|
await outcomeMutation.mutateAsync({ |
||||
|
status: "success", |
||||
|
}); |
||||
|
} |
||||
|
} else { |
||||
|
setIsDismissReasonSheetOpen(true); |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
) : null} |
||||
|
|
||||
|
{isDismissReasonSheetOpen ? ( |
||||
|
<DismissReasonSheet |
||||
|
onClose={() => setIsDismissReasonSheetOpen(false)} |
||||
|
onSubmit={async (value) => { |
||||
|
if (caseId) { |
||||
|
await outcomeMutation.mutateAsync({ |
||||
|
status: "failure", |
||||
|
custom_note: value, |
||||
|
}); |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
) : null} |
||||
|
|
||||
|
<main className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-[calc(20px+var(--safe-bottom))]"> |
||||
|
<PageHeader |
||||
|
className="-mx-[6px]" |
||||
|
leftButton={ |
||||
|
isFemale ? { icon: "back", className: "hidden" } : undefined |
||||
|
} |
||||
|
/> |
||||
|
|
||||
|
<div className="flex flex-1 flex-col justify-between gap-6 pt-4"> |
||||
|
<section className="flex flex-col items-center"> |
||||
|
{isFinalized ? ( |
||||
|
<div className="flex flex-col items-center max-w-[320px] text-center my-auto py-12"> |
||||
|
<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["Congratulations! 🎉"]} |
||||
|
</h1> |
||||
|
<p className="mt-6 text-[#4A4A4A] group-14 font-medium leading-relaxed"> |
||||
|
{ |
||||
|
t[ |
||||
|
"Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed." |
||||
|
] |
||||
|
} |
||||
|
</p> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<> |
||||
|
{/* Illustration section */} |
||||
|
<div className="relative mt-4 flex items-center justify-center"> |
||||
|
<Image |
||||
|
src="/assets/images/Group 159788fd0467.svg" |
||||
|
alt={t["Selected candidate contact status"]} |
||||
|
width={160} |
||||
|
height={152} |
||||
|
priority |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
{/* Title / Status message */} |
||||
|
<h1 className="mt-8 text-center text-[20px] leading-[1.25] font-bold text-[#1A1A1A] max-w-[280px]"> |
||||
|
{ |
||||
|
t[ |
||||
|
"The selected candidate will contact your family shortly." |
||||
|
] |
||||
|
} |
||||
|
</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[ |
||||
|
"Thank you for giving us feedback, we would be very happy if you also let us know the final result." |
||||
|
] |
||||
|
} |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex mt-6 w-full gap-3"> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => |
||||
|
router.push( |
||||
|
localizePath("/new-match/profile", locale), |
||||
|
) |
||||
|
} |
||||
|
className="flex-1 h-[52px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#F5F5F5]" |
||||
|
> |
||||
|
{t["View Profile"]} |
||||
|
</button> |
||||
|
|
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setIsOutcomeSheetOpen(true)} |
||||
|
disabled={outcomeMutation.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" |
||||
|
> |
||||
|
{outcomeMutation.isPending ? ( |
||||
|
<LoadingThreeDot className="text-white" /> |
||||
|
) : ( |
||||
|
t["Submit Final Outcome"] |
||||
|
)} |
||||
|
</button> |
||||
|
</div> |
||||
|
</> |
||||
|
) : ( |
||||
|
<> |
||||
|
{/* Action Buttons Row */} |
||||
|
<div className="flex mt-8 w-full gap-3"> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={handleNoContactReport} |
||||
|
disabled={contactStatusMutation.isPending} |
||||
|
className="flex-1 h-[52px] rounded-[15px] bg-[#F5F5F7] text-[#8E8E93] font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#EAEAEF] disabled:opacity-50" |
||||
|
> |
||||
|
{contactStatusMutation.isPending ? ( |
||||
|
<LoadingThreeDot className="text-[#8E8E93]" /> |
||||
|
) : ( |
||||
|
t["Report No Contact"] |
||||
|
)} |
||||
|
</button> |
||||
|
|
||||
|
<button |
||||
|
type="button" |
||||
|
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" |
||||
|
> |
||||
|
{contactStatusMutation.isPending ? ( |
||||
|
<LoadingThreeDot className="text-white" /> |
||||
|
) : ( |
||||
|
t["Confirm Contacted"] |
||||
|
)} |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
{/* Red warning box */} |
||||
|
<div className="w-full border border-[#F0445B] bg-[#FFF5F6] rounded-[15px] mt-4 px-4 py-3 text-center"> |
||||
|
<p className="text-[#F0445B] group-12 font-semibold leading-normal"> |
||||
|
{ |
||||
|
t[ |
||||
|
"To keep the process moving smoothly, the other party has a 48-hour (2-day) window to make initial contact with you or your family. If no contact is established after 2 days, you have the option to decline his request or notify us that he hasn't reached out." |
||||
|
] |
||||
|
} |
||||
|
</p> |
||||
|
</div> |
||||
|
</> |
||||
|
)} |
||||
|
</> |
||||
|
)} |
||||
|
</section> |
||||
|
|
||||
|
<div className="space-y-4 pb-20"> |
||||
|
{/* Advisor section */} |
||||
|
<AdvisorActionsCard |
||||
|
title={t["Get an advisor"]} |
||||
|
description={ |
||||
|
t[ |
||||
|
"Not sure what to do next? Our psychology section is here to guide you at every step." |
||||
|
] |
||||
|
} |
||||
|
avatars={advisorAvatars} |
||||
|
extraCount={7} |
||||
|
getAdvisorLabel={t["Get Advisor"]} |
||||
|
getAdvisorHref="/marriage-advisors" |
||||
|
/> |
||||
|
|
||||
|
{/* Profile Locked banner */} |
||||
|
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> |
||||
|
<div |
||||
|
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }} |
||||
|
className="pointer-events-auto px-[17px] pt-3" |
||||
|
> |
||||
|
<div className="flex items-center justify-center w-full gap-1 rounded-[11px] bg-[#DBDBDB] py-3.5"> |
||||
|
<Image |
||||
|
src="/assets/images/material-symbols_lock.svg" |
||||
|
width={24} |
||||
|
height={24} |
||||
|
alt="lock" |
||||
|
/> |
||||
|
<h2 className="group-14 leading-none font-semibold text-[#747474]"> |
||||
|
{t["Profile is locked"]} |
||||
|
</h2> |
||||
|
</div> |
||||
|
</div> |
||||
|
</section> |
||||
|
</div> |
||||
|
</div> |
||||
|
</main> |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Fallback UI for male/other users
|
||||
|
return ( |
||||
|
<> |
||||
|
<PageBackground /> |
||||
|
|
||||
|
{isCallResultSheetOpen ? ( |
||||
|
<CallResultSheet |
||||
|
onClose={() => setIsCallResultSheetOpen(false)} |
||||
|
onOtherReasonsClick={() => setIsDismissReasonSheetOpen(true)} |
||||
|
onSubmit={async (value) => { |
||||
|
if (caseId) { |
||||
|
await contactStatusMutation.mutateAsync({ |
||||
|
action: "contacted", |
||||
|
custom_note: value, |
||||
|
}); |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
) : null} |
||||
|
|
||||
|
{isDismissReasonSheetOpen ? ( |
||||
|
<DismissReasonSheet |
||||
|
onClose={() => setIsDismissReasonSheetOpen(false)} |
||||
|
onSubmit={async (value) => { |
||||
|
if (caseId) { |
||||
|
await contactStatusMutation.mutateAsync({ |
||||
|
action: "contacted", |
||||
|
custom_note: value, |
||||
|
}); |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
) : null} |
||||
|
|
||||
|
<main className="-mx-[17px] flex min-h-screen flex-col px-[17px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-36"> |
||||
|
<PageHeader /> |
||||
|
|
||||
|
<section className="flex flex-1 items-center justify-center"> |
||||
|
<div className="flex max-w-[300px] flex-col items-center"> |
||||
|
<Image |
||||
|
src="/assets/images/Group 1597880467.svg" |
||||
|
alt={t["Selected candidate contact status"]} |
||||
|
width={131} |
||||
|
height={125} |
||||
|
/> |
||||
|
|
||||
|
<h1 className="mt-10 text-center text-[18px] leading-[1.2] font-bold text-[#1A1A1A]"> |
||||
|
{t["The selected candidate will contact your family shortly."]} |
||||
|
</h1> |
||||
|
</div> |
||||
|
</section> |
||||
|
|
||||
|
<section |
||||
|
style={{ paddingBottom: `calc(2rem + var(--safe-bottom))` }} |
||||
|
className="fixed right-0 bottom-0 left-0 z-20 mx-auto flex w-full sm:max-w-[375px] gap-3 rounded-t-[30px] bg-white px-[17px] pt-6 shadow-[0_-18px_50px_rgba(15,23,42,0.08)]" |
||||
|
> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setIsCallResultSheetOpen(true)} |
||||
|
className="flex-1 h-[52px] rounded-[11px] bg-gradient-to-tl from-[#FE6F82] to-[#E03950] text-white font-semibold flex items-center justify-center cursor-pointer transition-opacity active:opacity-90" |
||||
|
> |
||||
|
{t["Confirm Contacted"]} |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={handleNoContactReport} |
||||
|
disabled={contactStatusMutation.isPending} |
||||
|
className="flex-1 h-[52px] rounded-[11px] border border-[#8B8B8B] bg-transparent text-[#8B8B8B] font-semibold flex items-center justify-center cursor-pointer transition-opacity active:opacity-90 disabled:opacity-50" |
||||
|
> |
||||
|
{contactStatusMutation.isPending ? ( |
||||
|
<LoadingThreeDot className="text-[#8B8B8B]" /> |
||||
|
) : ( |
||||
|
t["Report No Contact"] |
||||
|
)} |
||||
|
</button> |
||||
|
</section> |
||||
|
</main> |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
@ -1,380 +1,39 @@ |
|||||
"use client"; |
|
||||
|
|
||||
import Image from "next/image"; |
|
||||
import { useRouter } from "next/navigation"; |
|
||||
import { useEffect, useMemo, useState } from "react"; |
|
||||
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot"; |
|
||||
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; |
|
||||
import CallResultSheet from "@/components/Componentes/call-result-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 AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; |
|
||||
import { PageBackground } from "@/components/Componentes/page-background"; |
|
||||
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
|
||||
|
import { cookies } from "next/headers"; |
||||
import { |
import { |
||||
useSubmitMarriageContactStatusMutation, |
|
||||
useSubmitMarriageOutcomeMutation, |
|
||||
} from "@/hooks/marriage/use-contact-status"; |
|
||||
import { getSubmitPath } from "@/lib/get-submit-path"; |
|
||||
import { localizePath } from "@/translations/config"; |
|
||||
import { useI18n } from "@/translations/provider"; |
|
||||
|
|
||||
const advisorAvatars = [ |
|
||||
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" }, |
|
||||
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" }, |
|
||||
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" }, |
|
||||
]; |
|
||||
|
|
||||
export default function CandidateContactPage() { |
|
||||
const { dictionary: t, locale } = useI18n(); |
|
||||
const router = useRouter(); |
|
||||
const [isOutcomeSheetOpen, setIsOutcomeSheetOpen] = useState(false); |
|
||||
const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false); |
|
||||
const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] = |
|
||||
useState(false); |
|
||||
|
|
||||
const { data: profile, isLoading: isProfileLoading } = |
|
||||
useMarriageProfileQuery({ |
|
||||
refetchInterval: 3000, |
|
||||
|
dehydrate, |
||||
|
HydrationBoundary, |
||||
|
QueryClient, |
||||
|
} from "@tanstack/react-query"; |
||||
|
import { isAuthenticatedToken } from "@/lib/entry-route-cache"; |
||||
|
import { fetchProfileSSR } from "@/lib/ssr-fetch"; |
||||
|
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; |
||||
|
import CandidateContactClient from "./candidate-contact-client"; |
||||
|
|
||||
|
export const dynamic = "force-dynamic"; |
||||
|
|
||||
|
export default async function CandidateContactPage() { |
||||
|
const cookieStore = await cookies(); |
||||
|
|
||||
|
const token = |
||||
|
cookieStore.get("HABIB_TOKEN")?.value ?? |
||||
|
cookieStore.get("habib_token")?.value; |
||||
|
|
||||
|
const queryClient = new QueryClient({ |
||||
|
defaultOptions: { |
||||
|
queries: { staleTime: 10 * 1000 }, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
if (isAuthenticatedToken(token)) { |
||||
|
await queryClient.prefetchQuery({ |
||||
|
queryKey: marriageQueryKeys.profile(), |
||||
|
queryFn: () => fetchProfileSSR(token!), |
||||
}); |
}); |
||||
|
|
||||
useEffect(() => { |
|
||||
if (!profile) { |
|
||||
return; |
|
||||
} |
|
||||
const targetPath = getSubmitPath(profile); |
|
||||
if (targetPath !== "/candidate-contact") { |
|
||||
router.replace(localizePath(targetPath, locale)); |
|
||||
} |
|
||||
}, [profile, router, locale]); |
|
||||
|
|
||||
const isRedirecting = useMemo(() => { |
|
||||
if (!profile) return false; |
|
||||
return getSubmitPath(profile) !== "/candidate-contact"; |
|
||||
}, [profile]); |
|
||||
|
|
||||
const caseId = profile?.active_case?.case_id; |
|
||||
const caseStatus = profile?.active_case?.status; |
|
||||
const isFemale = profile?.gender === "female"; |
|
||||
const isFinalized = |
|
||||
caseStatus === "finalized" || profile?.status === "matched"; |
|
||||
|
|
||||
const contactStatusMutation = useSubmitMarriageContactStatusMutation( |
|
||||
caseId ?? "", |
|
||||
); |
|
||||
|
|
||||
const outcomeMutation = useSubmitMarriageOutcomeMutation(caseId ?? ""); |
|
||||
|
|
||||
const handleNoContactReport = async () => { |
|
||||
if (!caseId || contactStatusMutation.isPending) return; |
|
||||
await contactStatusMutation.mutateAsync({ |
|
||||
action: "no_contact", |
|
||||
custom_note: |
|
||||
"No contact reported by female candidate after decision window", |
|
||||
}); |
|
||||
}; |
|
||||
|
|
||||
if (isProfileLoading || isRedirecting) { |
|
||||
return <PageLoadingSkeleton />; |
|
||||
} |
} |
||||
|
|
||||
// If female, render the beautiful, customized layout matching the design
|
|
||||
if (isFemale) { |
|
||||
return ( |
|
||||
<> |
|
||||
<PageBackground /> |
|
||||
|
|
||||
{isOutcomeSheetOpen ? ( |
|
||||
<OutcomeSelectionSheet |
|
||||
onClose={() => setIsOutcomeSheetOpen(false)} |
|
||||
onSubmit={async (status) => { |
|
||||
if (status === "success") { |
|
||||
if (caseId) { |
|
||||
await outcomeMutation.mutateAsync({ |
|
||||
status: "success", |
|
||||
}); |
|
||||
} |
|
||||
} else { |
|
||||
setIsDismissReasonSheetOpen(true); |
|
||||
} |
|
||||
}} |
|
||||
/> |
|
||||
) : null} |
|
||||
|
|
||||
{isDismissReasonSheetOpen ? ( |
|
||||
<DismissReasonSheet |
|
||||
onClose={() => setIsDismissReasonSheetOpen(false)} |
|
||||
onSubmit={async (value) => { |
|
||||
if (caseId) { |
|
||||
await outcomeMutation.mutateAsync({ |
|
||||
status: "failure", |
|
||||
custom_note: value, |
|
||||
}); |
|
||||
} |
|
||||
}} |
|
||||
/> |
|
||||
) : null} |
|
||||
|
|
||||
<main className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-[calc(20px+var(--safe-bottom))]"> |
|
||||
<PageHeader |
|
||||
className="-mx-[6px]" |
|
||||
leftButton={ |
|
||||
isFemale ? { icon: "back", className: "hidden" } : undefined |
|
||||
} |
|
||||
/> |
|
||||
|
|
||||
<div className="flex flex-1 flex-col justify-between gap-6 pt-4"> |
|
||||
<section className="flex flex-col items-center"> |
|
||||
{isFinalized ? ( |
|
||||
<div className="flex flex-col items-center max-w-[320px] text-center my-auto py-12"> |
|
||||
<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["Congratulations! 🎉"]} |
|
||||
</h1> |
|
||||
<p className="mt-6 text-[#4A4A4A] group-14 font-medium leading-relaxed"> |
|
||||
{ |
|
||||
t[ |
|
||||
"Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed." |
|
||||
] |
|
||||
} |
|
||||
</p> |
|
||||
</div> |
|
||||
) : ( |
|
||||
<> |
|
||||
{/* Illustration section */} |
|
||||
<div className="relative mt-4 flex items-center justify-center"> |
|
||||
<Image |
|
||||
src="/assets/images/Group 159788fd0467.svg" |
|
||||
alt={t["Selected candidate contact status"]} |
|
||||
width={160} |
|
||||
height={152} |
|
||||
priority |
|
||||
/> |
|
||||
</div> |
|
||||
|
|
||||
{/* Title / Status message */} |
|
||||
<h1 className="mt-8 text-center text-[20px] leading-[1.25] font-bold text-[#1A1A1A] max-w-[280px]"> |
|
||||
{ |
|
||||
t[ |
|
||||
"The selected candidate will contact your family shortly." |
|
||||
] |
|
||||
} |
|
||||
</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[ |
|
||||
"Thank you for giving us feedback, we would be very happy if you also let us know the final result." |
|
||||
] |
|
||||
} |
|
||||
</p> |
|
||||
</div> |
|
||||
|
|
||||
<div className="flex mt-6 w-full gap-3"> |
|
||||
<button |
|
||||
type="button" |
|
||||
onClick={() => |
|
||||
router.push( |
|
||||
localizePath("/new-match/profile", locale), |
|
||||
) |
|
||||
} |
|
||||
className="flex-1 h-[52px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#F5F5F5]" |
|
||||
> |
|
||||
{t["View Profile"]} |
|
||||
</button> |
|
||||
|
|
||||
<button |
|
||||
type="button" |
|
||||
onClick={() => setIsOutcomeSheetOpen(true)} |
|
||||
disabled={outcomeMutation.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" |
|
||||
> |
|
||||
{outcomeMutation.isPending ? ( |
|
||||
<LoadingThreeDot className="text-white" /> |
|
||||
) : ( |
|
||||
t["Submit Final Outcome"] |
|
||||
)} |
|
||||
</button> |
|
||||
</div> |
|
||||
</> |
|
||||
) : ( |
|
||||
<> |
|
||||
{/* Action Buttons Row */} |
|
||||
<div className="flex mt-8 w-full gap-3"> |
|
||||
<button |
|
||||
type="button" |
|
||||
onClick={handleNoContactReport} |
|
||||
disabled={contactStatusMutation.isPending} |
|
||||
className="flex-1 h-[52px] rounded-[15px] bg-[#F5F5F7] text-[#8E8E93] font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#EAEAEF] disabled:opacity-50" |
|
||||
> |
|
||||
{contactStatusMutation.isPending ? ( |
|
||||
<LoadingThreeDot className="text-[#8E8E93]" /> |
|
||||
) : ( |
|
||||
t["Report No Contact"] |
|
||||
)} |
|
||||
</button> |
|
||||
|
|
||||
<button |
|
||||
type="button" |
|
||||
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" |
|
||||
> |
|
||||
{contactStatusMutation.isPending ? ( |
|
||||
<LoadingThreeDot className="text-white" /> |
|
||||
) : ( |
|
||||
t["Confirm Contacted"] |
|
||||
)} |
|
||||
</button> |
|
||||
</div> |
|
||||
|
|
||||
{/* Red warning box */} |
|
||||
<div className="w-full border border-[#F0445B] bg-[#FFF5F6] rounded-[15px] mt-4 px-4 py-3 text-center"> |
|
||||
<p className="text-[#F0445B] group-12 font-semibold leading-normal"> |
|
||||
{ |
|
||||
t[ |
|
||||
"To keep the process moving smoothly, the other party has a 48-hour (2-day) window to make initial contact with you or your family. If no contact is established after 2 days, you have the option to decline his request or notify us that he hasn't reached out." |
|
||||
] |
|
||||
} |
|
||||
</p> |
|
||||
</div> |
|
||||
</> |
|
||||
)} |
|
||||
</> |
|
||||
)} |
|
||||
</section> |
|
||||
|
|
||||
<div className="space-y-4 pb-20"> |
|
||||
{/* Advisor section */} |
|
||||
<AdvisorActionsCard |
|
||||
title={t["Get an advisor"]} |
|
||||
description={ |
|
||||
t[ |
|
||||
"Not sure what to do next? Our psychology section is here to guide you at every step." |
|
||||
] |
|
||||
} |
|
||||
avatars={advisorAvatars} |
|
||||
extraCount={7} |
|
||||
getAdvisorLabel={t["Get Advisor"]} |
|
||||
getAdvisorHref="/marriage-advisors" |
|
||||
/> |
|
||||
|
|
||||
{/* Profile Locked banner */} |
|
||||
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> |
|
||||
<div |
|
||||
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }} |
|
||||
className="pointer-events-auto px-[17px] pt-3" |
|
||||
> |
|
||||
<div className="flex items-center justify-center w-full gap-1 rounded-[11px] bg-[#DBDBDB] py-3.5"> |
|
||||
<Image |
|
||||
src="/assets/images/material-symbols_lock.svg" |
|
||||
width={24} |
|
||||
height={24} |
|
||||
alt="lock" |
|
||||
/> |
|
||||
<h2 className="group-14 leading-none font-semibold text-[#747474]"> |
|
||||
{t["Profile is locked"]} |
|
||||
</h2> |
|
||||
</div> |
|
||||
</div> |
|
||||
</section> |
|
||||
</div> |
|
||||
</div> |
|
||||
</main> |
|
||||
</> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// Fallback UI for male/other users
|
|
||||
return ( |
return ( |
||||
<> |
|
||||
<PageBackground /> |
|
||||
|
|
||||
{isCallResultSheetOpen ? ( |
|
||||
<CallResultSheet |
|
||||
onClose={() => setIsCallResultSheetOpen(false)} |
|
||||
onOtherReasonsClick={() => setIsDismissReasonSheetOpen(true)} |
|
||||
onSubmit={async (value) => { |
|
||||
if (caseId) { |
|
||||
await contactStatusMutation.mutateAsync({ |
|
||||
action: "contacted", |
|
||||
custom_note: value, |
|
||||
}); |
|
||||
} |
|
||||
}} |
|
||||
/> |
|
||||
) : null} |
|
||||
|
|
||||
{isDismissReasonSheetOpen ? ( |
|
||||
<DismissReasonSheet |
|
||||
onClose={() => setIsDismissReasonSheetOpen(false)} |
|
||||
onSubmit={async (value) => { |
|
||||
if (caseId) { |
|
||||
await contactStatusMutation.mutateAsync({ |
|
||||
action: "contacted", |
|
||||
custom_note: value, |
|
||||
}); |
|
||||
} |
|
||||
}} |
|
||||
/> |
|
||||
) : null} |
|
||||
|
|
||||
<main className="-mx-[17px] flex min-h-screen flex-col px-[17px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-36"> |
|
||||
<PageHeader /> |
|
||||
|
|
||||
<section className="flex flex-1 items-center justify-center"> |
|
||||
<div className="flex max-w-[300px] flex-col items-center"> |
|
||||
<Image |
|
||||
src="/assets/images/Group 1597880467.svg" |
|
||||
alt={t["Selected candidate contact status"]} |
|
||||
width={131} |
|
||||
height={125} |
|
||||
/> |
|
||||
|
|
||||
<h1 className="mt-10 text-center text-[18px] leading-[1.2] font-bold text-[#1A1A1A]"> |
|
||||
{t["The selected candidate will contact your family shortly."]} |
|
||||
</h1> |
|
||||
</div> |
|
||||
</section> |
|
||||
|
|
||||
<section |
|
||||
style={{ paddingBottom: `calc(2rem + var(--safe-bottom))` }} |
|
||||
className="fixed right-0 bottom-0 left-0 z-20 mx-auto flex w-full sm:max-w-[375px] gap-3 rounded-t-[30px] bg-white px-[17px] pt-6 shadow-[0_-18px_50px_rgba(15,23,42,0.08)]" |
|
||||
> |
|
||||
<button |
|
||||
type="button" |
|
||||
onClick={() => setIsCallResultSheetOpen(true)} |
|
||||
className="flex-1 h-[52px] rounded-[11px] bg-gradient-to-tl from-[#FE6F82] to-[#E03950] text-white font-semibold flex items-center justify-center cursor-pointer transition-opacity active:opacity-90" |
|
||||
> |
|
||||
{t["Confirm Contacted"]} |
|
||||
</button> |
|
||||
<button |
|
||||
type="button" |
|
||||
onClick={handleNoContactReport} |
|
||||
disabled={contactStatusMutation.isPending} |
|
||||
className="flex-1 h-[52px] rounded-[11px] border border-[#8B8B8B] bg-transparent text-[#8B8B8B] font-semibold flex items-center justify-center cursor-pointer transition-opacity active:opacity-90 disabled:opacity-50" |
|
||||
> |
|
||||
{contactStatusMutation.isPending ? ( |
|
||||
<LoadingThreeDot className="text-[#8B8B8B]" /> |
|
||||
) : ( |
|
||||
t["Report No Contact"] |
|
||||
)} |
|
||||
</button> |
|
||||
</section> |
|
||||
</main> |
|
||||
</> |
|
||||
|
<HydrationBoundary state={dehydrate(queryClient)}> |
||||
|
<CandidateContactClient /> |
||||
|
</HydrationBoundary> |
||||
); |
); |
||||
} |
} |
||||
@ -0,0 +1,187 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import Image from "next/image"; |
||||
|
import { useRouter } from "next/navigation"; |
||||
|
import { useEffect, useMemo } from "react"; |
||||
|
import { FaLock, FaPen } from "react-icons/fa6"; |
||||
|
import { IoAlertCircle } from "react-icons/io5"; |
||||
|
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; |
||||
|
import Button from "@/components/Componentes/button"; |
||||
|
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; |
||||
|
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; |
||||
|
import { PageBackground } from "@/components/Componentes/page-background"; |
||||
|
import PageHeader from "@/components/Componentes/page-header"; |
||||
|
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
||||
|
import { useRejectionSeenMutation } from "@/hooks/marriage/use-rejection-seen"; |
||||
|
import { getSubmitPath } from "@/lib/get-submit-path"; |
||||
|
import { localizePath } from "@/translations/config"; |
||||
|
import { useI18n } from "@/translations/provider"; |
||||
|
|
||||
|
const advisorAvatars = [ |
||||
|
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" }, |
||||
|
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" }, |
||||
|
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" }, |
||||
|
]; |
||||
|
|
||||
|
export default function FindingMatchClient() { |
||||
|
const router = useRouter(); |
||||
|
const { dictionary: t, locale } = useI18n(); |
||||
|
const { data: profile, isLoading } = useMarriageProfileQuery({ |
||||
|
refetchInterval: 3000, |
||||
|
}); |
||||
|
|
||||
|
const { mutate: markRejectionSeen, isPending: isMarkingSeen } = |
||||
|
useRejectionSeenMutation(); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (!profile) { |
||||
|
return; |
||||
|
} |
||||
|
const targetPath = getSubmitPath(profile); |
||||
|
if (targetPath !== "/finding-match") { |
||||
|
router.replace(localizePath(targetPath, locale)); |
||||
|
} |
||||
|
}, [profile, locale, router]); |
||||
|
|
||||
|
// Signal Flutter to lift its loading cover once the profile is available.
|
||||
|
useEffect(() => { |
||||
|
if (profile && !isLoading) { |
||||
|
window.__announceHabibWebReady?.(); |
||||
|
} |
||||
|
}, [profile, isLoading]); |
||||
|
|
||||
|
const isRedirecting = useMemo(() => { |
||||
|
if (!profile) return false; |
||||
|
return getSubmitPath(profile) !== "/finding-match"; |
||||
|
}, [profile]); |
||||
|
|
||||
|
if (isLoading || isRedirecting) { |
||||
|
return <PageLoadingSkeleton />; |
||||
|
} |
||||
|
|
||||
|
const copy = { |
||||
|
title: t["SEARCH IN PROGRESS"], |
||||
|
description: |
||||
|
t[ |
||||
|
"Our system is actively looking for compatible partners based on your criteria. This process requires time and patience. We will notify you immediately once a profile is ready for your review." |
||||
|
], |
||||
|
advisorTitle: t["Get an advisor"], |
||||
|
advisorDescription: |
||||
|
t[ |
||||
|
"Not sure what to do next? Our psychology section is here to guide you at every step." |
||||
|
], |
||||
|
getAdvisor: t["Get Advisor"], |
||||
|
editProfile: t["Edit Profile"], |
||||
|
}; |
||||
|
const matchImageSrc = "/assets/images/Group 1597880466.svg"; |
||||
|
// This notice belongs exclusively to the gentleman whose accepted request
|
||||
|
// was later rejected by the lady. The API enforces the same rule.
|
||||
|
const unseenRejection = |
||||
|
profile?.gender === "male" ? profile.unseen_rejection : null; |
||||
|
|
||||
|
return ( |
||||
|
<> |
||||
|
<PageBackground /> |
||||
|
|
||||
|
<main |
||||
|
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }} |
||||
|
className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] text-center" |
||||
|
> |
||||
|
<PageHeader |
||||
|
className="-mx-[6px]" |
||||
|
rightButton={{ icon: "subscription", iconLabel: "Subscribe" }} |
||||
|
/> |
||||
|
|
||||
|
<section className="mt-20 flex flex-1 flex-col items-center"> |
||||
|
<div className="relative h-[124px] w-[130px]" aria-hidden="true"> |
||||
|
<Image |
||||
|
src={matchImageSrc} |
||||
|
alt="" |
||||
|
fill |
||||
|
sizes="58px" |
||||
|
className="object-cover" |
||||
|
priority |
||||
|
/> |
||||
|
</div> |
||||
|
<h1 className="mt-5 group-16 font-bold leading-none tracking-[0.02em] text-[#171717] uppercase"> |
||||
|
{copy.title} |
||||
|
</h1> |
||||
|
<p className="mt-3 max-w-[320px] mx-auto text-center group-12 leading-[1.35] font-semibold text-[#747474]"> |
||||
|
{copy.description} |
||||
|
</p> |
||||
|
{unseenRejection && ( |
||||
|
<aside |
||||
|
className="mt-5 w-full rounded-[20px] border border-[#F2465F]/15 bg-white p-4 text-start shadow-[0_10px_28px_rgba(242,70,95,0.08)]" |
||||
|
aria-live="polite" |
||||
|
aria-labelledby="rejection-notice-title" |
||||
|
> |
||||
|
<div className="flex items-start gap-3"> |
||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#FFF0F2] text-[#F2465F]"> |
||||
|
<IoAlertCircle className="h-6 w-6" aria-hidden="true" /> |
||||
|
</div> |
||||
|
|
||||
|
<div className="min-w-0 flex-1"> |
||||
|
<h2 |
||||
|
id="rejection-notice-title" |
||||
|
className="text-[16px] font-bold leading-[1.3] text-[#171717]" |
||||
|
> |
||||
|
{t["Your request was rejected"]} |
||||
|
</h2> |
||||
|
|
||||
|
<p className="mt-1.5 text-[13px] font-semibold leading-[1.5] text-[#747474]"> |
||||
|
{ |
||||
|
t[ |
||||
|
"Your request was rejected by the lady. You will be introduced to other candidates in the future." |
||||
|
] |
||||
|
} |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="mt-4 w-full"> |
||||
|
<Button |
||||
|
isLoading={isMarkingSeen} |
||||
|
onClick={() => { |
||||
|
markRejectionSeen({ |
||||
|
rejection_id: unseenRejection.id, |
||||
|
}); |
||||
|
}} |
||||
|
> |
||||
|
{t["Got it"]} |
||||
|
</Button> |
||||
|
</div> |
||||
|
</aside> |
||||
|
)}{" "} |
||||
|
</section> |
||||
|
|
||||
|
<AdvisorActionsCard |
||||
|
title={copy.advisorTitle} |
||||
|
description={copy.advisorDescription} |
||||
|
avatars={advisorAvatars} |
||||
|
extraCount={7} |
||||
|
getAdvisorLabel={copy.getAdvisor} |
||||
|
getAdvisorHref="/marriage-advisors" |
||||
|
/> |
||||
|
|
||||
|
<FixToTheEnd> |
||||
|
{profile?.can_edit_profile === false ? ( |
||||
|
<div |
||||
|
className="inline-flex w-full items-center justify-center gap-2 rounded-[9px] border-none bg-[#D1D1D6] dark:bg-[#3D3E42] px-4 py-[17px] text-center text-[#747474] dark:text-[#A1A1A1] shadow-none" |
||||
|
aria-live="polite" |
||||
|
> |
||||
|
<FaLock aria-hidden="true" className="h-3.5 w-3.5 shrink-0" /> |
||||
|
<span className="group-16 leading-none font-semibold"> |
||||
|
{t["Profile is locked"]} |
||||
|
</span> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<Button variant="dark" href="/questions-list"> |
||||
|
<FaPen aria-hidden="true" className="h-3.5 w-3.5 shrink-0" /> |
||||
|
<span>{copy.editProfile}</span> |
||||
|
</Button> |
||||
|
)} |
||||
|
</FixToTheEnd> |
||||
|
</main> |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
@ -1,180 +1,39 @@ |
|||||
"use client"; |
|
||||
|
|
||||
import Image from "next/image"; |
|
||||
import { useRouter } from "next/navigation"; |
|
||||
import { useEffect, useMemo } from "react"; |
|
||||
import { FaLock, FaPen } from "react-icons/fa6"; |
|
||||
import { IoAlertCircle } from "react-icons/io5"; |
|
||||
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; |
|
||||
import Button from "@/components/Componentes/button"; |
|
||||
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; |
|
||||
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; |
|
||||
import { PageBackground } from "@/components/Componentes/page-background"; |
|
||||
import PageHeader from "@/components/Componentes/page-header"; |
|
||||
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
|
||||
import { useRejectionSeenMutation } from "@/hooks/marriage/use-rejection-seen"; |
|
||||
import { getSubmitPath } from "@/lib/get-submit-path"; |
|
||||
import { localizePath } from "@/translations/config"; |
|
||||
import { useI18n } from "@/translations/provider"; |
|
||||
|
|
||||
const advisorAvatars = [ |
|
||||
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" }, |
|
||||
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" }, |
|
||||
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" }, |
|
||||
]; |
|
||||
|
|
||||
export default function FindingMatchPage() { |
|
||||
const router = useRouter(); |
|
||||
const { dictionary: t, locale } = useI18n(); |
|
||||
const { data: profile, isLoading } = useMarriageProfileQuery({ |
|
||||
refetchInterval: 3000, |
|
||||
|
import { cookies } from "next/headers"; |
||||
|
import { |
||||
|
dehydrate, |
||||
|
HydrationBoundary, |
||||
|
QueryClient, |
||||
|
} from "@tanstack/react-query"; |
||||
|
import { isAuthenticatedToken } from "@/lib/entry-route-cache"; |
||||
|
import { fetchProfileSSR } from "@/lib/ssr-fetch"; |
||||
|
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; |
||||
|
import FindingMatchClient from "./finding-match-client"; |
||||
|
|
||||
|
export const dynamic = "force-dynamic"; |
||||
|
|
||||
|
export default async function FindingMatchPage() { |
||||
|
const cookieStore = await cookies(); |
||||
|
|
||||
|
const token = |
||||
|
cookieStore.get("HABIB_TOKEN")?.value ?? |
||||
|
cookieStore.get("habib_token")?.value; |
||||
|
|
||||
|
const queryClient = new QueryClient({ |
||||
|
defaultOptions: { |
||||
|
queries: { staleTime: 10 * 1000 }, |
||||
|
}, |
||||
}); |
}); |
||||
|
|
||||
const { mutate: markRejectionSeen, isPending: isMarkingSeen } = |
|
||||
useRejectionSeenMutation(); |
|
||||
|
|
||||
useEffect(() => { |
|
||||
if (!profile) { |
|
||||
return; |
|
||||
} |
|
||||
const targetPath = getSubmitPath(profile); |
|
||||
if (targetPath !== "/finding-match") { |
|
||||
router.replace(localizePath(targetPath, locale)); |
|
||||
} |
|
||||
}, [profile, locale, router]); |
|
||||
|
|
||||
const isRedirecting = useMemo(() => { |
|
||||
if (!profile) return false; |
|
||||
return getSubmitPath(profile) !== "/finding-match"; |
|
||||
}, [profile]); |
|
||||
|
|
||||
if (isLoading || isRedirecting) { |
|
||||
return <PageLoadingSkeleton />; |
|
||||
|
if (isAuthenticatedToken(token)) { |
||||
|
await queryClient.prefetchQuery({ |
||||
|
queryKey: marriageQueryKeys.profile(), |
||||
|
queryFn: () => fetchProfileSSR(token!), |
||||
|
}); |
||||
} |
} |
||||
|
|
||||
const copy = { |
|
||||
title: t["SEARCH IN PROGRESS"], |
|
||||
description: |
|
||||
t[ |
|
||||
"Our system is actively looking for compatible partners based on your criteria. This process requires time and patience. We will notify you immediately once a profile is ready for your review." |
|
||||
], |
|
||||
advisorTitle: t["Get an advisor"], |
|
||||
advisorDescription: |
|
||||
t[ |
|
||||
"Not sure what to do next? Our psychology section is here to guide you at every step." |
|
||||
], |
|
||||
getAdvisor: t["Get Advisor"], |
|
||||
editProfile: t["Edit Profile"], |
|
||||
}; |
|
||||
const matchImageSrc = "/assets/images/Group 1597880466.svg"; |
|
||||
// This notice belongs exclusively to the gentleman whose accepted request
|
|
||||
// was later rejected by the lady. The API enforces the same rule.
|
|
||||
const unseenRejection = |
|
||||
profile?.gender === "male" ? profile.unseen_rejection : null; |
|
||||
|
|
||||
return ( |
return ( |
||||
<> |
|
||||
<PageBackground /> |
|
||||
|
|
||||
<main |
|
||||
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }} |
|
||||
className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] text-center" |
|
||||
> |
|
||||
<PageHeader |
|
||||
className="-mx-[6px]" |
|
||||
rightButton={{ icon: "subscription", iconLabel: "Subscribe" }} |
|
||||
/> |
|
||||
|
|
||||
<section className="mt-20 flex flex-1 flex-col items-center"> |
|
||||
<div className="relative h-[124px] w-[130px]" aria-hidden="true"> |
|
||||
<Image |
|
||||
src={matchImageSrc} |
|
||||
alt="" |
|
||||
fill |
|
||||
sizes="58px" |
|
||||
className="object-cover" |
|
||||
priority |
|
||||
/> |
|
||||
</div> |
|
||||
<h1 className="mt-5 group-16 font-bold leading-none tracking-[0.02em] text-[#171717] uppercase"> |
|
||||
{copy.title} |
|
||||
</h1> |
|
||||
<p className="mt-3 max-w-[320px] mx-auto text-center group-12 leading-[1.35] font-semibold text-[#747474]"> |
|
||||
{copy.description} |
|
||||
</p> |
|
||||
{unseenRejection && ( |
|
||||
<aside |
|
||||
className="mt-5 w-full rounded-[20px] border border-[#F2465F]/15 bg-white p-4 text-start shadow-[0_10px_28px_rgba(242,70,95,0.08)]" |
|
||||
aria-live="polite" |
|
||||
aria-labelledby="rejection-notice-title" |
|
||||
> |
|
||||
<div className="flex items-start gap-3"> |
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#FFF0F2] text-[#F2465F]"> |
|
||||
<IoAlertCircle className="h-6 w-6" aria-hidden="true" /> |
|
||||
</div> |
|
||||
|
|
||||
<div className="min-w-0 flex-1"> |
|
||||
<h2 |
|
||||
id="rejection-notice-title" |
|
||||
className="text-[16px] font-bold leading-[1.3] text-[#171717]" |
|
||||
> |
|
||||
{t["Your request was rejected"]} |
|
||||
</h2> |
|
||||
|
|
||||
<p className="mt-1.5 text-[13px] font-semibold leading-[1.5] text-[#747474]"> |
|
||||
{ |
|
||||
t[ |
|
||||
"Your request was rejected by the lady. You will be introduced to other candidates in the future." |
|
||||
] |
|
||||
} |
|
||||
</p> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div className="mt-4 w-full"> |
|
||||
<Button |
|
||||
isLoading={isMarkingSeen} |
|
||||
onClick={() => { |
|
||||
markRejectionSeen({ |
|
||||
rejection_id: unseenRejection.id, |
|
||||
}); |
|
||||
}} |
|
||||
> |
|
||||
{t["Got it"]} |
|
||||
</Button> |
|
||||
</div> |
|
||||
</aside> |
|
||||
)}{" "} |
|
||||
</section> |
|
||||
|
|
||||
<AdvisorActionsCard |
|
||||
title={copy.advisorTitle} |
|
||||
description={copy.advisorDescription} |
|
||||
avatars={advisorAvatars} |
|
||||
extraCount={7} |
|
||||
getAdvisorLabel={copy.getAdvisor} |
|
||||
getAdvisorHref="/marriage-advisors" |
|
||||
/> |
|
||||
|
|
||||
<FixToTheEnd> |
|
||||
{profile?.can_edit_profile === false ? ( |
|
||||
<div |
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-[9px] border-none bg-[#D1D1D6] dark:bg-[#3D3E42] px-4 py-[17px] text-center text-[#747474] dark:text-[#A1A1A1] shadow-none" |
|
||||
aria-live="polite" |
|
||||
> |
|
||||
<FaLock aria-hidden="true" className="h-3.5 w-3.5 shrink-0" /> |
|
||||
<span className="group-16 leading-none font-semibold"> |
|
||||
{t["Profile is locked"]} |
|
||||
</span> |
|
||||
</div> |
|
||||
) : ( |
|
||||
<Button variant="dark" href="/questions-list"> |
|
||||
<FaPen aria-hidden="true" className="h-3.5 w-3.5 shrink-0" /> |
|
||||
<span>{copy.editProfile}</span> |
|
||||
</Button> |
|
||||
)} |
|
||||
</FixToTheEnd> |
|
||||
</main> |
|
||||
</> |
|
||||
|
<HydrationBoundary state={dehydrate(queryClient)}> |
||||
|
<FindingMatchClient /> |
||||
|
</HydrationBoundary> |
||||
); |
); |
||||
} |
} |
||||
@ -0,0 +1,675 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import Image from "next/image"; |
||||
|
import Link from "next/link"; |
||||
|
import { useRouter } from "next/navigation"; |
||||
|
import { useEffect, useMemo, useState } from "react"; |
||||
|
import { FaLock } from "react-icons/fa6"; |
||||
|
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; |
||||
|
import PageHeader from "@/components/Componentes/page-header"; |
||||
|
import { PageBackground } from "@/components/Componentes/page-background"; |
||||
|
import { IoClose } from "react-icons/io5"; |
||||
|
import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton"; |
||||
|
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot"; |
||||
|
import { useHabcoinPaymentMutation } from "@/hooks/marriage/use-habcoin-payment"; |
||||
|
import { useRespondToMarriageCaseMutation } from "@/hooks/marriage/use-case-respond"; |
||||
|
import type { |
||||
|
MarriageField, |
||||
|
MarriageFieldValue, |
||||
|
MarriageMatchSummary, |
||||
|
MarriagePhoneFieldValue, |
||||
|
} from "@/hooks/marriage/types"; |
||||
|
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
||||
|
import { useViewPaddings } from "@/hooks/use-view-paddings"; |
||||
|
import { getSubmitPath } from "@/lib/get-submit-path"; |
||||
|
import { |
||||
|
buyHabibCoinPackages, |
||||
|
isInFlutterWebView, |
||||
|
} from "@/lib/webview-actions"; |
||||
|
import { localizePath } from "@/translations/config"; |
||||
|
import { useI18n } from "@/translations/provider"; |
||||
|
|
||||
|
const advisorAvatars = [ |
||||
|
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" }, |
||||
|
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" }, |
||||
|
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" }, |
||||
|
]; |
||||
|
|
||||
|
const fieldCandidates = { |
||||
|
name: ["name", "full_name", "fullname", "first_name", "display_name"], |
||||
|
occupation: [ |
||||
|
"occupation", |
||||
|
"job", |
||||
|
"profession", |
||||
|
"career", |
||||
|
"work", |
||||
|
"education", |
||||
|
"highest_level_of_education", |
||||
|
], |
||||
|
age: ["age"], |
||||
|
city: [ |
||||
|
"city", |
||||
|
"current_city", |
||||
|
"residence_city", |
||||
|
"location", |
||||
|
"residence", |
||||
|
"birth_city", |
||||
|
], |
||||
|
maritalStatus: [ |
||||
|
"marital_status", |
||||
|
"maritalstatus", |
||||
|
"relationship_status", |
||||
|
"current_marital_status", |
||||
|
], |
||||
|
cityPreference: [ |
||||
|
"city_preference", |
||||
|
"citypreference", |
||||
|
"preferred_city", |
||||
|
"preferred_location", |
||||
|
"future_residence", |
||||
|
], |
||||
|
} as const; |
||||
|
|
||||
|
type DisplayField = { |
||||
|
id: string; |
||||
|
label: string; |
||||
|
value: string; |
||||
|
}; |
||||
|
|
||||
|
function normalizeFieldName(value: string) { |
||||
|
return value |
||||
|
.toLowerCase() |
||||
|
.replace(/^q\d+[_-]?/, "") |
||||
|
.replace(/[^a-z0-9]/g, ""); |
||||
|
} |
||||
|
|
||||
|
function formatFieldValue(value: MarriageFieldValue) { |
||||
|
if (value === null || value === "") { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
if (isMarriagePhoneFieldValue(value)) { |
||||
|
return `+${value.countryCode}${value.phoneNumber}`; |
||||
|
} |
||||
|
|
||||
|
if (typeof value === "boolean") { |
||||
|
return value ? "Yes" : "No"; |
||||
|
} |
||||
|
|
||||
|
return String(value); |
||||
|
} |
||||
|
|
||||
|
function isMarriagePhoneFieldValue( |
||||
|
value: unknown, |
||||
|
): value is MarriagePhoneFieldValue { |
||||
|
if (!value || typeof value !== "object") { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const phoneValue = value as Partial<MarriagePhoneFieldValue>; |
||||
|
|
||||
|
return ( |
||||
|
typeof phoneValue.countryCode === "string" && |
||||
|
typeof phoneValue.phoneNumber === "string" |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
function titleFromKey(key: string) { |
||||
|
return key |
||||
|
.replace(/^q\d+[_-]?/i, "") |
||||
|
.replace(/[_-]+/g, " ") |
||||
|
.replace(/\s+/g, " ") |
||||
|
.trim() |
||||
|
.replace(/\b\w/g, (letter) => letter.toUpperCase()); |
||||
|
} |
||||
|
|
||||
|
function toDisplayField(field: MarriageField): DisplayField | null { |
||||
|
const value = formatFieldValue(field.value); |
||||
|
|
||||
|
if (!value) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
id: field.key || field.label || value, |
||||
|
label: field.label || titleFromKey(field.key), |
||||
|
value, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
function pickField( |
||||
|
fields: MarriageField[], |
||||
|
candidates: readonly string[], |
||||
|
usedIndexes: Set<number>, |
||||
|
) { |
||||
|
const candidateSet = new Set(candidates.map(normalizeFieldName)); |
||||
|
for (const [fieldIndex, field] of fields.entries()) { |
||||
|
if (usedIndexes.has(fieldIndex)) { |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
const displayField = toDisplayField(field); |
||||
|
|
||||
|
if ( |
||||
|
displayField && |
||||
|
[field.key, field.label].some((value) => |
||||
|
candidateSet.has(normalizeFieldName(value)), |
||||
|
) |
||||
|
) { |
||||
|
usedIndexes.add(fieldIndex); |
||||
|
return displayField; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
function useMatchSummaryDisplay(matchSummary: MarriageMatchSummary | null) { |
||||
|
return useMemo(() => { |
||||
|
const fields = matchSummary?.public_info ?? []; |
||||
|
const usedIndexes = new Set<number>(); |
||||
|
const name = pickField(fields, fieldCandidates.name, usedIndexes); |
||||
|
const occupation = pickField( |
||||
|
fields, |
||||
|
fieldCandidates.occupation, |
||||
|
usedIndexes, |
||||
|
); |
||||
|
const age = pickField(fields, fieldCandidates.age, usedIndexes); |
||||
|
const city = pickField(fields, fieldCandidates.city, usedIndexes); |
||||
|
const maritalStatus = pickField( |
||||
|
fields, |
||||
|
fieldCandidates.maritalStatus, |
||||
|
usedIndexes, |
||||
|
); |
||||
|
const cityPreference = pickField( |
||||
|
fields, |
||||
|
fieldCandidates.cityPreference, |
||||
|
usedIndexes, |
||||
|
); |
||||
|
const extraFields = fields |
||||
|
.filter((_, index) => !usedIndexes.has(index)) |
||||
|
.map(toDisplayField) |
||||
|
.filter((field): field is DisplayField => Boolean(field)) |
||||
|
.slice(0, 4); |
||||
|
|
||||
|
return { |
||||
|
age, |
||||
|
city, |
||||
|
cityPreference, |
||||
|
extraFields, |
||||
|
maritalStatus, |
||||
|
name: |
||||
|
name?.value ?? |
||||
|
(matchSummary?.id ? `Profile #${matchSummary.id}` : null), |
||||
|
occupation, |
||||
|
}; |
||||
|
}, [matchSummary]); |
||||
|
} |
||||
|
|
||||
|
function FieldLine({ field }: { field: DisplayField }) { |
||||
|
return ( |
||||
|
<p className="break-words text-[10px] leading-[1.85] font-medium text-white"> |
||||
|
<span>{field.label}: </span> |
||||
|
<span>{field.value}</span> |
||||
|
</p> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
export default function NewMatchClient() { |
||||
|
const router = useRouter(); |
||||
|
const { dictionary: t, locale } = useI18n(); |
||||
|
const { top, bottom } = useViewPaddings(); |
||||
|
const { data: profile, isError, isLoading } = useMarriageProfileQuery(); |
||||
|
|
||||
|
const [isPaymentSheetOpen, setIsPaymentSheetOpen] = useState(false); |
||||
|
const [paymentError, setPaymentError] = useState<string | null>(null); |
||||
|
const [isInsufficientCoins, setIsInsufficientCoins] = useState(false); |
||||
|
|
||||
|
const paymentMutation = useHabcoinPaymentMutation(); |
||||
|
const caseId = profile?.active_case?.case_id; |
||||
|
const respondMutation = useRespondToMarriageCaseMutation(caseId ?? "", { |
||||
|
onSuccess: () => { |
||||
|
router.replace(localizePath("/finding-match", locale)); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const handlePayment = async () => { |
||||
|
const recommendedPlanId = profile?.recommended_plan?.id; |
||||
|
if (!recommendedPlanId) return; |
||||
|
|
||||
|
try { |
||||
|
setPaymentError(null); |
||||
|
setIsInsufficientCoins(false); |
||||
|
await paymentMutation.mutateAsync(recommendedPlanId); |
||||
|
setIsPaymentSheetOpen(false); |
||||
|
router.push(localizePath("/new-match/profile", locale)); |
||||
|
} catch (err: any) { |
||||
|
console.error("Payment failed", err); |
||||
|
const msg = |
||||
|
err?.response?.data?.error || err?.message || "Payment failed"; |
||||
|
const modalT = (t as any).paymentModal || {}; |
||||
|
if (msg === "Not enough coins") { |
||||
|
setIsInsufficientCoins(true); |
||||
|
setPaymentError( |
||||
|
modalT.insufficientCoins || |
||||
|
"Insufficient coin balance. Please recharge your account.", |
||||
|
); |
||||
|
} else { |
||||
|
setPaymentError(msg); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handleDecline = async () => { |
||||
|
if (!caseId) return; |
||||
|
try { |
||||
|
await respondMutation.mutateAsync({ action: "reject" }); |
||||
|
} catch (err) { |
||||
|
console.error("Decline failed", err); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (!profile) { |
||||
|
return; |
||||
|
} |
||||
|
const targetPath = getSubmitPath(profile); |
||||
|
if (targetPath !== "/new-match") { |
||||
|
router.replace(localizePath(targetPath, locale)); |
||||
|
} |
||||
|
}, [profile, locale, router]); |
||||
|
|
||||
|
// Signal Flutter to lift its loading cover once the profile is available.
|
||||
|
useEffect(() => { |
||||
|
if (profile && !isLoading) { |
||||
|
window.__announceHabibWebReady?.(); |
||||
|
} |
||||
|
}, [profile, isLoading]); |
||||
|
|
||||
|
const isRedirecting = useMemo(() => { |
||||
|
if (!profile) return false; |
||||
|
return getSubmitPath(profile) !== "/new-match"; |
||||
|
}, [profile]); |
||||
|
|
||||
|
const matchSummary = profile?.match_summary ?? null; |
||||
|
const matchDisplay = useMatchSummaryDisplay(matchSummary); |
||||
|
|
||||
|
if (isLoading || isRedirecting) { |
||||
|
return ( |
||||
|
<> |
||||
|
<PageBackground /> |
||||
|
<main |
||||
|
style={{ |
||||
|
paddingBottom: `${bottom + 20}px`, |
||||
|
paddingTop: `${Math.max(12, top + 4)}px`, |
||||
|
}} |
||||
|
className="-mx-[17px] min-h-screen h-full text-center px-4" |
||||
|
> |
||||
|
<PageHeader /> |
||||
|
|
||||
|
<div> |
||||
|
{/* Header Section Skeleton */} |
||||
|
<section className="flex flex-col items-center mt-9"> |
||||
|
<LoadingSkeleton className="h-[60px] w-[60px] rounded-full" /> |
||||
|
<LoadingSkeleton className="h-6 w-[220px] mt-4" /> |
||||
|
<LoadingSkeleton className="h-4 w-[280px] mt-3" /> |
||||
|
<LoadingSkeleton className="h-4 w-[240px] mt-1.5" /> |
||||
|
</section> |
||||
|
|
||||
|
<div className="flex h-full flex-col justify-between"> |
||||
|
{/* Match Card Skeleton */} |
||||
|
<section className="mt-[36px] rounded-[15px] border border-white/80 bg-white px-[17px] pt-[18px] pb-[17px] shadow-[0_18px_45px_rgba(15,23,42,0.06)] flex flex-col items-center"> |
||||
|
{/* Name line */} |
||||
|
<LoadingSkeleton className="h-5 w-[140px]" /> |
||||
|
|
||||
|
{/* Subtitle / Details lines */} |
||||
|
<div className="mt-3 w-full flex flex-col items-center gap-2 min-h-[48px]"> |
||||
|
<LoadingSkeleton className="h-3 w-[180px]" /> |
||||
|
<LoadingSkeleton className="h-3 w-[120px]" /> |
||||
|
<LoadingSkeleton className="h-3 w-[150px]" /> |
||||
|
</div> |
||||
|
|
||||
|
{/* Button skeleton */} |
||||
|
<LoadingSkeleton className="mt-[15px] h-[40px] w-full rounded-[10px]" /> |
||||
|
</section> |
||||
|
|
||||
|
{/* Advisor Card Skeleton */} |
||||
|
<section className="mt-[36px]"> |
||||
|
<div className="rounded-[13px] border border-white/80 bg-white px-3 py-3.5 text-left shadow-[0_18px_45px_rgba(15,23,42,0.06)] backdrop-blur-sm"> |
||||
|
<LoadingSkeleton className="h-[16px] w-[140px]" /> |
||||
|
<div className="mt-2 space-y-1"> |
||||
|
<LoadingSkeleton className="h-[10px] w-[260px]" /> |
||||
|
<LoadingSkeleton className="h-[10px] w-[180px]" /> |
||||
|
</div> |
||||
|
|
||||
|
<div className="mt-4 flex items-center justify-between gap-3"> |
||||
|
<div className="flex items-center pl-1"> |
||||
|
<LoadingSkeleton className="h-[30px] w-[30px] rounded-full border-2 border-white" /> |
||||
|
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" /> |
||||
|
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" /> |
||||
|
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" /> |
||||
|
</div> |
||||
|
|
||||
|
<LoadingSkeleton className="h-[38px] w-[120px] rounded-[9px]" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</section> |
||||
|
</div> |
||||
|
</div> |
||||
|
</main> |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
|
const pairedFields = [matchDisplay.age, matchDisplay.city].filter( |
||||
|
(field): field is DisplayField => Boolean(field), |
||||
|
); |
||||
|
|
||||
|
const isFemaleProfile = profile?.gender === "female"; |
||||
|
const matchHeadingTitle = isFemaleProfile |
||||
|
? t["New Marriage Proposal"] |
||||
|
: t["YOU HAVE A NEW MATCH!"]; |
||||
|
const matchHeadingDescription = isFemaleProfile |
||||
|
? t[ |
||||
|
"A suitable match has been found for you. If approved, your profile will be evaluated to proceed with the introduction process." |
||||
|
] |
||||
|
: t[ |
||||
|
"If you proceed, we will notify the other party, and upon their approval, you can view each other's contact information." |
||||
|
]; |
||||
|
|
||||
|
const isMale = profile?.gender === "male"; |
||||
|
const hasActiveSub = !!profile?.active_subscription; |
||||
|
const isMatchAvailable = !!profile?.match_summary; |
||||
|
|
||||
|
return ( |
||||
|
<> |
||||
|
<PageBackground /> |
||||
|
|
||||
|
<main |
||||
|
style={{ |
||||
|
paddingBottom: `${profile?.can_edit_profile === false ? 100 + bottom : 20 + bottom}px`, |
||||
|
paddingTop: `${Math.max(12, top + 4)}px`, |
||||
|
}} |
||||
|
className="-mx-[17px] min-h-screen h-full text-center px-4" |
||||
|
> |
||||
|
<PageHeader /> |
||||
|
|
||||
|
<div className=""> |
||||
|
<section className="flex flex-col items-center mt-9"> |
||||
|
<div |
||||
|
aria-hidden="true" |
||||
|
className="relative flex h-[60px] w-[60px] items-center justify-center rounded-full bg-[#FF4E67] shadow-[0_12px_28px_rgba(240,68,91,0.22)]" |
||||
|
> |
||||
|
<Image |
||||
|
src={"/assets/images/Ellipse 1210.svg"} |
||||
|
width={70} |
||||
|
height={70} |
||||
|
alt="notification" |
||||
|
/> |
||||
|
</div> |
||||
|
<h2 className="text-[22px] font-bold mt-3.5"> |
||||
|
{matchHeadingTitle} |
||||
|
</h2> |
||||
|
<p className="mt-[11px] max-w-[322px] text-[12px] leading-[1.35] font-semibold text-[#7C7C7C]"> |
||||
|
{matchHeadingDescription} |
||||
|
</p> |
||||
|
</section> |
||||
|
<div className="flex h-full flex-col justify-between"> |
||||
|
{isLoading ? ( |
||||
|
<section className="mt-[36px] rounded-[15px] border border-white/80 bg-white px-[17px] pt-[18px] pb-[17px] shadow-[0_18px_45px_rgba(15,23,42,0.06)]"> |
||||
|
<div className="flex flex-col items-center py-2"> |
||||
|
{/* Name line */} |
||||
|
<LoadingSkeleton className="h-5 w-[140px]" /> |
||||
|
|
||||
|
{/* Subtitle / Details lines */} |
||||
|
<div className="mt-3 w-full flex flex-col items-center gap-2 min-h-[48px]"> |
||||
|
<LoadingSkeleton className="h-3 w-[180px]" /> |
||||
|
<LoadingSkeleton className="h-3 w-[120px]" /> |
||||
|
<LoadingSkeleton className="h-3 w-[150px]" /> |
||||
|
</div> |
||||
|
|
||||
|
{/* Button skeleton */} |
||||
|
<LoadingSkeleton className="mt-[15px] h-[40px] w-full rounded-[10px]" /> |
||||
|
</div> |
||||
|
</section> |
||||
|
) : ( |
||||
|
<section className="mt-[36px] rounded-[15px] bg-[linear-gradient(180deg,#F0445B_0%,#F4556E_100%)] px-[17px] pt-[18px] pb-[17px] text-white shadow-[0_18px_38px_rgba(240,68,91,0.25)]"> |
||||
|
{isError ? ( |
||||
|
<p className="py-8 text-[13px] font-semibold"> |
||||
|
Unable to load match summary. |
||||
|
</p> |
||||
|
) : matchSummary ? ( |
||||
|
<> |
||||
|
<h2 className="break-words text-[13px] leading-[1.4] font-bold"> |
||||
|
<span>Name: </span> |
||||
|
<span>{matchDisplay.name}</span> |
||||
|
</h2> |
||||
|
|
||||
|
<div className="mt-[3px] min-h-[48px]"> |
||||
|
{matchDisplay.occupation ? ( |
||||
|
<FieldLine field={matchDisplay.occupation} /> |
||||
|
) : null} |
||||
|
|
||||
|
{pairedFields.length ? ( |
||||
|
<p className="break-words text-[10px] leading-[1.85] font-medium text-white"> |
||||
|
{pairedFields.map((field, index) => ( |
||||
|
<span key={field.id}> |
||||
|
{index > 0 ? <span> | </span> : null} |
||||
|
<span> |
||||
|
{field.label}: {field.value} |
||||
|
</span> |
||||
|
</span> |
||||
|
))} |
||||
|
</p> |
||||
|
) : null} |
||||
|
|
||||
|
{matchDisplay.maritalStatus ? ( |
||||
|
<FieldLine field={matchDisplay.maritalStatus} /> |
||||
|
) : null} |
||||
|
{matchDisplay.cityPreference ? ( |
||||
|
<FieldLine field={matchDisplay.cityPreference} /> |
||||
|
) : null} |
||||
|
</div> |
||||
|
|
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => { |
||||
|
if (isMale && !hasActiveSub) { |
||||
|
setIsPaymentSheetOpen(true); |
||||
|
} else { |
||||
|
router.push( |
||||
|
localizePath("/new-match/profile", locale), |
||||
|
); |
||||
|
} |
||||
|
}} |
||||
|
className="mt-[15px] inline-flex w-full items-center justify-center rounded-[10px] border-none bg-white h-[40px] text-[16px] font-semibold text-[#F0445B] no-underline shadow-none hover:bg-white/95 transition-colors cursor-pointer" |
||||
|
> |
||||
|
{t["View more details"]} |
||||
|
</button> |
||||
|
</> |
||||
|
) : ( |
||||
|
<p className="py-8 text-[13px] font-semibold"> |
||||
|
No match summary is available yet. |
||||
|
</p> |
||||
|
)} |
||||
|
</section> |
||||
|
)} |
||||
|
<div className="w-full space-y-3"> |
||||
|
<AdvisorActionsCard |
||||
|
className="mt-[36px]" |
||||
|
title={t["Get an advisor"]} |
||||
|
description={ |
||||
|
t[ |
||||
|
"Not sure what to do next? Our psychology section is here to guide you at every step." |
||||
|
] |
||||
|
} |
||||
|
avatars={advisorAvatars} |
||||
|
extraCount={7} |
||||
|
getAdvisorLabel={t["Get Advisor"]} |
||||
|
getAdvisorHref="/marriage-advisors" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</main> |
||||
|
|
||||
|
{profile?.can_edit_profile === false && ( |
||||
|
<div |
||||
|
style={{ paddingBottom: `${16 + bottom}px` }} |
||||
|
className="fixed inset-x-0 bottom-0 z-20 mx-auto w-full sm:max-w-[375px] bg-background/95 px-[17px] pt-3 pb-[16px] backdrop-blur-md" |
||||
|
> |
||||
|
<div |
||||
|
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" |
||||
|
> |
||||
|
<FaLock aria-hidden="true" className="h-3.5 w-3.5 shrink-0" /> |
||||
|
<span className="group-16 leading-none font-semibold"> |
||||
|
{t["Profile is locked"]} |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{isPaymentSheetOpen && ( |
||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 backdrop-blur-xs p-4 animate-in fade-in duration-200"> |
||||
|
<div |
||||
|
style={{ paddingBottom: `calc(20px + ${bottom}px)` }} |
||||
|
className="relative w-full max-w-[375px] rounded-[24px] bg-white px-5 pt-6 shadow-[0_-8px_30px_rgba(0,0,0,0.12)] border border-slate-100 animate-in slide-in-from-bottom duration-300 text-center" |
||||
|
> |
||||
|
<button |
||||
|
type="button" |
||||
|
className="absolute top-4 right-4 text-[#8F8F8F] hover:text-[#5F5F5F] transition-colors" |
||||
|
onClick={() => { |
||||
|
setIsPaymentSheetOpen(false); |
||||
|
setPaymentError(null); |
||||
|
setIsInsufficientCoins(false); |
||||
|
}} |
||||
|
> |
||||
|
<IoClose className="text-[22px]" /> |
||||
|
</button> |
||||
|
|
||||
|
<div className="flex flex-col items-center"> |
||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#F0445B] text-white mb-4"> |
||||
|
<Image |
||||
|
src="/assets/images/noun-wedding-rings-6540466 1.svg" |
||||
|
alt="" |
||||
|
width={28} |
||||
|
height={28} |
||||
|
className="text-white" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<h3 className="group-16 font-bold text-gray-900 mb-2"> |
||||
|
{t["Verification & Subscription Activation"] || |
||||
|
"Verification & Subscription Activation"} |
||||
|
</h3> |
||||
|
|
||||
|
<p className="text-xs text-gray-500 leading-relaxed mb-4 text-center"> |
||||
|
{t[ |
||||
|
"This payment acts as a verification process for your account and activates a 3-month subscription to receive new case introductions. The cost of this subscription is 50 Habib Coins." |
||||
|
] || |
||||
|
"This payment acts as a verification process for your account and activates a 3-month subscription to receive new case introductions. The cost of this subscription is 50 Habib Coins."} |
||||
|
</p> |
||||
|
|
||||
|
<div className="w-full bg-gray-50 rounded-xl p-3 mb-4 text-center"> |
||||
|
<span className="text-[10px] text-gray-400 block mb-0.5"> |
||||
|
{t["Valid for 3 months"] || "Valid for 3 months"} |
||||
|
</span> |
||||
|
<span className="text-base font-bold text-[#FF4E67]"> |
||||
|
{t["50 Coins"] || "50 Habib Coins"} |
||||
|
</span> |
||||
|
</div> |
||||
|
|
||||
|
<p className="text-[10px] text-gray-400 leading-normal mb-6 text-center"> |
||||
|
{t[ |
||||
|
"Please note that there is no guarantee for a specific number of cases, and the volume of incoming cases is solely subject to profile compatibility with other users." |
||||
|
] || |
||||
|
"Please note that there is no guarantee for a specific number of cases, and the volume of incoming cases is solely subject to profile compatibility with other users."} |
||||
|
</p> |
||||
|
|
||||
|
{paymentError && ( |
||||
|
<div className="w-full bg-[#FEF2F2] text-[#B91C1C] text-xs p-3 rounded-xl mb-4 text-center font-medium"> |
||||
|
{paymentError} |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{isInsufficientCoins && isInFlutterWebView() && ( |
||||
|
<button |
||||
|
type="button" |
||||
|
className="appearance-none border-0 bg-transparent p-0 text-left w-full mb-4" |
||||
|
onClick={() => buyHabibCoinPackages()} |
||||
|
> |
||||
|
<div className="inline-flex w-full items-center justify-center gap-2 rounded-[18px] bg-[#00AC78] px-4 h-[52px] text-[16px] font-semibold text-white shadow-[0_10px_18px_rgba(0,172,120,0.28)] transition-opacity active:opacity-90"> |
||||
|
<Image |
||||
|
src="/assets/images/Inner Plugdsain Iframe.svg" |
||||
|
alt="" |
||||
|
aria-hidden="true" |
||||
|
width={18} |
||||
|
height={18} |
||||
|
className="shrink-0" |
||||
|
/> |
||||
|
<span className="truncate"> |
||||
|
{t["Buy Habib Coins"] || "Buy Habib Coins"} |
||||
|
</span> |
||||
|
</div> |
||||
|
</button> |
||||
|
)} |
||||
|
|
||||
|
<div className="grid w-full grid-cols-[33fr_67fr] gap-3"> |
||||
|
<button |
||||
|
type="button" |
||||
|
disabled={ |
||||
|
paymentMutation.isPending || respondMutation.isPending |
||||
|
} |
||||
|
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0" |
||||
|
onClick={handleDecline} |
||||
|
> |
||||
|
<div className="inline-flex w-full items-center justify-center rounded-[18px] 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"> |
||||
|
{respondMutation.isPending ? ( |
||||
|
<LoadingThreeDot /> |
||||
|
) : ( |
||||
|
<span className="truncate"> |
||||
|
{t["Decline"] || "Decline"} |
||||
|
</span> |
||||
|
)} |
||||
|
</div> |
||||
|
</button> |
||||
|
|
||||
|
<button |
||||
|
type="button" |
||||
|
disabled={ |
||||
|
paymentMutation.isPending || respondMutation.isPending |
||||
|
} |
||||
|
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0" |
||||
|
onClick={handlePayment} |
||||
|
> |
||||
|
<div className="inline-flex w-full items-center justify-center gap-3 rounded-[18px] bg-[#F0445B] px-4 h-[52px] text-[16px] font-semibold text-white shadow-[0_10px_18px_rgba(240,68,91,0.28)] transition-opacity active:opacity-90 min-w-0"> |
||||
|
{paymentMutation.isPending ? ( |
||||
|
<LoadingThreeDot /> |
||||
|
) : ( |
||||
|
<> |
||||
|
<span className="truncate min-w-0"> |
||||
|
{t["Pay"] || "Pay"} |
||||
|
</span> |
||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-[#E43B51] p-1.5 text-xs font-semibold leading-none text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.12)] shrink-0 min-w-0 whitespace-nowrap"> |
||||
|
<Image |
||||
|
src="/assets/images/Inner Plugdsain Iframe.svg" |
||||
|
alt="" |
||||
|
aria-hidden="true" |
||||
|
width={18} |
||||
|
height={18} |
||||
|
className="shrink-0" |
||||
|
/> |
||||
|
<span className="truncate">50</span> |
||||
|
</span> |
||||
|
</> |
||||
|
)} |
||||
|
</div> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
@ -1,668 +1,39 @@ |
|||||
"use client"; |
|
||||
|
|
||||
import Image from "next/image"; |
|
||||
import Link from "next/link"; |
|
||||
import { useRouter } from "next/navigation"; |
|
||||
import { useEffect, useMemo, useState } from "react"; |
|
||||
import { FaLock } from "react-icons/fa6"; |
|
||||
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; |
|
||||
import PageHeader from "@/components/Componentes/page-header"; |
|
||||
import { PageBackground } from "@/components/Componentes/page-background"; |
|
||||
import { IoClose } from "react-icons/io5"; |
|
||||
import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton"; |
|
||||
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot"; |
|
||||
import { useHabcoinPaymentMutation } from "@/hooks/marriage/use-habcoin-payment"; |
|
||||
import { useRespondToMarriageCaseMutation } from "@/hooks/marriage/use-case-respond"; |
|
||||
import type { |
|
||||
MarriageField, |
|
||||
MarriageFieldValue, |
|
||||
MarriageMatchSummary, |
|
||||
MarriagePhoneFieldValue, |
|
||||
} from "@/hooks/marriage/types"; |
|
||||
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
|
||||
import { useViewPaddings } from "@/hooks/use-view-paddings"; |
|
||||
import { getSubmitPath } from "@/lib/get-submit-path"; |
|
||||
|
import { cookies } from "next/headers"; |
||||
import { |
import { |
||||
buyHabibCoinPackages, |
|
||||
isInFlutterWebView, |
|
||||
} from "@/lib/webview-actions"; |
|
||||
import { localizePath } from "@/translations/config"; |
|
||||
import { useI18n } from "@/translations/provider"; |
|
||||
|
|
||||
const advisorAvatars = [ |
|
||||
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" }, |
|
||||
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" }, |
|
||||
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" }, |
|
||||
]; |
|
||||
|
|
||||
const fieldCandidates = { |
|
||||
name: ["name", "full_name", "fullname", "first_name", "display_name"], |
|
||||
occupation: [ |
|
||||
"occupation", |
|
||||
"job", |
|
||||
"profession", |
|
||||
"career", |
|
||||
"work", |
|
||||
"education", |
|
||||
"highest_level_of_education", |
|
||||
], |
|
||||
age: ["age"], |
|
||||
city: [ |
|
||||
"city", |
|
||||
"current_city", |
|
||||
"residence_city", |
|
||||
"location", |
|
||||
"residence", |
|
||||
"birth_city", |
|
||||
], |
|
||||
maritalStatus: [ |
|
||||
"marital_status", |
|
||||
"maritalstatus", |
|
||||
"relationship_status", |
|
||||
"current_marital_status", |
|
||||
], |
|
||||
cityPreference: [ |
|
||||
"city_preference", |
|
||||
"citypreference", |
|
||||
"preferred_city", |
|
||||
"preferred_location", |
|
||||
"future_residence", |
|
||||
], |
|
||||
} as const; |
|
||||
|
|
||||
type DisplayField = { |
|
||||
id: string; |
|
||||
label: string; |
|
||||
value: string; |
|
||||
}; |
|
||||
|
|
||||
function normalizeFieldName(value: string) { |
|
||||
return value |
|
||||
.toLowerCase() |
|
||||
.replace(/^q\d+[_-]?/, "") |
|
||||
.replace(/[^a-z0-9]/g, ""); |
|
||||
} |
|
||||
|
|
||||
function formatFieldValue(value: MarriageFieldValue) { |
|
||||
if (value === null || value === "") { |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
if (isMarriagePhoneFieldValue(value)) { |
|
||||
return `+${value.countryCode}${value.phoneNumber}`; |
|
||||
} |
|
||||
|
|
||||
if (typeof value === "boolean") { |
|
||||
return value ? "Yes" : "No"; |
|
||||
} |
|
||||
|
|
||||
return String(value); |
|
||||
} |
|
||||
|
|
||||
function isMarriagePhoneFieldValue( |
|
||||
value: unknown, |
|
||||
): value is MarriagePhoneFieldValue { |
|
||||
if (!value || typeof value !== "object") { |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
const phoneValue = value as Partial<MarriagePhoneFieldValue>; |
|
||||
|
|
||||
return ( |
|
||||
typeof phoneValue.countryCode === "string" && |
|
||||
typeof phoneValue.phoneNumber === "string" |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
function titleFromKey(key: string) { |
|
||||
return key |
|
||||
.replace(/^q\d+[_-]?/i, "") |
|
||||
.replace(/[_-]+/g, " ") |
|
||||
.replace(/\s+/g, " ") |
|
||||
.trim() |
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase()); |
|
||||
} |
|
||||
|
|
||||
function toDisplayField(field: MarriageField): DisplayField | null { |
|
||||
const value = formatFieldValue(field.value); |
|
||||
|
|
||||
if (!value) { |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
return { |
|
||||
id: field.key || field.label || value, |
|
||||
label: field.label || titleFromKey(field.key), |
|
||||
value, |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
function pickField( |
|
||||
fields: MarriageField[], |
|
||||
candidates: readonly string[], |
|
||||
usedIndexes: Set<number>, |
|
||||
) { |
|
||||
const candidateSet = new Set(candidates.map(normalizeFieldName)); |
|
||||
for (const [fieldIndex, field] of fields.entries()) { |
|
||||
if (usedIndexes.has(fieldIndex)) { |
|
||||
continue; |
|
||||
} |
|
||||
|
|
||||
const displayField = toDisplayField(field); |
|
||||
|
|
||||
if ( |
|
||||
displayField && |
|
||||
[field.key, field.label].some((value) => |
|
||||
candidateSet.has(normalizeFieldName(value)), |
|
||||
) |
|
||||
) { |
|
||||
usedIndexes.add(fieldIndex); |
|
||||
return displayField; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
function useMatchSummaryDisplay(matchSummary: MarriageMatchSummary | null) { |
|
||||
return useMemo(() => { |
|
||||
const fields = matchSummary?.public_info ?? []; |
|
||||
const usedIndexes = new Set<number>(); |
|
||||
const name = pickField(fields, fieldCandidates.name, usedIndexes); |
|
||||
const occupation = pickField( |
|
||||
fields, |
|
||||
fieldCandidates.occupation, |
|
||||
usedIndexes, |
|
||||
); |
|
||||
const age = pickField(fields, fieldCandidates.age, usedIndexes); |
|
||||
const city = pickField(fields, fieldCandidates.city, usedIndexes); |
|
||||
const maritalStatus = pickField( |
|
||||
fields, |
|
||||
fieldCandidates.maritalStatus, |
|
||||
usedIndexes, |
|
||||
); |
|
||||
const cityPreference = pickField( |
|
||||
fields, |
|
||||
fieldCandidates.cityPreference, |
|
||||
usedIndexes, |
|
||||
); |
|
||||
const extraFields = fields |
|
||||
.filter((_, index) => !usedIndexes.has(index)) |
|
||||
.map(toDisplayField) |
|
||||
.filter((field): field is DisplayField => Boolean(field)) |
|
||||
.slice(0, 4); |
|
||||
|
|
||||
return { |
|
||||
age, |
|
||||
city, |
|
||||
cityPreference, |
|
||||
extraFields, |
|
||||
maritalStatus, |
|
||||
name: |
|
||||
name?.value ?? |
|
||||
(matchSummary?.id ? `Profile #${matchSummary.id}` : null), |
|
||||
occupation, |
|
||||
}; |
|
||||
}, [matchSummary]); |
|
||||
} |
|
||||
|
|
||||
function FieldLine({ field }: { field: DisplayField }) { |
|
||||
return ( |
|
||||
<p className="break-words text-[10px] leading-[1.85] font-medium text-white"> |
|
||||
<span>{field.label}: </span> |
|
||||
<span>{field.value}</span> |
|
||||
</p> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
export default function NewMatchPage() { |
|
||||
const router = useRouter(); |
|
||||
const { dictionary: t, locale } = useI18n(); |
|
||||
const { top, bottom } = useViewPaddings(); |
|
||||
const { data: profile, isError, isLoading } = useMarriageProfileQuery(); |
|
||||
|
|
||||
const [isPaymentSheetOpen, setIsPaymentSheetOpen] = useState(false); |
|
||||
const [paymentError, setPaymentError] = useState<string | null>(null); |
|
||||
const [isInsufficientCoins, setIsInsufficientCoins] = useState(false); |
|
||||
|
|
||||
const paymentMutation = useHabcoinPaymentMutation(); |
|
||||
const caseId = profile?.active_case?.case_id; |
|
||||
const respondMutation = useRespondToMarriageCaseMutation(caseId ?? "", { |
|
||||
onSuccess: () => { |
|
||||
router.replace(localizePath("/finding-match", locale)); |
|
||||
|
dehydrate, |
||||
|
HydrationBoundary, |
||||
|
QueryClient, |
||||
|
} from "@tanstack/react-query"; |
||||
|
import { isAuthenticatedToken } from "@/lib/entry-route-cache"; |
||||
|
import { fetchProfileSSR } from "@/lib/ssr-fetch"; |
||||
|
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; |
||||
|
import NewMatchClient from "./new-match-client"; |
||||
|
|
||||
|
export const dynamic = "force-dynamic"; |
||||
|
|
||||
|
export default async function NewMatchPage() { |
||||
|
const cookieStore = await cookies(); |
||||
|
|
||||
|
const token = |
||||
|
cookieStore.get("HABIB_TOKEN")?.value ?? |
||||
|
cookieStore.get("habib_token")?.value; |
||||
|
|
||||
|
const queryClient = new QueryClient({ |
||||
|
defaultOptions: { |
||||
|
queries: { staleTime: 10 * 1000 }, |
||||
}, |
}, |
||||
}); |
}); |
||||
|
|
||||
const handlePayment = async () => { |
|
||||
const recommendedPlanId = profile?.recommended_plan?.id; |
|
||||
if (!recommendedPlanId) return; |
|
||||
|
|
||||
try { |
|
||||
setPaymentError(null); |
|
||||
setIsInsufficientCoins(false); |
|
||||
await paymentMutation.mutateAsync(recommendedPlanId); |
|
||||
setIsPaymentSheetOpen(false); |
|
||||
router.push(localizePath("/new-match/profile", locale)); |
|
||||
} catch (err: any) { |
|
||||
console.error("Payment failed", err); |
|
||||
const msg = |
|
||||
err?.response?.data?.error || err?.message || "Payment failed"; |
|
||||
const modalT = (t as any).paymentModal || {}; |
|
||||
if (msg === "Not enough coins") { |
|
||||
setIsInsufficientCoins(true); |
|
||||
setPaymentError( |
|
||||
modalT.insufficientCoins || |
|
||||
"Insufficient coin balance. Please recharge your account.", |
|
||||
); |
|
||||
} else { |
|
||||
setPaymentError(msg); |
|
||||
} |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
const handleDecline = async () => { |
|
||||
if (!caseId) return; |
|
||||
try { |
|
||||
await respondMutation.mutateAsync({ action: "reject" }); |
|
||||
} catch (err) { |
|
||||
console.error("Decline failed", err); |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
useEffect(() => { |
|
||||
if (!profile) { |
|
||||
return; |
|
||||
} |
|
||||
const targetPath = getSubmitPath(profile); |
|
||||
if (targetPath !== "/new-match") { |
|
||||
router.replace(localizePath(targetPath, locale)); |
|
||||
} |
|
||||
}, [profile, locale, router]); |
|
||||
|
|
||||
const isRedirecting = useMemo(() => { |
|
||||
if (!profile) return false; |
|
||||
return getSubmitPath(profile) !== "/new-match"; |
|
||||
}, [profile]); |
|
||||
|
|
||||
const matchSummary = profile?.match_summary ?? null; |
|
||||
const matchDisplay = useMatchSummaryDisplay(matchSummary); |
|
||||
|
|
||||
if (isLoading || isRedirecting) { |
|
||||
return ( |
|
||||
<> |
|
||||
<PageBackground /> |
|
||||
<main |
|
||||
style={{ |
|
||||
paddingBottom: `${bottom + 20}px`, |
|
||||
paddingTop: `${Math.max(12, top + 4)}px`, |
|
||||
}} |
|
||||
className="-mx-[17px] min-h-screen h-full text-center px-4" |
|
||||
> |
|
||||
<PageHeader /> |
|
||||
|
|
||||
<div> |
|
||||
{/* Header Section Skeleton */} |
|
||||
<section className="flex flex-col items-center mt-9"> |
|
||||
<LoadingSkeleton className="h-[60px] w-[60px] rounded-full" /> |
|
||||
<LoadingSkeleton className="h-6 w-[220px] mt-4" /> |
|
||||
<LoadingSkeleton className="h-4 w-[280px] mt-3" /> |
|
||||
<LoadingSkeleton className="h-4 w-[240px] mt-1.5" /> |
|
||||
</section> |
|
||||
|
|
||||
<div className="flex h-full flex-col justify-between"> |
|
||||
{/* Match Card Skeleton */} |
|
||||
<section className="mt-[36px] rounded-[15px] border border-white/80 bg-white px-[17px] pt-[18px] pb-[17px] shadow-[0_18px_45px_rgba(15,23,42,0.06)] flex flex-col items-center"> |
|
||||
{/* Name line */} |
|
||||
<LoadingSkeleton className="h-5 w-[140px]" /> |
|
||||
|
|
||||
{/* Subtitle / Details lines */} |
|
||||
<div className="mt-3 w-full flex flex-col items-center gap-2 min-h-[48px]"> |
|
||||
<LoadingSkeleton className="h-3 w-[180px]" /> |
|
||||
<LoadingSkeleton className="h-3 w-[120px]" /> |
|
||||
<LoadingSkeleton className="h-3 w-[150px]" /> |
|
||||
</div> |
|
||||
|
|
||||
{/* Button skeleton */} |
|
||||
<LoadingSkeleton className="mt-[15px] h-[40px] w-full rounded-[10px]" /> |
|
||||
</section> |
|
||||
|
|
||||
{/* Advisor Card Skeleton */} |
|
||||
<section className="mt-[36px]"> |
|
||||
<div className="rounded-[13px] border border-white/80 bg-white px-3 py-3.5 text-left shadow-[0_18px_45px_rgba(15,23,42,0.06)] backdrop-blur-sm"> |
|
||||
<LoadingSkeleton className="h-[16px] w-[140px]" /> |
|
||||
<div className="mt-2 space-y-1"> |
|
||||
<LoadingSkeleton className="h-[10px] w-[260px]" /> |
|
||||
<LoadingSkeleton className="h-[10px] w-[180px]" /> |
|
||||
</div> |
|
||||
|
|
||||
<div className="mt-4 flex items-center justify-between gap-3"> |
|
||||
<div className="flex items-center pl-1"> |
|
||||
<LoadingSkeleton className="h-[30px] w-[30px] rounded-full border-2 border-white" /> |
|
||||
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" /> |
|
||||
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" /> |
|
||||
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" /> |
|
||||
</div> |
|
||||
|
|
||||
<LoadingSkeleton className="h-[38px] w-[120px] rounded-[9px]" /> |
|
||||
</div> |
|
||||
</div> |
|
||||
</section> |
|
||||
</div> |
|
||||
</div> |
|
||||
</main> |
|
||||
</> |
|
||||
); |
|
||||
|
if (isAuthenticatedToken(token)) { |
||||
|
await queryClient.prefetchQuery({ |
||||
|
queryKey: marriageQueryKeys.profile(), |
||||
|
queryFn: () => fetchProfileSSR(token!), |
||||
|
}); |
||||
} |
} |
||||
const pairedFields = [matchDisplay.age, matchDisplay.city].filter( |
|
||||
(field): field is DisplayField => Boolean(field), |
|
||||
); |
|
||||
|
|
||||
const isFemaleProfile = profile?.gender === "female"; |
|
||||
const matchHeadingTitle = isFemaleProfile |
|
||||
? t["New Marriage Proposal"] |
|
||||
: t["YOU HAVE A NEW MATCH!"]; |
|
||||
const matchHeadingDescription = isFemaleProfile |
|
||||
? t[ |
|
||||
"A suitable match has been found for you. If approved, your profile will be evaluated to proceed with the introduction process." |
|
||||
] |
|
||||
: t[ |
|
||||
"If you proceed, we will notify the other party, and upon their approval, you can view each other's contact information." |
|
||||
]; |
|
||||
|
|
||||
const isMale = profile?.gender === "male"; |
|
||||
const hasActiveSub = !!profile?.active_subscription; |
|
||||
const isMatchAvailable = !!profile?.match_summary; |
|
||||
|
|
||||
return ( |
return ( |
||||
<> |
|
||||
<PageBackground /> |
|
||||
|
|
||||
<main |
|
||||
style={{ |
|
||||
paddingBottom: `${profile?.can_edit_profile === false ? 100 + bottom : 20 + bottom}px`, |
|
||||
paddingTop: `${Math.max(12, top + 4)}px`, |
|
||||
}} |
|
||||
className="-mx-[17px] min-h-screen h-full text-center px-4" |
|
||||
> |
|
||||
<PageHeader /> |
|
||||
|
|
||||
<div className=""> |
|
||||
<section className="flex flex-col items-center mt-9"> |
|
||||
<div |
|
||||
aria-hidden="true" |
|
||||
className="relative flex h-[60px] w-[60px] items-center justify-center rounded-full bg-[#FF4E67] shadow-[0_12px_28px_rgba(240,68,91,0.22)]" |
|
||||
> |
|
||||
<Image |
|
||||
src={"/assets/images/Ellipse 1210.svg"} |
|
||||
width={70} |
|
||||
height={70} |
|
||||
alt="notification" |
|
||||
/> |
|
||||
</div> |
|
||||
<h2 className="text-[22px] font-bold mt-3.5"> |
|
||||
{matchHeadingTitle} |
|
||||
</h2> |
|
||||
<p className="mt-[11px] max-w-[322px] text-[12px] leading-[1.35] font-semibold text-[#7C7C7C]"> |
|
||||
{matchHeadingDescription} |
|
||||
</p> |
|
||||
</section> |
|
||||
<div className="flex h-full flex-col justify-between"> |
|
||||
{isLoading ? ( |
|
||||
<section className="mt-[36px] rounded-[15px] border border-white/80 bg-white px-[17px] pt-[18px] pb-[17px] shadow-[0_18px_45px_rgba(15,23,42,0.06)]"> |
|
||||
<div className="flex flex-col items-center py-2"> |
|
||||
{/* Name line */} |
|
||||
<LoadingSkeleton className="h-5 w-[140px]" /> |
|
||||
|
|
||||
{/* Subtitle / Details lines */} |
|
||||
<div className="mt-3 w-full flex flex-col items-center gap-2 min-h-[48px]"> |
|
||||
<LoadingSkeleton className="h-3 w-[180px]" /> |
|
||||
<LoadingSkeleton className="h-3 w-[120px]" /> |
|
||||
<LoadingSkeleton className="h-3 w-[150px]" /> |
|
||||
</div> |
|
||||
|
|
||||
{/* Button skeleton */} |
|
||||
<LoadingSkeleton className="mt-[15px] h-[40px] w-full rounded-[10px]" /> |
|
||||
</div> |
|
||||
</section> |
|
||||
) : ( |
|
||||
<section className="mt-[36px] rounded-[15px] bg-[linear-gradient(180deg,#F0445B_0%,#F4556E_100%)] px-[17px] pt-[18px] pb-[17px] text-white shadow-[0_18px_38px_rgba(240,68,91,0.25)]"> |
|
||||
{isError ? ( |
|
||||
<p className="py-8 text-[13px] font-semibold"> |
|
||||
Unable to load match summary. |
|
||||
</p> |
|
||||
) : matchSummary ? ( |
|
||||
<> |
|
||||
<h2 className="break-words text-[13px] leading-[1.4] font-bold"> |
|
||||
<span>Name: </span> |
|
||||
<span>{matchDisplay.name}</span> |
|
||||
</h2> |
|
||||
|
|
||||
<div className="mt-[3px] min-h-[48px]"> |
|
||||
{matchDisplay.occupation ? ( |
|
||||
<FieldLine field={matchDisplay.occupation} /> |
|
||||
) : null} |
|
||||
|
|
||||
{pairedFields.length ? ( |
|
||||
<p className="break-words text-[10px] leading-[1.85] font-medium text-white"> |
|
||||
{pairedFields.map((field, index) => ( |
|
||||
<span key={field.id}> |
|
||||
{index > 0 ? <span> | </span> : null} |
|
||||
<span> |
|
||||
{field.label}: {field.value} |
|
||||
</span> |
|
||||
</span> |
|
||||
))} |
|
||||
</p> |
|
||||
) : null} |
|
||||
|
|
||||
{matchDisplay.maritalStatus ? ( |
|
||||
<FieldLine field={matchDisplay.maritalStatus} /> |
|
||||
) : null} |
|
||||
{matchDisplay.cityPreference ? ( |
|
||||
<FieldLine field={matchDisplay.cityPreference} /> |
|
||||
) : null} |
|
||||
</div> |
|
||||
|
|
||||
<button |
|
||||
type="button" |
|
||||
onClick={() => { |
|
||||
if (isMale && !hasActiveSub) { |
|
||||
setIsPaymentSheetOpen(true); |
|
||||
} else { |
|
||||
router.push( |
|
||||
localizePath("/new-match/profile", locale), |
|
||||
); |
|
||||
} |
|
||||
}} |
|
||||
className="mt-[15px] inline-flex w-full items-center justify-center rounded-[10px] border-none bg-white h-[40px] text-[16px] font-semibold text-[#F0445B] no-underline shadow-none hover:bg-white/95 transition-colors cursor-pointer" |
|
||||
> |
|
||||
{t["View more details"]} |
|
||||
</button> |
|
||||
</> |
|
||||
) : ( |
|
||||
<p className="py-8 text-[13px] font-semibold"> |
|
||||
No match summary is available yet. |
|
||||
</p> |
|
||||
)} |
|
||||
</section> |
|
||||
)} |
|
||||
<div className="w-full space-y-3"> |
|
||||
<AdvisorActionsCard |
|
||||
className="mt-[36px]" |
|
||||
title={t["Get an advisor"]} |
|
||||
description={ |
|
||||
t[ |
|
||||
"Not sure what to do next? Our psychology section is here to guide you at every step." |
|
||||
] |
|
||||
} |
|
||||
avatars={advisorAvatars} |
|
||||
extraCount={7} |
|
||||
getAdvisorLabel={t["Get Advisor"]} |
|
||||
getAdvisorHref="/marriage-advisors" |
|
||||
/> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</main> |
|
||||
|
|
||||
{profile?.can_edit_profile === false && ( |
|
||||
<div |
|
||||
style={{ paddingBottom: `${16 + bottom}px` }} |
|
||||
className="fixed inset-x-0 bottom-0 z-20 mx-auto w-full sm:max-w-[375px] bg-background/95 px-[17px] pt-3 pb-[16px] backdrop-blur-md" |
|
||||
> |
|
||||
<div |
|
||||
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" |
|
||||
> |
|
||||
<FaLock aria-hidden="true" className="h-3.5 w-3.5 shrink-0" /> |
|
||||
<span className="group-16 leading-none font-semibold"> |
|
||||
{t["Profile is locked"]} |
|
||||
</span> |
|
||||
</div> |
|
||||
</div> |
|
||||
)} |
|
||||
|
|
||||
{isPaymentSheetOpen && ( |
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 backdrop-blur-xs p-4 animate-in fade-in duration-200"> |
|
||||
<div |
|
||||
style={{ paddingBottom: `calc(20px + ${bottom}px)` }} |
|
||||
className="relative w-full max-w-[375px] rounded-[24px] bg-white px-5 pt-6 shadow-[0_-8px_30px_rgba(0,0,0,0.12)] border border-slate-100 animate-in slide-in-from-bottom duration-300 text-center" |
|
||||
> |
|
||||
<button |
|
||||
type="button" |
|
||||
className="absolute top-4 right-4 text-[#8F8F8F] hover:text-[#5F5F5F] transition-colors" |
|
||||
onClick={() => { |
|
||||
setIsPaymentSheetOpen(false); |
|
||||
setPaymentError(null); |
|
||||
setIsInsufficientCoins(false); |
|
||||
}} |
|
||||
> |
|
||||
<IoClose className="text-[22px]" /> |
|
||||
</button> |
|
||||
|
|
||||
<div className="flex flex-col items-center"> |
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#F0445B] text-white mb-4"> |
|
||||
<Image |
|
||||
src="/assets/images/noun-wedding-rings-6540466 1.svg" |
|
||||
alt="" |
|
||||
width={28} |
|
||||
height={28} |
|
||||
className="text-white" |
|
||||
/> |
|
||||
</div> |
|
||||
|
|
||||
<h3 className="group-16 font-bold text-gray-900 mb-2"> |
|
||||
{t["Verification & Subscription Activation"] || |
|
||||
"Verification & Subscription Activation"} |
|
||||
</h3> |
|
||||
|
|
||||
<p className="text-xs text-gray-500 leading-relaxed mb-4 text-center"> |
|
||||
{t[ |
|
||||
"This payment acts as a verification process for your account and activates a 3-month subscription to receive new case introductions. The cost of this subscription is 50 Habib Coins." |
|
||||
] || |
|
||||
"This payment acts as a verification process for your account and activates a 3-month subscription to receive new case introductions. The cost of this subscription is 50 Habib Coins."} |
|
||||
</p> |
|
||||
|
|
||||
<div className="w-full bg-gray-50 rounded-xl p-3 mb-4 text-center"> |
|
||||
<span className="text-[10px] text-gray-400 block mb-0.5"> |
|
||||
{t["Valid for 3 months"] || "Valid for 3 months"} |
|
||||
</span> |
|
||||
<span className="text-base font-bold text-[#FF4E67]"> |
|
||||
{t["50 Coins"] || "50 Habib Coins"} |
|
||||
</span> |
|
||||
</div> |
|
||||
|
|
||||
<p className="text-[10px] text-gray-400 leading-normal mb-6 text-center"> |
|
||||
{t[ |
|
||||
"Please note that there is no guarantee for a specific number of cases, and the volume of incoming cases is solely subject to profile compatibility with other users." |
|
||||
] || |
|
||||
"Please note that there is no guarantee for a specific number of cases, and the volume of incoming cases is solely subject to profile compatibility with other users."} |
|
||||
</p> |
|
||||
|
|
||||
{paymentError && ( |
|
||||
<div className="w-full bg-[#FEF2F2] text-[#B91C1C] text-xs p-3 rounded-xl mb-4 text-center font-medium"> |
|
||||
{paymentError} |
|
||||
</div> |
|
||||
)} |
|
||||
|
|
||||
{isInsufficientCoins && isInFlutterWebView() && ( |
|
||||
<button |
|
||||
type="button" |
|
||||
className="appearance-none border-0 bg-transparent p-0 text-left w-full mb-4" |
|
||||
onClick={() => buyHabibCoinPackages()} |
|
||||
> |
|
||||
<div className="inline-flex w-full items-center justify-center gap-2 rounded-[18px] bg-[#00AC78] px-4 h-[52px] text-[16px] font-semibold text-white shadow-[0_10px_18px_rgba(0,172,120,0.28)] transition-opacity active:opacity-90"> |
|
||||
<Image |
|
||||
src="/assets/images/Inner Plugdsain Iframe.svg" |
|
||||
alt="" |
|
||||
aria-hidden="true" |
|
||||
width={18} |
|
||||
height={18} |
|
||||
className="shrink-0" |
|
||||
/> |
|
||||
<span className="truncate"> |
|
||||
{t["Buy Habib Coins"] || "Buy Habib Coins"} |
|
||||
</span> |
|
||||
</div> |
|
||||
</button> |
|
||||
)} |
|
||||
|
|
||||
<div className="grid w-full grid-cols-[33fr_67fr] gap-3"> |
|
||||
<button |
|
||||
type="button" |
|
||||
disabled={ |
|
||||
paymentMutation.isPending || respondMutation.isPending |
|
||||
} |
|
||||
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0" |
|
||||
onClick={handleDecline} |
|
||||
> |
|
||||
<div className="inline-flex w-full items-center justify-center rounded-[18px] 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"> |
|
||||
{respondMutation.isPending ? ( |
|
||||
<LoadingThreeDot /> |
|
||||
) : ( |
|
||||
<span className="truncate"> |
|
||||
{t["Decline"] || "Decline"} |
|
||||
</span> |
|
||||
)} |
|
||||
</div> |
|
||||
</button> |
|
||||
|
|
||||
<button |
|
||||
type="button" |
|
||||
disabled={ |
|
||||
paymentMutation.isPending || respondMutation.isPending |
|
||||
} |
|
||||
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0" |
|
||||
onClick={handlePayment} |
|
||||
> |
|
||||
<div className="inline-flex w-full items-center justify-center gap-3 rounded-[18px] bg-[#F0445B] px-4 h-[52px] text-[16px] font-semibold text-white shadow-[0_10px_18px_rgba(240,68,91,0.28)] transition-opacity active:opacity-90 min-w-0"> |
|
||||
{paymentMutation.isPending ? ( |
|
||||
<LoadingThreeDot /> |
|
||||
) : ( |
|
||||
<> |
|
||||
<span className="truncate min-w-0"> |
|
||||
{t["Pay"] || "Pay"} |
|
||||
</span> |
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[#E43B51] p-1.5 text-xs font-semibold leading-none text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.12)] shrink-0 min-w-0 whitespace-nowrap"> |
|
||||
<Image |
|
||||
src="/assets/images/Inner Plugdsain Iframe.svg" |
|
||||
alt="" |
|
||||
aria-hidden="true" |
|
||||
width={18} |
|
||||
height={18} |
|
||||
className="shrink-0" |
|
||||
/> |
|
||||
<span className="truncate">50</span> |
|
||||
</span> |
|
||||
</> |
|
||||
)} |
|
||||
</div> |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
)} |
|
||||
</> |
|
||||
|
<HydrationBoundary state={dehydrate(queryClient)}> |
||||
|
<NewMatchClient /> |
||||
|
</HydrationBoundary> |
||||
); |
); |
||||
} |
} |
||||
@ -1,767 +1,49 @@ |
|||||
"use client"; |
|
||||
|
|
||||
import { useQueryClient } from "@tanstack/react-query"; |
|
||||
import Image from "next/image"; |
|
||||
import { useRouter } from "next/navigation"; |
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
|
||||
import { IoClose } from "react-icons/io5"; |
|
||||
import Button from "@/components/Componentes/button"; |
|
||||
import DataErrorState from "@/components/Componentes/data-error-state"; |
|
||||
import ErrorToast from "@/components/Componentes/error-toast"; |
|
||||
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; |
|
||||
import InformationSheet from "@/components/Componentes/information-sheet"; |
|
||||
import NavigationButton from "@/components/Componentes/navigation-button"; |
|
||||
import { PageBackground } from "@/components/Componentes/page-background"; |
|
||||
import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage"; |
|
||||
import QuestionCard from "@/components/Componentes/question-card"; |
|
||||
import RequiredStepsCard from "@/components/Componentes/required-steps-card"; |
|
||||
import type { MarriageField } from "@/hooks/marriage/types"; |
|
||||
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; |
|
||||
import { |
|
||||
getFormSection, |
|
||||
useFormOverviewQuery, |
|
||||
} from "@/hooks/marriage/use-form-schema"; |
|
||||
import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start"; |
|
||||
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
|
||||
import { |
|
||||
applyProfilePatchResultToCache, |
|
||||
updateMarriageSectionData, |
|
||||
} from "@/hooks/marriage/use-section-data"; |
|
||||
import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back"; |
|
||||
import { getAssessmentLocalProgress } from "@/lib/assessment-progress"; |
|
||||
import { |
|
||||
getSubmitPath, |
|
||||
hasCompletedMarriageProfileBasics, |
|
||||
} from "@/lib/get-submit-path"; |
|
||||
import { REQUIRED_PROFILE_SECTION_COUNT } from "@/lib/marriage-profile-contract"; |
|
||||
|
import { cookies } from "next/headers"; |
||||
import { |
import { |
||||
clearMatchStartGrace, |
|
||||
markMatchStarted, |
|
||||
} from "@/lib/match-start-grace"; |
|
||||
import type { QuestionListItem } from "@/lib/schema-adapter"; |
|
||||
import { convertOverviewToFrontendItems } from "@/lib/schema-adapter"; |
|
||||
import { localizePath } from "@/translations/config"; |
|
||||
import { useI18n } from "@/translations/provider"; |
|
||||
import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch"; |
|
||||
import SectionsRequest from "./sections-request"; |
|
||||
|
|
||||
export default function QuestionsListPage() { |
|
||||
useCloseServiceOnBack(); |
|
||||
const { dictionary: t, locale } = useI18n(); |
|
||||
const router = useRouter(); |
|
||||
const queryClient = useQueryClient(); |
|
||||
const { data: profile, isLoading: isProfileLoading, isError: isProfileError, refetch: refetchProfile } = |
|
||||
useMarriageProfileQuery(); |
|
||||
const { data: overview, isLoading: isSchemaLoading, isError: isSchemaError, refetch: refetchOverview } = useFormOverviewQuery( |
|
||||
"profile", |
|
||||
locale, |
|
||||
); |
|
||||
|
|
||||
const isSectionsLoading = false; |
|
||||
|
|
||||
const profileTargetPath = useMemo( |
|
||||
() => (profile ? getSubmitPath(profile) : null), |
|
||||
[profile], |
|
||||
); |
|
||||
const isProfileRedirecting = |
|
||||
profileTargetPath !== null && |
|
||||
profileTargetPath !== "/questions-list" && |
|
||||
(!hasCompletedMarriageProfileBasics(profile) || |
|
||||
profile?.can_edit_profile === false); |
|
||||
|
|
||||
useEffect(() => { |
|
||||
if (isProfileRedirecting && profileTargetPath) { |
|
||||
router.replace(localizePath(profileTargetPath, locale)); |
|
||||
} |
|
||||
}, [isProfileRedirecting, locale, profileTargetPath, router]); |
|
||||
|
|
||||
const startMatchMutation = useStartMarriageMatchMutation({ |
|
||||
onSuccess: () => { |
|
||||
markMatchStarted(); |
|
||||
router.push(localizePath("/finding-match", locale)); |
|
||||
}, |
|
||||
onError: () => { |
|
||||
// Never pretend the request went through – the user stays here and can
|
|
||||
// retry instead of being parked on the waiting screen forever.
|
|
||||
clearMatchStartGrace(); |
|
||||
|
dehydrate, |
||||
|
HydrationBoundary, |
||||
|
QueryClient, |
||||
|
} from "@tanstack/react-query"; |
||||
|
import { isAuthenticatedToken } from "@/lib/entry-route-cache"; |
||||
|
import { fetchProfileSSR } from "@/lib/ssr-fetch"; |
||||
|
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; |
||||
|
import QuestionsListClient from "./questions-list-client"; |
||||
|
|
||||
|
export const dynamic = "force-dynamic"; |
||||
|
|
||||
|
/** |
||||
|
* Server Component wrapper for questions-list. |
||||
|
* |
||||
|
* Prefetches the marriage profile server-side using the HABIB_TOKEN cookie |
||||
|
* (Flutter sets it via WebViewCookieManager before loadRequest). The profile |
||||
|
* determines which page the user should see — having it in the HTML avoids |
||||
|
* the white-flash caused by waiting for the client-side API call. |
||||
|
* |
||||
|
* The sections/form-overview loads client-side with shimmer — that's fine. |
||||
|
*/ |
||||
|
export default async function QuestionsListPage() { |
||||
|
const cookieStore = await cookies(); |
||||
|
|
||||
|
const token = |
||||
|
cookieStore.get("HABIB_TOKEN")?.value ?? |
||||
|
cookieStore.get("habib_token")?.value; |
||||
|
|
||||
|
const queryClient = new QueryClient({ |
||||
|
defaultOptions: { |
||||
|
queries: { staleTime: 10 * 1000 }, |
||||
}, |
}, |
||||
}); |
}); |
||||
const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false); |
|
||||
const [selectedSection, setSelectedSection] = |
|
||||
useState<QuestionListItem | null>(null); |
|
||||
const questionListItems = useMemo( |
|
||||
() => convertOverviewToFrontendItems(overview), |
|
||||
[overview], |
|
||||
); |
|
||||
const [localAssessmentProgress, setLocalAssessmentProgress] = useState< |
|
||||
Map<string, number> |
|
||||
>(new Map()); |
|
||||
|
|
||||
useEffect(() => { |
|
||||
const next = new Map<string, number>(); |
|
||||
for (const slug of ["personality_test", "glasser_5_needs_test"]) { |
|
||||
try { |
|
||||
const raw = window.localStorage.getItem(`marriage:tests:${slug}:draft`); |
|
||||
const draft = raw ? JSON.parse(raw) : null; |
|
||||
const progress = getAssessmentLocalProgress(draft, false); |
|
||||
if (progress > 0) next.set(slug, progress); |
|
||||
} catch { |
|
||||
// Ignore malformed local drafts.
|
|
||||
} |
|
||||
} |
|
||||
setLocalAssessmentProgress(next); |
|
||||
}, [overview]); |
|
||||
|
|
||||
const sectionProgressBySlug = useMemo(() => { |
|
||||
const progressBySlug = new Map<string, number>(); |
|
||||
|
|
||||
if (overview?.progress?.sections_progress) { |
|
||||
Object.entries(overview.progress.sections_progress).forEach( |
|
||||
([slug, prog]) => { |
|
||||
progressBySlug.set( |
|
||||
slug, |
|
||||
Math.max(0, Math.min(100, Math.round(prog.completion_percent))), |
|
||||
); |
|
||||
}, |
|
||||
); |
|
||||
} |
|
||||
localAssessmentProgress.forEach((progress, slug) => { |
|
||||
if ((progressBySlug.get(slug) ?? 0) < 100) |
|
||||
progressBySlug.set(slug, progress); |
|
||||
}); |
|
||||
|
|
||||
// Add fallback for combined section from the schema adapter which attaches it to questionListItems directly
|
|
||||
questionListItems.forEach((item) => { |
|
||||
if (!progressBySlug.has(item.slug)) { |
|
||||
progressBySlug.set(item.slug, item.progress); |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
return progressBySlug; |
|
||||
}, [overview, questionListItems, localAssessmentProgress]); |
|
||||
|
|
||||
const requiredQuestionListItems = useMemo( |
|
||||
() => questionListItems.filter((item) => Boolean(item.required)), |
|
||||
[questionListItems], |
|
||||
); |
|
||||
|
|
||||
const completedRequiredSections = useMemo( |
|
||||
() => |
|
||||
requiredQuestionListItems.filter( |
|
||||
(item) => (sectionProgressBySlug.get(item.slug) ?? 0) >= 100, |
|
||||
).length, |
|
||||
[requiredQuestionListItems, sectionProgressBySlug], |
|
||||
); |
|
||||
const [displayedRequiredSections, setDisplayedRequiredSections] = useState( |
|
||||
() => completedRequiredSections, |
|
||||
); |
|
||||
|
|
||||
useEffect(() => { |
|
||||
if (displayedRequiredSections === completedRequiredSections) return; |
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { |
|
||||
setDisplayedRequiredSections(completedRequiredSections); |
|
||||
return; |
|
||||
} |
|
||||
const direction = |
|
||||
completedRequiredSections > displayedRequiredSections ? 1 : -1; |
|
||||
const distance = Math.abs( |
|
||||
completedRequiredSections - displayedRequiredSections, |
|
||||
); |
|
||||
const interval = window.setInterval( |
|
||||
() => { |
|
||||
setDisplayedRequiredSections((current) => { |
|
||||
const next = current + direction; |
|
||||
if (next === completedRequiredSections) |
|
||||
window.clearInterval(interval); |
|
||||
return next; |
|
||||
}); |
|
||||
}, |
|
||||
Math.max(90, Math.floor(500 / distance)), |
|
||||
); |
|
||||
return () => window.clearInterval(interval); |
|
||||
}, [completedRequiredSections, displayedRequiredSections]); |
|
||||
|
|
||||
const hasValidRequiredContract = |
|
||||
requiredQuestionListItems.length === REQUIRED_PROFILE_SECTION_COUNT; |
|
||||
|
|
||||
useEffect(() => { |
|
||||
if (overview && !hasValidRequiredContract) { |
|
||||
console.error("Required section contract mismatch", { |
|
||||
expected: REQUIRED_PROFILE_SECTION_COUNT, |
|
||||
actual: requiredQuestionListItems.length, |
|
||||
}); |
|
||||
} |
|
||||
}, [hasValidRequiredContract, overview, requiredQuestionListItems.length]); |
|
||||
|
|
||||
const allRequiredSectionsCompleted = useMemo(() => { |
|
||||
if (!hasValidRequiredContract) { |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
return requiredQuestionListItems.every((item) => { |
|
||||
const progress = sectionProgressBySlug.get(item.slug) ?? 0; |
|
||||
return progress >= 100; |
|
||||
|
if (isAuthenticatedToken(token)) { |
||||
|
await queryClient.prefetchQuery({ |
||||
|
queryKey: marriageQueryKeys.profile(), |
||||
|
queryFn: () => fetchProfileSSR(token!), |
||||
}); |
}); |
||||
}, [ |
|
||||
hasValidRequiredContract, |
|
||||
requiredQuestionListItems, |
|
||||
sectionProgressBySlug, |
|
||||
]); |
|
||||
|
|
||||
const profileStatus = profile?.status; |
|
||||
const isProfileSuspended = profileStatus === "suspended"; |
|
||||
const isProfileBlocked = |
|
||||
profileStatus === "in_case" || profileStatus === "matched"; |
|
||||
const canStartMatch = |
|
||||
!isProfileSuspended && !isProfileBlocked && allRequiredSectionsCompleted; |
|
||||
const [isSyncError, setIsSyncError] = useState(false); |
|
||||
const [isSyncing, setIsSyncing] = useState(false); |
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null); |
|
||||
|
|
||||
const syncPromiseRef = useRef<Promise<void> | null>(null); |
|
||||
|
|
||||
const syncPendingAnswers = useCallback(async () => { |
|
||||
if (!overview) return; |
|
||||
if (syncPromiseRef.current) { |
|
||||
return syncPromiseRef.current; |
|
||||
} |
|
||||
|
|
||||
const task = (async () => { |
|
||||
const pendingSections: Array<{ |
|
||||
storageKey: string; |
|
||||
storedValue: { |
|
||||
current_step: number; |
|
||||
fields: MarriageField[]; |
|
||||
pending_keys: string[]; |
|
||||
pending_sync: boolean; |
|
||||
}; |
|
||||
fields: MarriageField[]; |
|
||||
slug: string; |
|
||||
}> = []; |
|
||||
for (const item of questionListItems) { |
|
||||
if ( |
|
||||
item.slug === "personality_test" || |
|
||||
item.slug === "glasser_5_needs_test" |
|
||||
) { |
|
||||
continue; |
|
||||
} |
|
||||
const storageKey = getQuestionAnswersStorageKey(item.slug); |
|
||||
const rawValue = window.localStorage.getItem(storageKey); |
|
||||
if (!rawValue) continue; |
|
||||
|
|
||||
let storedValue: any; |
|
||||
try { |
|
||||
storedValue = JSON.parse(rawValue); |
|
||||
} catch { |
|
||||
continue; |
|
||||
} |
|
||||
|
|
||||
if ( |
|
||||
!storedValue || |
|
||||
!storedValue.pending_sync || |
|
||||
!Array.isArray(storedValue.fields) |
|
||||
) { |
|
||||
continue; |
|
||||
} |
|
||||
const pendingKeys = new Set<string>( |
|
||||
Array.isArray(storedValue.pending_keys) |
|
||||
? storedValue.pending_keys |
|
||||
: storedValue.fields.map((field: { key: string }) => field.key), |
|
||||
); |
|
||||
const pendingFields = storedValue.fields.filter( |
|
||||
(field: { key: string }) => pendingKeys.has(field.key), |
|
||||
); |
|
||||
if (pendingFields.length === 0) continue; |
|
||||
pendingSections.push({ |
|
||||
storageKey, |
|
||||
storedValue, |
|
||||
fields: pendingFields, |
|
||||
slug: item.slug, |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
if (pendingSections.length === 0) return; |
|
||||
const result = await updateMarriageSectionData(pendingSections[0].slug, { |
|
||||
current_step: pendingSections[0].storedValue.current_step, |
|
||||
fields: pendingSections.flatMap((section) => section.fields), |
|
||||
}); |
|
||||
applyProfilePatchResultToCache(queryClient, locale, result); |
|
||||
const cleared = new Set(result.cleared_answer_ids ?? []); |
|
||||
|
|
||||
for (const { storageKey, storedValue } of pendingSections) { |
|
||||
storedValue.fields = storedValue.fields.filter( |
|
||||
(field: { key: string }) => !cleared.has(field.key), |
|
||||
); |
|
||||
storedValue.pending_sync = false; |
|
||||
storedValue.pending_keys = []; |
|
||||
if (storedValue.fields.length === 0) { |
|
||||
window.localStorage.removeItem(storageKey); |
|
||||
} else { |
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(storedValue)); |
|
||||
} |
|
||||
} |
|
||||
})(); |
|
||||
|
|
||||
syncPromiseRef.current = task; |
|
||||
try { |
|
||||
await task; |
|
||||
setIsSyncError(false); |
|
||||
} finally { |
|
||||
syncPromiseRef.current = null; |
|
||||
} |
|
||||
}, [locale, overview, queryClient, questionListItems]); |
|
||||
|
|
||||
const prefetchSection = useCallback( |
|
||||
(item: QuestionListItem) => { |
|
||||
const href = localizePath(`/questions-list/${item.slug}`, locale); |
|
||||
router.prefetch(href); |
|
||||
if ( |
|
||||
item.slug === "personality_test" || |
|
||||
item.slug === "glasser_5_needs_test" |
|
||||
) |
|
||||
return; |
|
||||
void queryClient.prefetchQuery({ |
|
||||
queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), |
|
||||
queryFn: () => getFormSection("profile", item.slug, locale), |
|
||||
staleTime: 30 * 1000, |
|
||||
}); |
|
||||
}, |
|
||||
[locale, queryClient, router], |
|
||||
); |
|
||||
|
|
||||
const viewportPrefetchChain = useRef(Promise.resolve()); |
|
||||
const viewportPrefetchSlugs = useRef(new Set<string>()); |
|
||||
const enqueueViewportPrefetch = useCallback( |
|
||||
(item: QuestionListItem) => { |
|
||||
if ( |
|
||||
item.slug === "personality_test" || |
|
||||
item.slug === "glasser_5_needs_test" || |
|
||||
viewportPrefetchSlugs.current.has(item.slug) |
|
||||
) { |
|
||||
return; |
|
||||
} |
|
||||
viewportPrefetchSlugs.current.add(item.slug); |
|
||||
viewportPrefetchChain.current = viewportPrefetchChain.current |
|
||||
.catch(() => undefined) |
|
||||
.then(() => |
|
||||
queryClient.fetchQuery({ |
|
||||
queryKey: marriageQueryKeys.formSection( |
|
||||
"profile", |
|
||||
item.slug, |
|
||||
locale, |
|
||||
), |
|
||||
queryFn: () => getFormSection("profile", item.slug, locale), |
|
||||
staleTime: 30 * 1000, |
|
||||
}), |
|
||||
) |
|
||||
.then(() => undefined); |
|
||||
}, |
|
||||
[locale, queryClient], |
|
||||
); |
|
||||
|
|
||||
const prefetchQueueStarted = useRef(false); |
|
||||
useEffect(() => { |
|
||||
if (prefetchQueueStarted.current || !overview) return; |
|
||||
const profileSections = questionListItems |
|
||||
.filter( |
|
||||
(item) => |
|
||||
item.slug !== "personality_test" && |
|
||||
item.slug !== "glasser_5_needs_test", |
|
||||
) |
|
||||
.sort((first, second) => { |
|
||||
const firstPriority = |
|
||||
first.required && |
|
||||
(sectionProgressBySlug.get(first.slug) ?? first.progress) < 100 |
|
||||
? 0 |
|
||||
: 1; |
|
||||
const secondPriority = |
|
||||
second.required && |
|
||||
(sectionProgressBySlug.get(second.slug) ?? second.progress) < 100 |
|
||||
? 0 |
|
||||
: 1; |
|
||||
return firstPriority - secondPriority; |
|
||||
}); |
|
||||
if (profileSections.length === 0) return; |
|
||||
prefetchQueueStarted.current = true; |
|
||||
let cancelled = false; |
|
||||
void prefetchSectionsWithBoundedConcurrency( |
|
||||
profileSections, |
|
||||
(item) => |
|
||||
queryClient.fetchQuery({ |
|
||||
queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), |
|
||||
queryFn: () => getFormSection("profile", item.slug, locale), |
|
||||
staleTime: 30 * 1000, |
|
||||
}), |
|
||||
() => cancelled, |
|
||||
); |
|
||||
return () => { |
|
||||
cancelled = true; |
|
||||
}; |
|
||||
}, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]); |
|
||||
|
|
||||
useEffect(() => { |
|
||||
void syncPendingAnswers().catch((err) => { |
|
||||
console.warn("Background draft sync:", err); |
|
||||
}); |
|
||||
const handleOnline = () => { |
|
||||
void syncPendingAnswers().catch((err) => { |
|
||||
console.warn("Background draft sync on online:", err); |
|
||||
}); |
|
||||
}; |
|
||||
window.addEventListener("online", handleOnline); |
|
||||
return () => window.removeEventListener("online", handleOnline); |
|
||||
}, [syncPendingAnswers]); |
|
||||
|
|
||||
useEffect(() => { |
|
||||
if (startMatchMutation.isError) { |
|
||||
setToastMessage( |
|
||||
t[ |
|
||||
"Sending the match request failed. Please check your connection and try again." |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
}, [startMatchMutation.isError, t]); |
|
||||
|
|
||||
const handleCloseToast = () => { |
|
||||
setToastMessage(null); |
|
||||
setIsSyncError(false); |
|
||||
startMatchMutation.reset(); |
|
||||
}; |
|
||||
|
|
||||
const isStartMatchDisabled = |
|
||||
startMatchMutation.isPending || isSyncing || !canStartMatch; |
|
||||
|
|
||||
const hasIncompleteOptionalSections = useMemo(() => { |
|
||||
return questionListItems.some( |
|
||||
(item) => |
|
||||
!item.required && (sectionProgressBySlug.get(item.slug) ?? 0) < 100, |
|
||||
); |
|
||||
}, [questionListItems, sectionProgressBySlug]); |
|
||||
|
|
||||
const handleStartMatch = async () => { |
|
||||
if (isStartMatchDisabled || isSyncing) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
setIsSyncing(true); |
|
||||
setIsSyncError(false); |
|
||||
|
|
||||
try { |
|
||||
await syncPendingAnswers(); |
|
||||
startMatchMutation.mutate(); |
|
||||
} catch (err) { |
|
||||
console.error("Failed to sync pending sections:", err); |
|
||||
setIsSyncError(true); |
|
||||
setToastMessage( |
|
||||
t[ |
|
||||
"Sending the match request failed. Please check your connection and try again." |
|
||||
] ?? "Sending the match request failed. Please check your connection and try again." |
|
||||
); |
|
||||
} finally { |
|
||||
setIsSyncing(false); |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
if ( |
|
||||
isProfileLoading || |
|
||||
isSectionsLoading || |
|
||||
isSchemaLoading || |
|
||||
isProfileRedirecting |
|
||||
) { |
|
||||
return ( |
|
||||
<> |
|
||||
<PageBackground disabled /> |
|
||||
<main |
|
||||
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }} |
|
||||
className="-mx-[17px] relative min-h-screen overflow-x-clip bg-[#F5F5F5] px-[17px] pt-0" |
|
||||
> |
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-[240px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.7)_42%,rgba(255,255,255,0)_100%)]" /> |
|
||||
<div className="pointer-events-none absolute -top-16 left-1/2 h-[220px] w-[220px] -translate-x-1/2 rounded-full bg-white/70 blur-3xl" /> |
|
||||
|
|
||||
<header |
|
||||
style={{ |
|
||||
paddingTop: "max(12px, calc(var(--safe-top) + 4px))", |
|
||||
}} |
|
||||
className="sticky top-0 z-20 -mx-[17px] flex items-center justify-between bg-[#F5F5F5]/90 px-[17px] pb-3 backdrop-blur-md" |
|
||||
> |
|
||||
<NavigationButton |
|
||||
icon="back" |
|
||||
iconLabel={t["Close questions list"]} |
|
||||
onClick={(e) => { |
|
||||
e.preventDefault(); |
|
||||
if (typeof window !== "undefined" && (window as any).HabibApp) { |
|
||||
(window as any).HabibApp.postMessage( |
|
||||
JSON.stringify({ action: "close_service" }), |
|
||||
); |
|
||||
} else { |
|
||||
router.push("/"); |
|
||||
} |
|
||||
}} |
|
||||
/> |
|
||||
<h1 className="group-16 font-semibold text-[#151515]"> |
|
||||
{t["Profile registration"]} |
|
||||
</h1> |
|
||||
<NavigationButton |
|
||||
icon="document" |
|
||||
iconLabel={t["Support"]} |
|
||||
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]" |
|
||||
/> |
|
||||
</header> |
|
||||
|
|
||||
<div className="relative mt-4 space-y-5"> |
|
||||
<RequiredStepsCard |
|
||||
completed={displayedRequiredSections} |
|
||||
total={REQUIRED_PROFILE_SECTION_COUNT} |
|
||||
/> |
|
||||
|
|
||||
{/* Section Card Skeletons (solid blocks like the Meet/checkup |
|
||||
AppShimmer loading — one sweep band runs across each card) */} |
|
||||
<div className="space-y-3"> |
|
||||
{Array.from({ length: 6 }).map((_, idx) => ( |
|
||||
<div key={idx} className="shimmer-bg h-[84px] rounded-[20px]" /> |
|
||||
))} |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<FixToTheEnd> |
|
||||
<Button aria-label={t["Find Matches"]} disabled> |
|
||||
<span className="flex items-center justify-center gap-2.5"> |
|
||||
<Image |
|
||||
src="/assets/images/noun-wedding-rings-6540466 1.svg" |
|
||||
alt="" |
|
||||
aria-hidden="true" |
|
||||
width={28} |
|
||||
height={28} |
|
||||
className="shrink-0" |
|
||||
loading="eager" |
|
||||
fetchPriority="high" |
|
||||
/> |
|
||||
<span className="leading-none font-semibold"> |
|
||||
{t["Submit"]} |
|
||||
</span> |
|
||||
</span> |
|
||||
</Button> |
|
||||
</FixToTheEnd> |
|
||||
</main> |
|
||||
</> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
if (isProfileError || isSchemaError) { |
|
||||
return ( |
|
||||
<> |
|
||||
<PageBackground disabled /> |
|
||||
<main |
|
||||
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }} |
|
||||
className="-mx-[17px] relative min-h-screen overflow-x-clip bg-[#F5F5F5] px-[17px] pt-0" |
|
||||
> |
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-[240px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.7)_42%,rgba(255,255,255,0)_100%)]" /> |
|
||||
<div className="pointer-events-none absolute -top-16 left-1/2 h-[220px] w-[220px] -translate-x-1/2 rounded-full bg-white/70 blur-3xl" /> |
|
||||
|
|
||||
<header |
|
||||
style={{ |
|
||||
paddingTop: "max(12px, calc(var(--safe-top) + 4px))", |
|
||||
}} |
|
||||
className="sticky top-0 z-20 -mx-[17px] flex items-center justify-between bg-[#F5F5F5]/90 px-[17px] pb-3 backdrop-blur-md" |
|
||||
> |
|
||||
<NavigationButton |
|
||||
icon="back" |
|
||||
iconLabel={t["Close questions list"]} |
|
||||
onClick={(e) => { |
|
||||
e.preventDefault(); |
|
||||
if (typeof window !== "undefined" && (window as any).HabibApp) { |
|
||||
(window as any).HabibApp.postMessage( |
|
||||
JSON.stringify({ action: "close_service" }), |
|
||||
); |
|
||||
} else { |
|
||||
router.push("/"); |
|
||||
} |
|
||||
}} |
|
||||
/> |
|
||||
<h1 className="group-16 font-semibold text-[#151515]"> |
|
||||
{t["Profile registration"]} |
|
||||
</h1> |
|
||||
<NavigationButton |
|
||||
icon="document" |
|
||||
iconLabel={t["Support"]} |
|
||||
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]" |
|
||||
/> |
|
||||
</header> |
|
||||
|
|
||||
<DataErrorState |
|
||||
onRetry={() => { |
|
||||
if (isProfileError) refetchProfile(); |
|
||||
if (isSchemaError) refetchOverview(); |
|
||||
}} |
|
||||
/> |
|
||||
</main> |
|
||||
</> |
|
||||
); |
|
||||
} |
} |
||||
|
|
||||
return ( |
return ( |
||||
<> |
|
||||
{toastMessage && ( |
|
||||
<ErrorToast message={toastMessage} onClose={handleCloseToast} /> |
|
||||
)} |
|
||||
{isOptionalInfoSheetOpen ? ( |
|
||||
<InformationSheet |
|
||||
icon="warning" |
|
||||
title={t["Important Note"]} |
|
||||
description={ |
|
||||
<p className="px-0.5 text-sm leading-[1.45] text-[#2D2D2D]"> |
|
||||
{ |
|
||||
t[ |
|
||||
"You've completed all required fields. However, filling in all sections will help us find better matches for you" |
|
||||
] |
|
||||
} |
|
||||
</p> |
|
||||
} |
|
||||
onClose={() => setIsOptionalInfoSheetOpen(false)} |
|
||||
buttons={({ close }) => ( |
|
||||
<div className="grid w-full grid-cols-[33fr_67fr] gap-3"> |
|
||||
<Button |
|
||||
className="rounded-[11px] border border-[#9B9B9B] bg-transparent text-[#8B8B8B] min-w-0" |
|
||||
onClick={close} |
|
||||
variant="outlined" |
|
||||
> |
|
||||
<span className="truncate">{t["Cancel"]}</span> |
|
||||
</Button> |
|
||||
<Button |
|
||||
className="rounded-[11px] min-w-0" |
|
||||
disabled={isStartMatchDisabled} |
|
||||
onClick={() => { |
|
||||
close(); |
|
||||
handleStartMatch(); |
|
||||
}} |
|
||||
> |
|
||||
<span className="truncate">{t["Submit"]}</span> |
|
||||
</Button> |
|
||||
</div> |
|
||||
)} |
|
||||
/> |
|
||||
) : null} |
|
||||
{selectedSection ? ( |
|
||||
<InformationSheet |
|
||||
icon={null} |
|
||||
title={({ close }) => ( |
|
||||
<span className="flex w-full items-start justify-between gap-3 text-left"> |
|
||||
<span className="group-14 leading-5 font-bold tracking-normal text-[#8B8B8B]"> |
|
||||
{selectedSection.title} |
|
||||
</span> |
|
||||
<button |
|
||||
type="button" |
|
||||
aria-label={`Close ${selectedSection.title} explanation`} |
|
||||
className="-mt-0.5 flex size-6 shrink-0 items-center justify-center text-[#8F8F8F]" |
|
||||
onClick={close} |
|
||||
> |
|
||||
<IoClose aria-hidden="true" className="text-[22px]" /> |
|
||||
</button> |
|
||||
</span> |
|
||||
)} |
|
||||
description={ |
|
||||
<p className="px-0.5 group-14 leading-[1.45] text-[#2D2D2D]"> |
|
||||
{selectedSection.summary} |
|
||||
</p> |
|
||||
} |
|
||||
onClose={() => setSelectedSection(null)} |
|
||||
className="text-left" |
|
||||
/> |
|
||||
) : null} |
|
||||
<SectionsRequest sections={overview?.sections} /> |
|
||||
<PageBackground disabled /> |
|
||||
|
|
||||
<main |
|
||||
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }} |
|
||||
className="-mx-[17px] relative min-h-screen overflow-x-clip bg-[#F5F5F5] px-[17px] pt-0" |
|
||||
> |
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-[240px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.7)_42%,rgba(255,255,255,0)_100%)]" /> |
|
||||
<div className="pointer-events-none absolute -top-16 left-1/2 h-[220px] w-[220px] -translate-x-1/2 rounded-full bg-white/70 blur-3xl" /> |
|
||||
|
|
||||
<header |
|
||||
style={{ |
|
||||
paddingTop: "max(12px, calc(var(--safe-top) + 4px))", |
|
||||
}} |
|
||||
className="sticky top-0 z-20 -mx-[17px] flex items-center justify-between bg-[#F5F5F5]/90 px-[17px] pb-3 backdrop-blur-md" |
|
||||
> |
|
||||
<NavigationButton |
|
||||
icon="back" |
|
||||
iconLabel={t["Close questions list"]} |
|
||||
onClick={(e) => { |
|
||||
e.preventDefault(); |
|
||||
if (typeof window !== "undefined" && (window as any).HabibApp) { |
|
||||
(window as any).HabibApp.postMessage( |
|
||||
JSON.stringify({ action: "close_service" }), |
|
||||
); |
|
||||
} else { |
|
||||
router.push("/"); |
|
||||
} |
|
||||
}} |
|
||||
/> |
|
||||
<h1 className="group-16 font-semibold text-[#151515]"> |
|
||||
{t["Profile registration"]} |
|
||||
</h1> |
|
||||
<NavigationButton |
|
||||
icon="document" |
|
||||
iconLabel={t["Support"]} |
|
||||
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]" |
|
||||
/> |
|
||||
</header> |
|
||||
|
|
||||
<div className="relative"> |
|
||||
<div className="mt-4"> |
|
||||
<RequiredStepsCard |
|
||||
completed={displayedRequiredSections} |
|
||||
total={REQUIRED_PROFILE_SECTION_COUNT} |
|
||||
/> |
|
||||
</div> |
|
||||
|
|
||||
<section className="mt-5 space-y-3"> |
|
||||
{questionListItems.map((item) => ( |
|
||||
<QuestionCard |
|
||||
key={item.slug} |
|
||||
item={item} |
|
||||
progress={sectionProgressBySlug.get(item.slug) ?? null} |
|
||||
onInfoClick={(section) => setSelectedSection(section)} |
|
||||
onNearViewport={enqueueViewportPrefetch} |
|
||||
onPrefetch={prefetchSection} |
|
||||
/> |
|
||||
))} |
|
||||
</section> |
|
||||
</div> |
|
||||
|
|
||||
<FixToTheEnd> |
|
||||
<Button |
|
||||
aria-label={t["Find Matches"]} |
|
||||
disabled={isStartMatchDisabled} |
|
||||
isLoading={startMatchMutation.isPending || isSyncing} |
|
||||
onClick={() => { |
|
||||
if (hasIncompleteOptionalSections) { |
|
||||
setIsOptionalInfoSheetOpen(true); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
handleStartMatch(); |
|
||||
}} |
|
||||
> |
|
||||
<span className="flex items-center justify-center gap-2.5"> |
|
||||
<Image |
|
||||
src="/assets/images/noun-wedding-rings-6540466 1.svg" |
|
||||
alt="" |
|
||||
aria-hidden="true" |
|
||||
width={28} |
|
||||
height={28} |
|
||||
className="shrink-0" |
|
||||
// Above-the-fold CTA asset: fetch it with the initial
|
|
||||
// document instead of letting next/image lazy-load it.
|
|
||||
loading="eager" |
|
||||
fetchPriority="high" |
|
||||
/> |
|
||||
<span className="leading-none font-semibold">{t["Submit"]}</span> |
|
||||
</span> |
|
||||
</Button> |
|
||||
</FixToTheEnd> |
|
||||
</main> |
|
||||
</> |
|
||||
|
<HydrationBoundary state={dehydrate(queryClient)}> |
||||
|
<QuestionsListClient /> |
||||
|
</HydrationBoundary> |
||||
); |
); |
||||
} |
} |
||||
@ -0,0 +1,775 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import { useQueryClient } from "@tanstack/react-query"; |
||||
|
import Image from "next/image"; |
||||
|
import { useRouter } from "next/navigation"; |
||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
||||
|
import { IoClose } from "react-icons/io5"; |
||||
|
import Button from "@/components/Componentes/button"; |
||||
|
import DataErrorState from "@/components/Componentes/data-error-state"; |
||||
|
import ErrorToast from "@/components/Componentes/error-toast"; |
||||
|
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; |
||||
|
import InformationSheet from "@/components/Componentes/information-sheet"; |
||||
|
import NavigationButton from "@/components/Componentes/navigation-button"; |
||||
|
import { PageBackground } from "@/components/Componentes/page-background"; |
||||
|
import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage"; |
||||
|
import QuestionCard from "@/components/Componentes/question-card"; |
||||
|
import RequiredStepsCard from "@/components/Componentes/required-steps-card"; |
||||
|
import type { MarriageField } from "@/hooks/marriage/types"; |
||||
|
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; |
||||
|
import { |
||||
|
getFormSection, |
||||
|
useFormOverviewQuery, |
||||
|
} from "@/hooks/marriage/use-form-schema"; |
||||
|
import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start"; |
||||
|
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
||||
|
import { |
||||
|
applyProfilePatchResultToCache, |
||||
|
updateMarriageSectionData, |
||||
|
} from "@/hooks/marriage/use-section-data"; |
||||
|
import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back"; |
||||
|
import { getAssessmentLocalProgress } from "@/lib/assessment-progress"; |
||||
|
import { |
||||
|
getSubmitPath, |
||||
|
hasCompletedMarriageProfileBasics, |
||||
|
} from "@/lib/get-submit-path"; |
||||
|
import { REQUIRED_PROFILE_SECTION_COUNT } from "@/lib/marriage-profile-contract"; |
||||
|
import { |
||||
|
clearMatchStartGrace, |
||||
|
markMatchStarted, |
||||
|
} from "@/lib/match-start-grace"; |
||||
|
import type { QuestionListItem } from "@/lib/schema-adapter"; |
||||
|
import { convertOverviewToFrontendItems } from "@/lib/schema-adapter"; |
||||
|
import { localizePath } from "@/translations/config"; |
||||
|
import { useI18n } from "@/translations/provider"; |
||||
|
import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch"; |
||||
|
import SectionsRequest from "./sections-request"; |
||||
|
|
||||
|
export default function QuestionsListClient() { |
||||
|
useCloseServiceOnBack(); |
||||
|
const { dictionary: t, locale } = useI18n(); |
||||
|
const router = useRouter(); |
||||
|
const queryClient = useQueryClient(); |
||||
|
const { data: profile, isLoading: isProfileLoading, isError: isProfileError, refetch: refetchProfile } = |
||||
|
useMarriageProfileQuery(); |
||||
|
const { data: overview, isLoading: isSchemaLoading, isError: isSchemaError, refetch: refetchOverview } = useFormOverviewQuery( |
||||
|
"profile", |
||||
|
locale, |
||||
|
); |
||||
|
|
||||
|
const isSectionsLoading = false; |
||||
|
|
||||
|
const profileTargetPath = useMemo( |
||||
|
() => (profile ? getSubmitPath(profile) : null), |
||||
|
[profile], |
||||
|
); |
||||
|
const isProfileRedirecting = |
||||
|
profileTargetPath !== null && |
||||
|
profileTargetPath !== "/questions-list" && |
||||
|
(!hasCompletedMarriageProfileBasics(profile) || |
||||
|
profile?.can_edit_profile === false); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (isProfileRedirecting && profileTargetPath) { |
||||
|
router.replace(localizePath(profileTargetPath, locale)); |
||||
|
} |
||||
|
}, [isProfileRedirecting, locale, profileTargetPath, router]); |
||||
|
|
||||
|
// Signal Flutter to lift its loading cover once the profile is available
|
||||
|
// (either from SSR hydration or client-side fetch).
|
||||
|
useEffect(() => { |
||||
|
if (profile && !isProfileLoading) { |
||||
|
window.__announceHabibWebReady?.(); |
||||
|
} |
||||
|
}, [profile, isProfileLoading]); |
||||
|
|
||||
|
const startMatchMutation = useStartMarriageMatchMutation({ |
||||
|
onSuccess: () => { |
||||
|
markMatchStarted(); |
||||
|
router.push(localizePath("/finding-match", locale)); |
||||
|
}, |
||||
|
onError: () => { |
||||
|
// Never pretend the request went through – the user stays here and can
|
||||
|
// retry instead of being parked on the waiting screen forever.
|
||||
|
clearMatchStartGrace(); |
||||
|
}, |
||||
|
}); |
||||
|
const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false); |
||||
|
const [selectedSection, setSelectedSection] = |
||||
|
useState<QuestionListItem | null>(null); |
||||
|
const questionListItems = useMemo( |
||||
|
() => convertOverviewToFrontendItems(overview), |
||||
|
[overview], |
||||
|
); |
||||
|
const [localAssessmentProgress, setLocalAssessmentProgress] = useState< |
||||
|
Map<string, number> |
||||
|
>(new Map()); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const next = new Map<string, number>(); |
||||
|
for (const slug of ["personality_test", "glasser_5_needs_test"]) { |
||||
|
try { |
||||
|
const raw = window.localStorage.getItem(`marriage:tests:${slug}:draft`); |
||||
|
const draft = raw ? JSON.parse(raw) : null; |
||||
|
const progress = getAssessmentLocalProgress(draft, false); |
||||
|
if (progress > 0) next.set(slug, progress); |
||||
|
} catch { |
||||
|
// Ignore malformed local drafts.
|
||||
|
} |
||||
|
} |
||||
|
setLocalAssessmentProgress(next); |
||||
|
}, [overview]); |
||||
|
|
||||
|
const sectionProgressBySlug = useMemo(() => { |
||||
|
const progressBySlug = new Map<string, number>(); |
||||
|
|
||||
|
if (overview?.progress?.sections_progress) { |
||||
|
Object.entries(overview.progress.sections_progress).forEach( |
||||
|
([slug, prog]) => { |
||||
|
progressBySlug.set( |
||||
|
slug, |
||||
|
Math.max(0, Math.min(100, Math.round(prog.completion_percent))), |
||||
|
); |
||||
|
}, |
||||
|
); |
||||
|
} |
||||
|
localAssessmentProgress.forEach((progress, slug) => { |
||||
|
if ((progressBySlug.get(slug) ?? 0) < 100) |
||||
|
progressBySlug.set(slug, progress); |
||||
|
}); |
||||
|
|
||||
|
// Add fallback for combined section from the schema adapter which attaches it to questionListItems directly
|
||||
|
questionListItems.forEach((item) => { |
||||
|
if (!progressBySlug.has(item.slug)) { |
||||
|
progressBySlug.set(item.slug, item.progress); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return progressBySlug; |
||||
|
}, [overview, questionListItems, localAssessmentProgress]); |
||||
|
|
||||
|
const requiredQuestionListItems = useMemo( |
||||
|
() => questionListItems.filter((item) => Boolean(item.required)), |
||||
|
[questionListItems], |
||||
|
); |
||||
|
|
||||
|
const completedRequiredSections = useMemo( |
||||
|
() => |
||||
|
requiredQuestionListItems.filter( |
||||
|
(item) => (sectionProgressBySlug.get(item.slug) ?? 0) >= 100, |
||||
|
).length, |
||||
|
[requiredQuestionListItems, sectionProgressBySlug], |
||||
|
); |
||||
|
const [displayedRequiredSections, setDisplayedRequiredSections] = useState( |
||||
|
() => completedRequiredSections, |
||||
|
); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (displayedRequiredSections === completedRequiredSections) return; |
||||
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { |
||||
|
setDisplayedRequiredSections(completedRequiredSections); |
||||
|
return; |
||||
|
} |
||||
|
const direction = |
||||
|
completedRequiredSections > displayedRequiredSections ? 1 : -1; |
||||
|
const distance = Math.abs( |
||||
|
completedRequiredSections - displayedRequiredSections, |
||||
|
); |
||||
|
const interval = window.setInterval( |
||||
|
() => { |
||||
|
setDisplayedRequiredSections((current) => { |
||||
|
const next = current + direction; |
||||
|
if (next === completedRequiredSections) |
||||
|
window.clearInterval(interval); |
||||
|
return next; |
||||
|
}); |
||||
|
}, |
||||
|
Math.max(90, Math.floor(500 / distance)), |
||||
|
); |
||||
|
return () => window.clearInterval(interval); |
||||
|
}, [completedRequiredSections, displayedRequiredSections]); |
||||
|
|
||||
|
const hasValidRequiredContract = |
||||
|
requiredQuestionListItems.length === REQUIRED_PROFILE_SECTION_COUNT; |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (overview && !hasValidRequiredContract) { |
||||
|
console.error("Required section contract mismatch", { |
||||
|
expected: REQUIRED_PROFILE_SECTION_COUNT, |
||||
|
actual: requiredQuestionListItems.length, |
||||
|
}); |
||||
|
} |
||||
|
}, [hasValidRequiredContract, overview, requiredQuestionListItems.length]); |
||||
|
|
||||
|
const allRequiredSectionsCompleted = useMemo(() => { |
||||
|
if (!hasValidRequiredContract) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
return requiredQuestionListItems.every((item) => { |
||||
|
const progress = sectionProgressBySlug.get(item.slug) ?? 0; |
||||
|
return progress >= 100; |
||||
|
}); |
||||
|
}, [ |
||||
|
hasValidRequiredContract, |
||||
|
requiredQuestionListItems, |
||||
|
sectionProgressBySlug, |
||||
|
]); |
||||
|
|
||||
|
const profileStatus = profile?.status; |
||||
|
const isProfileSuspended = profileStatus === "suspended"; |
||||
|
const isProfileBlocked = |
||||
|
profileStatus === "in_case" || profileStatus === "matched"; |
||||
|
const canStartMatch = |
||||
|
!isProfileSuspended && !isProfileBlocked && allRequiredSectionsCompleted; |
||||
|
const [isSyncError, setIsSyncError] = useState(false); |
||||
|
const [isSyncing, setIsSyncing] = useState(false); |
||||
|
const [toastMessage, setToastMessage] = useState<string | null>(null); |
||||
|
|
||||
|
const syncPromiseRef = useRef<Promise<void> | null>(null); |
||||
|
|
||||
|
const syncPendingAnswers = useCallback(async () => { |
||||
|
if (!overview) return; |
||||
|
if (syncPromiseRef.current) { |
||||
|
return syncPromiseRef.current; |
||||
|
} |
||||
|
|
||||
|
const task = (async () => { |
||||
|
const pendingSections: Array<{ |
||||
|
storageKey: string; |
||||
|
storedValue: { |
||||
|
current_step: number; |
||||
|
fields: MarriageField[]; |
||||
|
pending_keys: string[]; |
||||
|
pending_sync: boolean; |
||||
|
}; |
||||
|
fields: MarriageField[]; |
||||
|
slug: string; |
||||
|
}> = []; |
||||
|
for (const item of questionListItems) { |
||||
|
if ( |
||||
|
item.slug === "personality_test" || |
||||
|
item.slug === "glasser_5_needs_test" |
||||
|
) { |
||||
|
continue; |
||||
|
} |
||||
|
const storageKey = getQuestionAnswersStorageKey(item.slug); |
||||
|
const rawValue = window.localStorage.getItem(storageKey); |
||||
|
if (!rawValue) continue; |
||||
|
|
||||
|
let storedValue: any; |
||||
|
try { |
||||
|
storedValue = JSON.parse(rawValue); |
||||
|
} catch { |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
if ( |
||||
|
!storedValue || |
||||
|
!storedValue.pending_sync || |
||||
|
!Array.isArray(storedValue.fields) |
||||
|
) { |
||||
|
continue; |
||||
|
} |
||||
|
const pendingKeys = new Set<string>( |
||||
|
Array.isArray(storedValue.pending_keys) |
||||
|
? storedValue.pending_keys |
||||
|
: storedValue.fields.map((field: { key: string }) => field.key), |
||||
|
); |
||||
|
const pendingFields = storedValue.fields.filter( |
||||
|
(field: { key: string }) => pendingKeys.has(field.key), |
||||
|
); |
||||
|
if (pendingFields.length === 0) continue; |
||||
|
pendingSections.push({ |
||||
|
storageKey, |
||||
|
storedValue, |
||||
|
fields: pendingFields, |
||||
|
slug: item.slug, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
if (pendingSections.length === 0) return; |
||||
|
const result = await updateMarriageSectionData(pendingSections[0].slug, { |
||||
|
current_step: pendingSections[0].storedValue.current_step, |
||||
|
fields: pendingSections.flatMap((section) => section.fields), |
||||
|
}); |
||||
|
applyProfilePatchResultToCache(queryClient, locale, result); |
||||
|
const cleared = new Set(result.cleared_answer_ids ?? []); |
||||
|
|
||||
|
for (const { storageKey, storedValue } of pendingSections) { |
||||
|
storedValue.fields = storedValue.fields.filter( |
||||
|
(field: { key: string }) => !cleared.has(field.key), |
||||
|
); |
||||
|
storedValue.pending_sync = false; |
||||
|
storedValue.pending_keys = []; |
||||
|
if (storedValue.fields.length === 0) { |
||||
|
window.localStorage.removeItem(storageKey); |
||||
|
} else { |
||||
|
window.localStorage.setItem(storageKey, JSON.stringify(storedValue)); |
||||
|
} |
||||
|
} |
||||
|
})(); |
||||
|
|
||||
|
syncPromiseRef.current = task; |
||||
|
try { |
||||
|
await task; |
||||
|
setIsSyncError(false); |
||||
|
} finally { |
||||
|
syncPromiseRef.current = null; |
||||
|
} |
||||
|
}, [locale, overview, queryClient, questionListItems]); |
||||
|
|
||||
|
const prefetchSection = useCallback( |
||||
|
(item: QuestionListItem) => { |
||||
|
const href = localizePath(`/questions-list/${item.slug}`, locale); |
||||
|
router.prefetch(href); |
||||
|
if ( |
||||
|
item.slug === "personality_test" || |
||||
|
item.slug === "glasser_5_needs_test" |
||||
|
) |
||||
|
return; |
||||
|
void queryClient.prefetchQuery({ |
||||
|
queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), |
||||
|
queryFn: () => getFormSection("profile", item.slug, locale), |
||||
|
staleTime: 30 * 1000, |
||||
|
}); |
||||
|
}, |
||||
|
[locale, queryClient, router], |
||||
|
); |
||||
|
|
||||
|
const viewportPrefetchChain = useRef(Promise.resolve()); |
||||
|
const viewportPrefetchSlugs = useRef(new Set<string>()); |
||||
|
const enqueueViewportPrefetch = useCallback( |
||||
|
(item: QuestionListItem) => { |
||||
|
if ( |
||||
|
item.slug === "personality_test" || |
||||
|
item.slug === "glasser_5_needs_test" || |
||||
|
viewportPrefetchSlugs.current.has(item.slug) |
||||
|
) { |
||||
|
return; |
||||
|
} |
||||
|
viewportPrefetchSlugs.current.add(item.slug); |
||||
|
viewportPrefetchChain.current = viewportPrefetchChain.current |
||||
|
.catch(() => undefined) |
||||
|
.then(() => |
||||
|
queryClient.fetchQuery({ |
||||
|
queryKey: marriageQueryKeys.formSection( |
||||
|
"profile", |
||||
|
item.slug, |
||||
|
locale, |
||||
|
), |
||||
|
queryFn: () => getFormSection("profile", item.slug, locale), |
||||
|
staleTime: 30 * 1000, |
||||
|
}), |
||||
|
) |
||||
|
.then(() => undefined); |
||||
|
}, |
||||
|
[locale, queryClient], |
||||
|
); |
||||
|
|
||||
|
const prefetchQueueStarted = useRef(false); |
||||
|
useEffect(() => { |
||||
|
if (prefetchQueueStarted.current || !overview) return; |
||||
|
const profileSections = questionListItems |
||||
|
.filter( |
||||
|
(item) => |
||||
|
item.slug !== "personality_test" && |
||||
|
item.slug !== "glasser_5_needs_test", |
||||
|
) |
||||
|
.sort((first, second) => { |
||||
|
const firstPriority = |
||||
|
first.required && |
||||
|
(sectionProgressBySlug.get(first.slug) ?? first.progress) < 100 |
||||
|
? 0 |
||||
|
: 1; |
||||
|
const secondPriority = |
||||
|
second.required && |
||||
|
(sectionProgressBySlug.get(second.slug) ?? second.progress) < 100 |
||||
|
? 0 |
||||
|
: 1; |
||||
|
return firstPriority - secondPriority; |
||||
|
}); |
||||
|
if (profileSections.length === 0) return; |
||||
|
prefetchQueueStarted.current = true; |
||||
|
let cancelled = false; |
||||
|
void prefetchSectionsWithBoundedConcurrency( |
||||
|
profileSections, |
||||
|
(item) => |
||||
|
queryClient.fetchQuery({ |
||||
|
queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), |
||||
|
queryFn: () => getFormSection("profile", item.slug, locale), |
||||
|
staleTime: 30 * 1000, |
||||
|
}), |
||||
|
() => cancelled, |
||||
|
); |
||||
|
return () => { |
||||
|
cancelled = true; |
||||
|
}; |
||||
|
}, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
void syncPendingAnswers().catch((err) => { |
||||
|
console.warn("Background draft sync:", err); |
||||
|
}); |
||||
|
const handleOnline = () => { |
||||
|
void syncPendingAnswers().catch((err) => { |
||||
|
console.warn("Background draft sync on online:", err); |
||||
|
}); |
||||
|
}; |
||||
|
window.addEventListener("online", handleOnline); |
||||
|
return () => window.removeEventListener("online", handleOnline); |
||||
|
}, [syncPendingAnswers]); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (startMatchMutation.isError) { |
||||
|
setToastMessage( |
||||
|
t[ |
||||
|
"Sending the match request failed. Please check your connection and try again." |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
}, [startMatchMutation.isError, t]); |
||||
|
|
||||
|
const handleCloseToast = () => { |
||||
|
setToastMessage(null); |
||||
|
setIsSyncError(false); |
||||
|
startMatchMutation.reset(); |
||||
|
}; |
||||
|
|
||||
|
const isStartMatchDisabled = |
||||
|
startMatchMutation.isPending || isSyncing || !canStartMatch; |
||||
|
|
||||
|
const hasIncompleteOptionalSections = useMemo(() => { |
||||
|
return questionListItems.some( |
||||
|
(item) => |
||||
|
!item.required && (sectionProgressBySlug.get(item.slug) ?? 0) < 100, |
||||
|
); |
||||
|
}, [questionListItems, sectionProgressBySlug]); |
||||
|
|
||||
|
const handleStartMatch = async () => { |
||||
|
if (isStartMatchDisabled || isSyncing) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
setIsSyncing(true); |
||||
|
setIsSyncError(false); |
||||
|
|
||||
|
try { |
||||
|
await syncPendingAnswers(); |
||||
|
startMatchMutation.mutate(); |
||||
|
} catch (err) { |
||||
|
console.error("Failed to sync pending sections:", err); |
||||
|
setIsSyncError(true); |
||||
|
setToastMessage( |
||||
|
t[ |
||||
|
"Sending the match request failed. Please check your connection and try again." |
||||
|
] ?? "Sending the match request failed. Please check your connection and try again." |
||||
|
); |
||||
|
} finally { |
||||
|
setIsSyncing(false); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
if ( |
||||
|
isProfileLoading || |
||||
|
isSectionsLoading || |
||||
|
isSchemaLoading || |
||||
|
isProfileRedirecting |
||||
|
) { |
||||
|
return ( |
||||
|
<> |
||||
|
<PageBackground disabled /> |
||||
|
<main |
||||
|
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }} |
||||
|
className="-mx-[17px] relative min-h-screen overflow-x-clip bg-[#F5F5F5] px-[17px] pt-0" |
||||
|
> |
||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-[240px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.7)_42%,rgba(255,255,255,0)_100%)]" /> |
||||
|
<div className="pointer-events-none absolute -top-16 left-1/2 h-[220px] w-[220px] -translate-x-1/2 rounded-full bg-white/70 blur-3xl" /> |
||||
|
|
||||
|
<header |
||||
|
style={{ |
||||
|
paddingTop: "max(12px, calc(var(--safe-top) + 4px))", |
||||
|
}} |
||||
|
className="sticky top-0 z-20 -mx-[17px] flex items-center justify-between bg-[#F5F5F5]/90 px-[17px] pb-3 backdrop-blur-md" |
||||
|
> |
||||
|
<NavigationButton |
||||
|
icon="back" |
||||
|
iconLabel={t["Close questions list"]} |
||||
|
onClick={(e) => { |
||||
|
e.preventDefault(); |
||||
|
if (typeof window !== "undefined" && (window as any).HabibApp) { |
||||
|
(window as any).HabibApp.postMessage( |
||||
|
JSON.stringify({ action: "close_service" }), |
||||
|
); |
||||
|
} else { |
||||
|
router.push("/"); |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
<h1 className="group-16 font-semibold text-[#151515]"> |
||||
|
{t["Profile registration"]} |
||||
|
</h1> |
||||
|
<NavigationButton |
||||
|
icon="document" |
||||
|
iconLabel={t["Support"]} |
||||
|
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]" |
||||
|
/> |
||||
|
</header> |
||||
|
|
||||
|
<div className="relative mt-4 space-y-5"> |
||||
|
<RequiredStepsCard |
||||
|
completed={displayedRequiredSections} |
||||
|
total={REQUIRED_PROFILE_SECTION_COUNT} |
||||
|
/> |
||||
|
|
||||
|
{/* Section Card Skeletons (solid blocks like the Meet/checkup |
||||
|
AppShimmer loading — one sweep band runs across each card) */} |
||||
|
<div className="space-y-3"> |
||||
|
{Array.from({ length: 6 }).map((_, idx) => ( |
||||
|
<div key={idx} className="shimmer-bg h-[84px] rounded-[20px]" /> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<FixToTheEnd> |
||||
|
<Button aria-label={t["Find Matches"]} disabled> |
||||
|
<span className="flex items-center justify-center gap-2.5"> |
||||
|
<Image |
||||
|
src="/assets/images/noun-wedding-rings-6540466 1.svg" |
||||
|
alt="" |
||||
|
aria-hidden="true" |
||||
|
width={28} |
||||
|
height={28} |
||||
|
className="shrink-0" |
||||
|
loading="eager" |
||||
|
fetchPriority="high" |
||||
|
/> |
||||
|
<span className="leading-none font-semibold"> |
||||
|
{t["Submit"]} |
||||
|
</span> |
||||
|
</span> |
||||
|
</Button> |
||||
|
</FixToTheEnd> |
||||
|
</main> |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (isProfileError || isSchemaError) { |
||||
|
return ( |
||||
|
<> |
||||
|
<PageBackground disabled /> |
||||
|
<main |
||||
|
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }} |
||||
|
className="-mx-[17px] relative min-h-screen overflow-x-clip bg-[#F5F5F5] px-[17px] pt-0" |
||||
|
> |
||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-[240px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.7)_42%,rgba(255,255,255,0)_100%)]" /> |
||||
|
<div className="pointer-events-none absolute -top-16 left-1/2 h-[220px] w-[220px] -translate-x-1/2 rounded-full bg-white/70 blur-3xl" /> |
||||
|
|
||||
|
<header |
||||
|
style={{ |
||||
|
paddingTop: "max(12px, calc(var(--safe-top) + 4px))", |
||||
|
}} |
||||
|
className="sticky top-0 z-20 -mx-[17px] flex items-center justify-between bg-[#F5F5F5]/90 px-[17px] pb-3 backdrop-blur-md" |
||||
|
> |
||||
|
<NavigationButton |
||||
|
icon="back" |
||||
|
iconLabel={t["Close questions list"]} |
||||
|
onClick={(e) => { |
||||
|
e.preventDefault(); |
||||
|
if (typeof window !== "undefined" && (window as any).HabibApp) { |
||||
|
(window as any).HabibApp.postMessage( |
||||
|
JSON.stringify({ action: "close_service" }), |
||||
|
); |
||||
|
} else { |
||||
|
router.push("/"); |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
<h1 className="group-16 font-semibold text-[#151515]"> |
||||
|
{t["Profile registration"]} |
||||
|
</h1> |
||||
|
<NavigationButton |
||||
|
icon="document" |
||||
|
iconLabel={t["Support"]} |
||||
|
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]" |
||||
|
/> |
||||
|
</header> |
||||
|
|
||||
|
<DataErrorState |
||||
|
onRetry={() => { |
||||
|
if (isProfileError) refetchProfile(); |
||||
|
if (isSchemaError) refetchOverview(); |
||||
|
}} |
||||
|
/> |
||||
|
</main> |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<> |
||||
|
{toastMessage && ( |
||||
|
<ErrorToast message={toastMessage} onClose={handleCloseToast} /> |
||||
|
)} |
||||
|
{isOptionalInfoSheetOpen ? ( |
||||
|
<InformationSheet |
||||
|
icon="warning" |
||||
|
title={t["Important Note"]} |
||||
|
description={ |
||||
|
<p className="px-0.5 text-sm leading-[1.45] text-[#2D2D2D]"> |
||||
|
{ |
||||
|
t[ |
||||
|
"You've completed all required fields. However, filling in all sections will help us find better matches for you" |
||||
|
] |
||||
|
} |
||||
|
</p> |
||||
|
} |
||||
|
onClose={() => setIsOptionalInfoSheetOpen(false)} |
||||
|
buttons={({ close }) => ( |
||||
|
<div className="grid w-full grid-cols-[33fr_67fr] gap-3"> |
||||
|
<Button |
||||
|
className="rounded-[11px] border border-[#9B9B9B] bg-transparent text-[#8B8B8B] min-w-0" |
||||
|
onClick={close} |
||||
|
variant="outlined" |
||||
|
> |
||||
|
<span className="truncate">{t["Cancel"]}</span> |
||||
|
</Button> |
||||
|
<Button |
||||
|
className="rounded-[11px] min-w-0" |
||||
|
disabled={isStartMatchDisabled} |
||||
|
onClick={() => { |
||||
|
close(); |
||||
|
handleStartMatch(); |
||||
|
}} |
||||
|
> |
||||
|
<span className="truncate">{t["Submit"]}</span> |
||||
|
</Button> |
||||
|
</div> |
||||
|
)} |
||||
|
/> |
||||
|
) : null} |
||||
|
{selectedSection ? ( |
||||
|
<InformationSheet |
||||
|
icon={null} |
||||
|
title={({ close }) => ( |
||||
|
<span className="flex w-full items-start justify-between gap-3 text-left"> |
||||
|
<span className="group-14 leading-5 font-bold tracking-normal text-[#8B8B8B]"> |
||||
|
{selectedSection.title} |
||||
|
</span> |
||||
|
<button |
||||
|
type="button" |
||||
|
aria-label={`Close ${selectedSection.title} explanation`} |
||||
|
className="-mt-0.5 flex size-6 shrink-0 items-center justify-center text-[#8F8F8F]" |
||||
|
onClick={close} |
||||
|
> |
||||
|
<IoClose aria-hidden="true" className="text-[22px]" /> |
||||
|
</button> |
||||
|
</span> |
||||
|
)} |
||||
|
description={ |
||||
|
<p className="px-0.5 group-14 leading-[1.45] text-[#2D2D2D]"> |
||||
|
{selectedSection.summary} |
||||
|
</p> |
||||
|
} |
||||
|
onClose={() => setSelectedSection(null)} |
||||
|
className="text-left" |
||||
|
/> |
||||
|
) : null} |
||||
|
<SectionsRequest sections={overview?.sections} /> |
||||
|
<PageBackground disabled /> |
||||
|
|
||||
|
<main |
||||
|
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }} |
||||
|
className="-mx-[17px] relative min-h-screen overflow-x-clip bg-[#F5F5F5] px-[17px] pt-0" |
||||
|
> |
||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-[240px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.7)_42%,rgba(255,255,255,0)_100%)]" /> |
||||
|
<div className="pointer-events-none absolute -top-16 left-1/2 h-[220px] w-[220px] -translate-x-1/2 rounded-full bg-white/70 blur-3xl" /> |
||||
|
|
||||
|
<header |
||||
|
style={{ |
||||
|
paddingTop: "max(12px, calc(var(--safe-top) + 4px))", |
||||
|
}} |
||||
|
className="sticky top-0 z-20 -mx-[17px] flex items-center justify-between bg-[#F5F5F5]/90 px-[17px] pb-3 backdrop-blur-md" |
||||
|
> |
||||
|
<NavigationButton |
||||
|
icon="back" |
||||
|
iconLabel={t["Close questions list"]} |
||||
|
onClick={(e) => { |
||||
|
e.preventDefault(); |
||||
|
if (typeof window !== "undefined" && (window as any).HabibApp) { |
||||
|
(window as any).HabibApp.postMessage( |
||||
|
JSON.stringify({ action: "close_service" }), |
||||
|
); |
||||
|
} else { |
||||
|
router.push("/"); |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
<h1 className="group-16 font-semibold text-[#151515]"> |
||||
|
{t["Profile registration"]} |
||||
|
</h1> |
||||
|
<NavigationButton |
||||
|
icon="document" |
||||
|
iconLabel={t["Support"]} |
||||
|
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]" |
||||
|
/> |
||||
|
</header> |
||||
|
|
||||
|
<div className="relative"> |
||||
|
<div className="mt-4"> |
||||
|
<RequiredStepsCard |
||||
|
completed={displayedRequiredSections} |
||||
|
total={REQUIRED_PROFILE_SECTION_COUNT} |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<section className="mt-5 space-y-3"> |
||||
|
{questionListItems.map((item) => ( |
||||
|
<QuestionCard |
||||
|
key={item.slug} |
||||
|
item={item} |
||||
|
progress={sectionProgressBySlug.get(item.slug) ?? null} |
||||
|
onInfoClick={(section) => setSelectedSection(section)} |
||||
|
onNearViewport={enqueueViewportPrefetch} |
||||
|
onPrefetch={prefetchSection} |
||||
|
/> |
||||
|
))} |
||||
|
</section> |
||||
|
</div> |
||||
|
|
||||
|
<FixToTheEnd> |
||||
|
<Button |
||||
|
aria-label={t["Find Matches"]} |
||||
|
disabled={isStartMatchDisabled} |
||||
|
isLoading={startMatchMutation.isPending || isSyncing} |
||||
|
onClick={() => { |
||||
|
if (hasIncompleteOptionalSections) { |
||||
|
setIsOptionalInfoSheetOpen(true); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
handleStartMatch(); |
||||
|
}} |
||||
|
> |
||||
|
<span className="flex items-center justify-center gap-2.5"> |
||||
|
<Image |
||||
|
src="/assets/images/noun-wedding-rings-6540466 1.svg" |
||||
|
alt="" |
||||
|
aria-hidden="true" |
||||
|
width={28} |
||||
|
height={28} |
||||
|
className="shrink-0" |
||||
|
// Above-the-fold CTA asset: fetch it with the initial
|
||||
|
// document instead of letting next/image lazy-load it.
|
||||
|
loading="eager" |
||||
|
fetchPriority="high" |
||||
|
/> |
||||
|
<span className="leading-none font-semibold">{t["Submit"]}</span> |
||||
|
</span> |
||||
|
</Button> |
||||
|
</FixToTheEnd> |
||||
|
</main> |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
@ -1,753 +1,39 @@ |
|||||
"use client"; |
|
||||
|
|
||||
import Image from "next/image"; |
|
||||
import Link from "next/link"; |
|
||||
import { useRouter } from "next/navigation"; |
|
||||
import { useEffect, useMemo, useState } from "react"; |
|
||||
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; |
|
||||
import CallResultSheet from "@/components/Componentes/call-result-sheet"; |
|
||||
import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet"; |
|
||||
import FemaleConsentSheet from "@/components/Componentes/female-consent-sheet"; |
|
||||
import FemaleOutcomeSheet from "@/components/Componentes/female-outcome-sheet"; |
|
||||
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot"; |
|
||||
import OutcomeSelectionSheet from "@/components/Componentes/outcome-selection-sheet"; |
|
||||
import { PageBackground } from "@/components/Componentes/page-background"; |
|
||||
import PageHeader from "@/components/Componentes/page-header"; |
|
||||
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; |
|
||||
import SubscriptionRequiredSheet from "@/components/Componentes/subscription-required-sheet"; |
|
||||
import SwipeButton from "@/components/Componentes/swipe-button"; |
|
||||
import type { |
|
||||
MarriageField, |
|
||||
MarriagePhoneFieldValue, |
|
||||
} from "@/hooks/marriage/types"; |
|
||||
import { useMarriageContactInfoQuery } from "@/hooks/marriage/use-contact-info"; |
|
||||
import { |
|
||||
useSubmitMarriageContactStatusMutation, |
|
||||
useSubmitMarriageOutcomeMutation, |
|
||||
} from "@/hooks/marriage/use-contact-status"; |
|
||||
|
import { cookies } from "next/headers"; |
||||
import { |
import { |
||||
extractHabcoinPaymentUrl, |
|
||||
useHabcoinPaymentMutation, |
|
||||
} from "@/hooks/marriage/use-habcoin-payment"; |
|
||||
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
|
||||
import { getSubmitPath } from "@/lib/get-submit-path"; |
|
||||
import { localizePath } from "@/translations/config"; |
|
||||
import { useI18n } from "@/translations/provider"; |
|
||||
|
|
||||
const advisorAvatars = [ |
|
||||
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" }, |
|
||||
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" }, |
|
||||
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" }, |
|
||||
]; |
|
||||
|
|
||||
type ContactInfoPhoneItem = { |
|
||||
key: string; |
|
||||
label: string; |
|
||||
phoneNumber: string; |
|
||||
}; |
|
||||
|
|
||||
function sanitizePhoneNumber(value: MarriageField["value"]) { |
|
||||
if (value === null || value === "") { |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
if (isMarriagePhoneFieldValue(value)) { |
|
||||
const digits = value.phoneNumber.replace(/\D/g, ""); |
|
||||
|
|
||||
return digits ? `+${value.countryCode}${digits}` : null; |
|
||||
} |
|
||||
|
|
||||
const trimmedValue = String(value).trim(); |
|
||||
|
|
||||
if (!trimmedValue) { |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
const digits = trimmedValue.replace(/\D/g, ""); |
|
||||
|
|
||||
if (!digits) { |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
return trimmedValue.startsWith("+") ? `+${digits}` : digits; |
|
||||
} |
|
||||
|
|
||||
function isMarriagePhoneFieldValue( |
|
||||
value: unknown, |
|
||||
): value is MarriagePhoneFieldValue { |
|
||||
if (!value || typeof value !== "object") { |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
const phoneValue = value as Partial<MarriagePhoneFieldValue>; |
|
||||
|
|
||||
return ( |
|
||||
typeof phoneValue.countryCode === "string" && |
|
||||
typeof phoneValue.phoneNumber === "string" |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
function getContactInfoPhoneItems( |
|
||||
contactInfoFields: MarriageField[] | null | undefined, |
|
||||
): ContactInfoPhoneItem[] { |
|
||||
if (!contactInfoFields) { |
|
||||
return []; |
|
||||
} |
|
||||
|
|
||||
return contactInfoFields |
|
||||
.map((field) => { |
|
||||
const phoneNumber = sanitizePhoneNumber(field.value); |
|
||||
|
|
||||
if (!phoneNumber) { |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
const rawLabel = field.label || field.key; |
|
||||
const label = rawLabel |
|
||||
.replace(/\s+with\s+Country\s+Code/gi, "") |
|
||||
.replace(/\s+با\s+کد\s+کشور/g, "") |
|
||||
.trim(); |
|
||||
|
|
||||
return { |
|
||||
key: field.key, |
|
||||
label, |
|
||||
phoneNumber, |
|
||||
}; |
|
||||
}) |
|
||||
.filter((item): item is ContactInfoPhoneItem => item !== null); |
|
||||
} |
|
||||
|
|
||||
function ContactInfoPhoneCard({ item }: { item: ContactInfoPhoneItem }) { |
|
||||
return ( |
|
||||
<div className="flex items-center justify-between rounded-[15px] border border-[#E4E4E4] bg-[#FBFBFB] px-4 py-3.5 shadow-[0_4px_12px_rgba(0,0,0,0.03)] gap-4"> |
|
||||
<div className="text-left min-w-0"> |
|
||||
<p className="text-xs font-semibold text-[#8F8F8F] break-words"> |
|
||||
{item.label} |
|
||||
</p> |
|
||||
<p className="text-base font-bold text-[#1F1F1F] dir-ltr"> |
|
||||
{item.phoneNumber} |
|
||||
</p> |
|
||||
</div> |
|
||||
|
|
||||
<div className="flex items-center gap-2 shrink-0"> |
|
||||
<a |
|
||||
href={`tel:${item.phoneNumber}`} |
|
||||
onClick={(e) => { |
|
||||
if (typeof window !== "undefined" && "HabibApp" in window) { |
|
||||
e.preventDefault(); |
|
||||
try { |
|
||||
const app = ( |
|
||||
window as Window & { |
|
||||
HabibApp?: { postMessage: (msg: string) => void }; |
|
||||
} |
|
||||
).HabibApp; |
|
||||
app?.postMessage( |
|
||||
JSON.stringify({ |
|
||||
action: "open_external_url", |
|
||||
data: { |
|
||||
url: `tel:${item.phoneNumber}`, |
|
||||
mode: "externalApplication", |
|
||||
title: "phone_call", |
|
||||
}, |
|
||||
}), |
|
||||
); |
|
||||
} catch (err) { |
|
||||
console.error("Error calling HabibApp bridge", err); |
|
||||
} |
|
||||
} |
|
||||
}} |
|
||||
className="inline-flex p-3 items-center justify-center rounded-[10px] bg-[#F0445B] text-white shrink-0" |
|
||||
> |
|
||||
<Image |
|
||||
src={"/assets/images/Vecfdastor.svg"} |
|
||||
width={16} |
|
||||
height={16} |
|
||||
alt="call" |
|
||||
/> |
|
||||
</a> |
|
||||
</div> |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
export default function RequestAcceptedPage() { |
|
||||
const { dictionary: t, locale } = useI18n(); |
|
||||
const router = useRouter(); |
|
||||
const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false); |
|
||||
const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] = |
|
||||
useState(false); |
|
||||
const [isContactInfoSheetOpen, setIsContactInfoSheetOpen] = useState(false); |
|
||||
const [isSubscriptionSheetOpen, setIsSubscriptionSheetOpen] = useState(false); |
|
||||
const [paymentError, setPaymentError] = useState<string | null>(null); |
|
||||
const [isInsufficientCoins, setIsInsufficientCoins] = useState(false); |
|
||||
const [isOutcomeSheetOpen, setIsOutcomeSheetOpen] = useState(false); |
|
||||
const [isNoContactConfirmOpen, setIsNoContactConfirmOpen] = useState(false); |
|
||||
const [isContactReceivedConfirmOpen, setIsContactReceivedConfirmOpen] = |
|
||||
useState(false); |
|
||||
const [noContactReportedSuccess, setNoContactReportedSuccess] = |
|
||||
useState(false); |
|
||||
const [hasConfirmedFemaleContact, setHasConfirmedFemaleContact] = |
|
||||
useState(false); |
|
||||
const profileHref = localizePath("/new-match/profile", locale); |
|
||||
const { data: profile, isLoading } = useMarriageProfileQuery({ |
|
||||
refetchInterval: 3000, |
|
||||
}); |
|
||||
|
|
||||
const isFemaleProfile = profile?.gender === "female"; |
|
||||
const contactSharedAtStr = profile?.active_case?.contact_shared_at; |
|
||||
|
|
||||
useEffect(() => { |
|
||||
if (!profile || noContactReportedSuccess) { |
|
||||
return; |
|
||||
} |
|
||||
const targetPath = getSubmitPath(profile); |
|
||||
if (targetPath !== "/request-accepted") { |
|
||||
router.replace(localizePath(targetPath, locale)); |
|
||||
} |
|
||||
}, [profile, router, locale, noContactReportedSuccess]); |
|
||||
|
|
||||
const isRedirecting = useMemo(() => { |
|
||||
if (!profile) return false; |
|
||||
return getSubmitPath(profile) !== "/request-accepted"; |
|
||||
}, [profile]); |
|
||||
|
|
||||
const caseId = profile?.active_case?.case_id; |
|
||||
const caseStatus = profile?.active_case?.status; |
|
||||
const isFemaleContactConfirmed = |
|
||||
isFemaleProfile && |
|
||||
(caseStatus === "contacted" || hasConfirmedFemaleContact); |
|
||||
const recommendedPlanId = profile?.recommended_plan?.id; |
|
||||
const paymentMutation = useHabcoinPaymentMutation(); |
|
||||
const outcomeMutation = useSubmitMarriageOutcomeMutation(caseId ?? ""); |
|
||||
const contactStatusMutation = useSubmitMarriageContactStatusMutation( |
|
||||
caseId ?? "", |
|
||||
{ |
|
||||
onSuccess: (_data, variables) => { |
|
||||
if (variables?.action === "no_contact") { |
|
||||
setNoContactReportedSuccess(true); |
|
||||
} else { |
|
||||
if (!isFemaleProfile) { |
|
||||
router.push(localizePath("/finding-match", locale)); |
|
||||
} |
|
||||
} |
|
||||
}, |
|
||||
|
dehydrate, |
||||
|
HydrationBoundary, |
||||
|
QueryClient, |
||||
|
} from "@tanstack/react-query"; |
||||
|
import { isAuthenticatedToken } from "@/lib/entry-route-cache"; |
||||
|
import { fetchProfileSSR } from "@/lib/ssr-fetch"; |
||||
|
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; |
||||
|
import RequestAcceptedClient from "./request-accepted-client"; |
||||
|
|
||||
|
export const dynamic = "force-dynamic"; |
||||
|
|
||||
|
export default async function RequestAcceptedPage() { |
||||
|
const cookieStore = await cookies(); |
||||
|
|
||||
|
const token = |
||||
|
cookieStore.get("HABIB_TOKEN")?.value ?? |
||||
|
cookieStore.get("habib_token")?.value; |
||||
|
|
||||
|
const queryClient = new QueryClient({ |
||||
|
defaultOptions: { |
||||
|
queries: { staleTime: 10 * 1000 }, |
||||
}, |
}, |
||||
); |
|
||||
const contactInfoQuery = useMarriageContactInfoQuery(caseId, { |
|
||||
enabled: false, |
|
||||
}); |
}); |
||||
|
|
||||
if (isLoading || isRedirecting) { |
|
||||
return <PageLoadingSkeleton />; |
|
||||
} |
|
||||
|
|
||||
const titleText = isFemaleProfile |
|
||||
? t["Request Approved"] |
|
||||
: caseStatus === "payment_done" || caseStatus === "contacted" |
|
||||
? t["Contact info released"] |
|
||||
: t["Request approved!"]; |
|
||||
const primaryActionText = isFemaleProfile |
|
||||
? t["No Contact Received"] |
|
||||
: t["View profile"]; |
|
||||
const secondaryActionText = isFemaleProfile |
|
||||
? t["Contact Received"] |
|
||||
: caseStatus === "payment_done" || caseStatus === "contacted" |
|
||||
? t["View contact number"] |
|
||||
: t["Pay and get contact"]; |
|
||||
const contactInfoPhoneItems = getContactInfoPhoneItems( |
|
||||
contactInfoQuery.data?.contact_info, |
|
||||
); |
|
||||
|
|
||||
const handleSecondaryAction = async () => { |
|
||||
if (isFemaleProfile) { |
|
||||
setIsContactReceivedConfirmOpen(true); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
if (caseStatus === "female_accepted" || caseStatus === "payment_pending") { |
|
||||
setIsSubscriptionSheetOpen(true); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
if (caseStatus === "payment_done" || caseStatus === "contacted") { |
|
||||
if (!caseId) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
if (!contactInfoQuery.data) { |
|
||||
await contactInfoQuery.refetch(); |
|
||||
} |
|
||||
|
|
||||
setIsContactInfoSheetOpen(true); |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
const handlePayment = async () => { |
|
||||
if (!recommendedPlanId || paymentMutation.isPending) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
setPaymentError(null); |
|
||||
setIsInsufficientCoins(false); |
|
||||
const paymentResponse = |
|
||||
await paymentMutation.mutateAsync(recommendedPlanId); |
|
||||
const paymentUrl = extractHabcoinPaymentUrl(paymentResponse); |
|
||||
|
|
||||
if (paymentUrl) { |
|
||||
window.location.assign(paymentUrl); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
setIsSubscriptionSheetOpen(false); |
|
||||
|
|
||||
if (caseId) { |
|
||||
await contactInfoQuery.refetch(); |
|
||||
setIsContactInfoSheetOpen(true); |
|
||||
} |
|
||||
} catch (err: any) { |
|
||||
console.error("Habcoin payment request failed", err); |
|
||||
const msg = |
|
||||
err?.response?.data?.error || err?.message || "Payment failed"; |
|
||||
if (msg === "Not enough coins") { |
|
||||
setIsInsufficientCoins(true); |
|
||||
setPaymentError( |
|
||||
t["Insufficient coin balance. Please recharge your account."] || |
|
||||
"Insufficient coin balance. Please recharge your account.", |
|
||||
); |
|
||||
} else { |
|
||||
setPaymentError(msg); |
|
||||
} |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
const handleNoContactReport = async () => { |
|
||||
if (!caseId || contactStatusMutation.isPending) return; |
|
||||
await contactStatusMutation.mutateAsync({ |
|
||||
action: "no_contact", |
|
||||
custom_note: |
|
||||
"No contact reported by female candidate after decision window", |
|
||||
|
if (isAuthenticatedToken(token)) { |
||||
|
await queryClient.prefetchQuery({ |
||||
|
queryKey: marriageQueryKeys.profile(), |
||||
|
queryFn: () => fetchProfileSSR(token!), |
||||
}); |
}); |
||||
}; |
|
||||
|
|
||||
const isFinalized = |
|
||||
caseStatus === "finalized" || profile?.status === "matched"; |
|
||||
|
} |
||||
|
|
||||
return ( |
return ( |
||||
<> |
|
||||
<PageBackground /> |
|
||||
|
|
||||
{isCallResultSheetOpen ? ( |
|
||||
<CallResultSheet |
|
||||
onClose={() => setIsCallResultSheetOpen(false)} |
|
||||
onOtherReasonsClick={() => setIsDismissReasonSheetOpen(true)} |
|
||||
onSubmit={async (value) => { |
|
||||
if (caseId) { |
|
||||
await contactStatusMutation.mutateAsync({ |
|
||||
action: "contacted", |
|
||||
custom_note: value, |
|
||||
}); |
|
||||
} |
|
||||
}} |
|
||||
/> |
|
||||
) : null} |
|
||||
|
|
||||
{isContactReceivedConfirmOpen ? ( |
|
||||
<FemaleConsentSheet |
|
||||
title={t.Confirm} |
|
||||
description={ |
|
||||
<p className="font-bold text-[#00AC78] text-center my-4 text-base"> |
|
||||
{t["Are you sure contact has been made?"]} |
|
||||
</p> |
|
||||
} |
|
||||
buttons={ |
|
||||
<SwipeButton |
|
||||
theme="green" |
|
||||
text={t["Confirm"]} |
|
||||
onCancel={() => setIsContactReceivedConfirmOpen(false)} |
|
||||
onSuccess={async () => { |
|
||||
setIsContactReceivedConfirmOpen(false); |
|
||||
setHasConfirmedFemaleContact(true); |
|
||||
|
|
||||
if (!caseId) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
await contactStatusMutation.mutateAsync({ |
|
||||
action: "contacted", |
|
||||
custom_note: |
|
||||
"Contact received confirmed by female candidate", |
|
||||
}); |
|
||||
} catch (error) { |
|
||||
// The confirmation screen must advance immediately after a swipe.
|
|
||||
// Keep the local state visible while the profile query retries.
|
|
||||
console.error("Unable to persist received contact", error); |
|
||||
} |
|
||||
}} |
|
||||
/> |
|
||||
} |
|
||||
onClose={() => setIsContactReceivedConfirmOpen(false)} |
|
||||
closeOnOutside={true} |
|
||||
/> |
|
||||
) : null} |
|
||||
|
|
||||
{isDismissReasonSheetOpen ? ( |
|
||||
<DismissReasonSheet |
|
||||
onClose={() => setIsDismissReasonSheetOpen(false)} |
|
||||
onSubmit={async (value) => { |
|
||||
if (caseId) { |
|
||||
await outcomeMutation.mutateAsync({ |
|
||||
status: "failure", |
|
||||
custom_note: value, |
|
||||
}); |
|
||||
} |
|
||||
}} |
|
||||
/> |
|
||||
) : null} |
|
||||
|
|
||||
{isOutcomeSheetOpen ? ( |
|
||||
isFemaleProfile ? ( |
|
||||
<FemaleOutcomeSheet |
|
||||
onClose={() => setIsOutcomeSheetOpen(false)} |
|
||||
onSubmit={async (status, reason) => { |
|
||||
if (status === "success") { |
|
||||
// If they confirm they are in the acquaintance/proposal process and nothing is finalized yet:
|
|
||||
// No change is made to the profile, we just close the sheet.
|
|
||||
setIsOutcomeSheetOpen(false); |
|
||||
} else { |
|
||||
// If they cancel:
|
|
||||
if (caseId) { |
|
||||
await outcomeMutation.mutateAsync({ |
|
||||
status: "failure", |
|
||||
custom_note: reason, |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
}} |
|
||||
/> |
|
||||
) : ( |
|
||||
<OutcomeSelectionSheet |
|
||||
onClose={() => setIsOutcomeSheetOpen(false)} |
|
||||
onSubmit={async (status) => { |
|
||||
if (status === "success") { |
|
||||
if (caseId) { |
|
||||
await outcomeMutation.mutateAsync({ |
|
||||
status: "success", |
|
||||
}); |
|
||||
} |
|
||||
} else { |
|
||||
setIsDismissReasonSheetOpen(true); |
|
||||
} |
|
||||
}} |
|
||||
/> |
|
||||
) |
|
||||
) : null} |
|
||||
|
|
||||
{isContactInfoSheetOpen ? ( |
|
||||
<FemaleConsentSheet |
|
||||
title={t["Contact Detail"]} |
|
||||
description={ |
|
||||
t[ |
|
||||
"Please mention during the call that you were introduced by the Habib Marriage app." |
|
||||
] |
|
||||
} |
|
||||
buttons={ |
|
||||
contactInfoPhoneItems.length ? ( |
|
||||
<div className="space-y-4"> |
|
||||
{contactInfoPhoneItems.map((item) => ( |
|
||||
<ContactInfoPhoneCard key={item.key} item={item} /> |
|
||||
))} |
|
||||
</div> |
|
||||
) : ( |
|
||||
<div className="rounded-[12px] bg-[#ECECEC] px-4 py-3 group-12 font-semibold text-[#555]"> |
|
||||
{t["Contact information is not available yet."]} |
|
||||
</div> |
|
||||
) |
|
||||
} |
|
||||
onClose={() => setIsContactInfoSheetOpen(false)} |
|
||||
/> |
|
||||
) : null} |
|
||||
|
|
||||
{isSubscriptionSheetOpen ? ( |
|
||||
<SubscriptionRequiredSheet |
|
||||
onClose={() => { |
|
||||
setIsSubscriptionSheetOpen(false); |
|
||||
setPaymentError(null); |
|
||||
setIsInsufficientCoins(false); |
|
||||
}} |
|
||||
onPayment={handlePayment} |
|
||||
isPaymentPending={!recommendedPlanId || paymentMutation.isPending} |
|
||||
errorMessage={paymentError} |
|
||||
showBuyCoins={isInsufficientCoins} |
|
||||
/> |
|
||||
) : null} |
|
||||
|
|
||||
{isNoContactConfirmOpen ? ( |
|
||||
<FemaleConsentSheet |
|
||||
title={t.Confirm} |
|
||||
description={ |
|
||||
<p className="font-bold text-[#E03950] text-center my-4 text-base"> |
|
||||
{ |
|
||||
t[ |
|
||||
"No contact has been made with you in any way or by any party." |
|
||||
] |
|
||||
} |
|
||||
</p> |
|
||||
} |
|
||||
buttons={ |
|
||||
<button |
|
||||
type="button" |
|
||||
onClick={async () => { |
|
||||
setIsNoContactConfirmOpen(false); |
|
||||
await handleNoContactReport(); |
|
||||
}} |
|
||||
className="w-full h-[48px] rounded-[15px] bg-[#E03950] text-white font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95" |
|
||||
> |
|
||||
{t.Confirm} |
|
||||
</button> |
|
||||
} |
|
||||
onClose={() => setIsNoContactConfirmOpen(false)} |
|
||||
closeOnOutside={true} |
|
||||
/> |
|
||||
) : null} |
|
||||
|
|
||||
<main className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-[calc(20px+var(--safe-bottom))] text-center"> |
|
||||
<PageHeader className="-mx-[6px]" /> |
|
||||
|
|
||||
<div className="flex flex-1 flex-col justify-between gap-6 pt-4"> |
|
||||
<section className="flex flex-col items-center"> |
|
||||
{isFinalized ? ( |
|
||||
<div className="flex flex-col items-center max-w-[320px] text-center my-auto py-12"> |
|
||||
<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["Congratulations! 🎉"]} |
|
||||
</h1> |
|
||||
<p className="mt-6 text-[#4A4A4A] group-14 font-medium leading-relaxed"> |
|
||||
{ |
|
||||
t[ |
|
||||
"Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed." |
|
||||
] |
|
||||
} |
|
||||
</p> |
|
||||
</div> |
|
||||
) : ( |
|
||||
<> |
|
||||
<div className="relative isolate flex items-center justify-center"> |
|
||||
<Image |
|
||||
src="/assets/images/Group 15978804fdasf68.svg" |
|
||||
alt={t["Request accepted"]} |
|
||||
width={131} |
|
||||
height={125} |
|
||||
priority |
|
||||
className="relative z-10" |
|
||||
/> |
|
||||
</div> |
|
||||
|
|
||||
<h1 className="mt-11 group-16 leading-none font-black tracking-[0.02em] text-[#171717] uppercase"> |
|
||||
{titleText} |
|
||||
</h1> |
|
||||
|
|
||||
{caseStatus === "contacted" || |
|
||||
isFemaleContactConfirmed || |
|
||||
(isFemaleProfile && contactStatusMutation.isPending) ? ( |
|
||||
<div className="w-full border border-[#ECECEC] bg-white rounded-[15px] mt-6 px-4 py-4 text-center shadow-sm max-w-[315px] flex flex-col items-center justify-center min-h-[100px]"> |
|
||||
{isFemaleProfile && contactStatusMutation.isPending ? ( |
|
||||
<LoadingThreeDot className="text-[#E03950]" /> |
|
||||
) : ( |
|
||||
<p className="text-[#555555] group-14 font-medium leading-relaxed"> |
|
||||
{isFemaleProfile |
|
||||
? t[ |
|
||||
"Thank you for your feedback. To complete the process, please submit the final outcome of this introduction/contact so that the final status can be determined. If the final status is not yet determined, you can stay in this state until it is finalized." |
|
||||
] |
|
||||
: t[ |
|
||||
"Thank you for giving us feedback, we would be very happy if you also let us know the final result." |
|
||||
]} |
|
||||
</p> |
|
||||
)} |
|
||||
</div> |
|
||||
) : ( |
|
||||
<p className="mt-4 max-w-[315px] group-12 leading-[1.45] font-semibold text-[#777777]"> |
|
||||
{noContactReportedSuccess |
|
||||
? t[ |
|
||||
"Thank you for your feedback. Our support team will investigate the matter and notify you of the result. Please wait patiently during the review; our support will contact you." |
|
||||
] |
|
||||
: isFemaleProfile |
|
||||
? t[ |
|
||||
"The selected candidate will contact your family shortly." |
|
||||
] |
|
||||
: t[ |
|
||||
"You can now view their family's contact details and arrange further steps." |
|
||||
]} |
|
||||
</p> |
|
||||
)} |
|
||||
|
|
||||
<> |
|
||||
{caseStatus === "contacted" || |
|
||||
isFemaleContactConfirmed || |
|
||||
(isFemaleProfile && contactStatusMutation.isPending) ? ( |
|
||||
<div className="flex mt-8 w-full gap-3 justify-center"> |
|
||||
{isFemaleProfile && |
|
||||
contactStatusMutation.isPending ? null : isFemaleProfile ? ( |
|
||||
<button |
|
||||
type="button" |
|
||||
onClick={() => setIsOutcomeSheetOpen(true)} |
|
||||
disabled={outcomeMutation.isPending} |
|
||||
className="w-full max-w-[315px] 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 ? ( |
|
||||
<LoadingThreeDot className="text-white" /> |
|
||||
) : ( |
|
||||
t["Share Result"] |
|
||||
)} |
|
||||
</button> |
|
||||
) : ( |
|
||||
<> |
|
||||
<button |
|
||||
type="button" |
|
||||
onClick={() => |
|
||||
router.push( |
|
||||
localizePath("/new-match/profile", locale), |
|
||||
) |
|
||||
} |
|
||||
className="flex-1 h-[52px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#F5F5F5]" |
|
||||
> |
|
||||
{t["View Profile"]} |
|
||||
</button> |
|
||||
|
|
||||
<button |
|
||||
type="button" |
|
||||
onClick={() => setIsOutcomeSheetOpen(true)} |
|
||||
disabled={outcomeMutation.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" |
|
||||
> |
|
||||
{outcomeMutation.isPending ? ( |
|
||||
<LoadingThreeDot className="text-white" /> |
|
||||
) : ( |
|
||||
t["Submit Final Outcome"] |
|
||||
)} |
|
||||
</button> |
|
||||
</> |
|
||||
)} |
|
||||
</div> |
|
||||
) : ( |
|
||||
<div className="flex mt-9 w-full justify-center gap-4 max-w-[315px] mx-auto"> |
|
||||
{isFemaleProfile ? ( |
|
||||
<button |
|
||||
type="button" |
|
||||
onClick={() => setIsNoContactConfirmOpen(true)} |
|
||||
disabled={ |
|
||||
contactStatusMutation.isPending || |
|
||||
noContactReportedSuccess |
|
||||
} |
|
||||
className="flex-1 h-[44px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-all cursor-pointer hover:bg-[#F5F5F5] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white" |
|
||||
> |
|
||||
{contactStatusMutation.isPending ? ( |
|
||||
<LoadingThreeDot /> |
|
||||
) : ( |
|
||||
primaryActionText |
|
||||
)} |
|
||||
</button> |
|
||||
) : ( |
|
||||
<Link |
|
||||
href={profileHref} |
|
||||
className="max-w-[212px] flex-1" |
|
||||
> |
|
||||
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C] min-h-[38px] flex items-center justify-center"> |
|
||||
{primaryActionText} |
|
||||
</div> |
|
||||
</Link> |
|
||||
)} |
|
||||
|
|
||||
<button |
|
||||
type="button" |
|
||||
onClick={() => { |
|
||||
void handleSecondaryAction(); |
|
||||
}} |
|
||||
disabled={paymentMutation.isPending} |
|
||||
className={ |
|
||||
isFemaleProfile |
|
||||
? "flex-1 h-[44px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-md shadow-[#FE6F82]/30 transition-all cursor-pointer hover:opacity-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none" |
|
||||
: "max-w-[212px] flex-1 appearance-none border-0 bg-transparent p-0 text-left" |
|
||||
} |
|
||||
> |
|
||||
{isFemaleProfile ? ( |
|
||||
paymentMutation.isPending ? ( |
|
||||
<LoadingThreeDot className="text-white" /> |
|
||||
) : ( |
|
||||
secondaryActionText |
|
||||
) |
|
||||
) : ( |
|
||||
<div className="bg-linear-180 from-[#FE6F82] to-[#E03950] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#fff] shadow-md shadow-[#F2596E]/60 flex items-center justify-center min-h-[38px]"> |
|
||||
{paymentMutation.isPending ? ( |
|
||||
<LoadingThreeDot /> |
|
||||
) : ( |
|
||||
secondaryActionText |
|
||||
)} |
|
||||
</div> |
|
||||
)} |
|
||||
</button> |
|
||||
</div> |
|
||||
)} |
|
||||
|
|
||||
{caseStatus !== "contacted" && |
|
||||
!isFemaleContactConfirmed && |
|
||||
!noContactReportedSuccess && |
|
||||
!(isFemaleProfile && contactStatusMutation.isPending) ? ( |
|
||||
<div className="border border-[#F0445B] bg-[#F0445B]/10 rounded-xl mt-4"> |
|
||||
<p className="text-[#F0445B] group-12 font-semibold py-2.5 px-3.5 whitespace-pre-line"> |
|
||||
{ |
|
||||
t[ |
|
||||
"Please be informed that from the time of this introduction, you have 48 hours (2 days) to contact the person or their respected family to declare your readiness and begin the acquaintance process. At this stage, merely an initial call to announce your presence is sufficient, and planning for further steps (such as an in-person meeting) depends entirely on your subsequent mutual agreements. Since failing to make contact within the specified time might be considered socially disrespectful, if no action is taken within these 2 days, the introduced match will be removed according to the platform's rules. We also remind you that this issue may lead to restrictions such as delays in future introductions and financial penalties." |
|
||||
] |
|
||||
} |
|
||||
</p> |
|
||||
</div> |
|
||||
) : null} |
|
||||
</> |
|
||||
</> |
|
||||
)} |
|
||||
</section> |
|
||||
|
|
||||
<div className="space-y-8 pb-20"> |
|
||||
{/* Advisor section */} |
|
||||
<AdvisorActionsCard |
|
||||
title={t["Get an advisor"]} |
|
||||
description={ |
|
||||
t[ |
|
||||
"Not sure what to do next? Our psychology section is here to guide you at every step." |
|
||||
] |
|
||||
} |
|
||||
avatars={advisorAvatars} |
|
||||
extraCount={7} |
|
||||
getAdvisorLabel={t["Get Advisor"]} |
|
||||
getAdvisorHref="/marriage-advisors" |
|
||||
/> |
|
||||
|
|
||||
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> |
|
||||
<div |
|
||||
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }} |
|
||||
className="pointer-events-auto px-[17px] pt-3" |
|
||||
> |
|
||||
<div className="flex items-center justify-center w-full gap-1 rounded-[11px] bg-[#DBDBDB] py-3.5"> |
|
||||
<Image |
|
||||
src="/assets/images/material-symbols_lock.svg" |
|
||||
width={24} |
|
||||
height={24} |
|
||||
alt="lock" |
|
||||
/> |
|
||||
|
|
||||
<h2 className="group-14 leading-none font-semibold text-[#747474]"> |
|
||||
{t["Profile is locked"]} |
|
||||
</h2> |
|
||||
</div> |
|
||||
</div> |
|
||||
</section> |
|
||||
</div> |
|
||||
</div> |
|
||||
</main> |
|
||||
</> |
|
||||
|
<HydrationBoundary state={dehydrate(queryClient)}> |
||||
|
<RequestAcceptedClient /> |
||||
|
</HydrationBoundary> |
||||
); |
); |
||||
} |
} |
||||
@ -0,0 +1,760 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import Image from "next/image"; |
||||
|
import Link from "next/link"; |
||||
|
import { useRouter } from "next/navigation"; |
||||
|
import { useEffect, useMemo, useState } from "react"; |
||||
|
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; |
||||
|
import CallResultSheet from "@/components/Componentes/call-result-sheet"; |
||||
|
import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet"; |
||||
|
import FemaleConsentSheet from "@/components/Componentes/female-consent-sheet"; |
||||
|
import FemaleOutcomeSheet from "@/components/Componentes/female-outcome-sheet"; |
||||
|
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot"; |
||||
|
import OutcomeSelectionSheet from "@/components/Componentes/outcome-selection-sheet"; |
||||
|
import { PageBackground } from "@/components/Componentes/page-background"; |
||||
|
import PageHeader from "@/components/Componentes/page-header"; |
||||
|
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; |
||||
|
import SubscriptionRequiredSheet from "@/components/Componentes/subscription-required-sheet"; |
||||
|
import SwipeButton from "@/components/Componentes/swipe-button"; |
||||
|
import type { |
||||
|
MarriageField, |
||||
|
MarriagePhoneFieldValue, |
||||
|
} from "@/hooks/marriage/types"; |
||||
|
import { useMarriageContactInfoQuery } from "@/hooks/marriage/use-contact-info"; |
||||
|
import { |
||||
|
useSubmitMarriageContactStatusMutation, |
||||
|
useSubmitMarriageOutcomeMutation, |
||||
|
} from "@/hooks/marriage/use-contact-status"; |
||||
|
import { |
||||
|
extractHabcoinPaymentUrl, |
||||
|
useHabcoinPaymentMutation, |
||||
|
} from "@/hooks/marriage/use-habcoin-payment"; |
||||
|
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
||||
|
import { getSubmitPath } from "@/lib/get-submit-path"; |
||||
|
import { localizePath } from "@/translations/config"; |
||||
|
import { useI18n } from "@/translations/provider"; |
||||
|
|
||||
|
const advisorAvatars = [ |
||||
|
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" }, |
||||
|
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" }, |
||||
|
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" }, |
||||
|
]; |
||||
|
|
||||
|
type ContactInfoPhoneItem = { |
||||
|
key: string; |
||||
|
label: string; |
||||
|
phoneNumber: string; |
||||
|
}; |
||||
|
|
||||
|
function sanitizePhoneNumber(value: MarriageField["value"]) { |
||||
|
if (value === null || value === "") { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
if (isMarriagePhoneFieldValue(value)) { |
||||
|
const digits = value.phoneNumber.replace(/\D/g, ""); |
||||
|
|
||||
|
return digits ? `+${value.countryCode}${digits}` : null; |
||||
|
} |
||||
|
|
||||
|
const trimmedValue = String(value).trim(); |
||||
|
|
||||
|
if (!trimmedValue) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
const digits = trimmedValue.replace(/\D/g, ""); |
||||
|
|
||||
|
if (!digits) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
return trimmedValue.startsWith("+") ? `+${digits}` : digits; |
||||
|
} |
||||
|
|
||||
|
function isMarriagePhoneFieldValue( |
||||
|
value: unknown, |
||||
|
): value is MarriagePhoneFieldValue { |
||||
|
if (!value || typeof value !== "object") { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const phoneValue = value as Partial<MarriagePhoneFieldValue>; |
||||
|
|
||||
|
return ( |
||||
|
typeof phoneValue.countryCode === "string" && |
||||
|
typeof phoneValue.phoneNumber === "string" |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
function getContactInfoPhoneItems( |
||||
|
contactInfoFields: MarriageField[] | null | undefined, |
||||
|
): ContactInfoPhoneItem[] { |
||||
|
if (!contactInfoFields) { |
||||
|
return []; |
||||
|
} |
||||
|
|
||||
|
return contactInfoFields |
||||
|
.map((field) => { |
||||
|
const phoneNumber = sanitizePhoneNumber(field.value); |
||||
|
|
||||
|
if (!phoneNumber) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
const rawLabel = field.label || field.key; |
||||
|
const label = rawLabel |
||||
|
.replace(/\s+with\s+Country\s+Code/gi, "") |
||||
|
.replace(/\s+با\s+کد\s+کشور/g, "") |
||||
|
.trim(); |
||||
|
|
||||
|
return { |
||||
|
key: field.key, |
||||
|
label, |
||||
|
phoneNumber, |
||||
|
}; |
||||
|
}) |
||||
|
.filter((item): item is ContactInfoPhoneItem => item !== null); |
||||
|
} |
||||
|
|
||||
|
function ContactInfoPhoneCard({ item }: { item: ContactInfoPhoneItem }) { |
||||
|
return ( |
||||
|
<div className="flex items-center justify-between rounded-[15px] border border-[#E4E4E4] bg-[#FBFBFB] px-4 py-3.5 shadow-[0_4px_12px_rgba(0,0,0,0.03)] gap-4"> |
||||
|
<div className="text-left min-w-0"> |
||||
|
<p className="text-xs font-semibold text-[#8F8F8F] break-words"> |
||||
|
{item.label} |
||||
|
</p> |
||||
|
<p className="text-base font-bold text-[#1F1F1F] dir-ltr"> |
||||
|
{item.phoneNumber} |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex items-center gap-2 shrink-0"> |
||||
|
<a |
||||
|
href={`tel:${item.phoneNumber}`} |
||||
|
onClick={(e) => { |
||||
|
if (typeof window !== "undefined" && "HabibApp" in window) { |
||||
|
e.preventDefault(); |
||||
|
try { |
||||
|
const app = ( |
||||
|
window as Window & { |
||||
|
HabibApp?: { postMessage: (msg: string) => void }; |
||||
|
} |
||||
|
).HabibApp; |
||||
|
app?.postMessage( |
||||
|
JSON.stringify({ |
||||
|
action: "open_external_url", |
||||
|
data: { |
||||
|
url: `tel:${item.phoneNumber}`, |
||||
|
mode: "externalApplication", |
||||
|
title: "phone_call", |
||||
|
}, |
||||
|
}), |
||||
|
); |
||||
|
} catch (err) { |
||||
|
console.error("Error calling HabibApp bridge", err); |
||||
|
} |
||||
|
} |
||||
|
}} |
||||
|
className="inline-flex p-3 items-center justify-center rounded-[10px] bg-[#F0445B] text-white shrink-0" |
||||
|
> |
||||
|
<Image |
||||
|
src={"/assets/images/Vecfdastor.svg"} |
||||
|
width={16} |
||||
|
height={16} |
||||
|
alt="call" |
||||
|
/> |
||||
|
</a> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
export default function RequestAcceptedClient() { |
||||
|
const { dictionary: t, locale } = useI18n(); |
||||
|
const router = useRouter(); |
||||
|
const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false); |
||||
|
const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] = |
||||
|
useState(false); |
||||
|
const [isContactInfoSheetOpen, setIsContactInfoSheetOpen] = useState(false); |
||||
|
const [isSubscriptionSheetOpen, setIsSubscriptionSheetOpen] = useState(false); |
||||
|
const [paymentError, setPaymentError] = useState<string | null>(null); |
||||
|
const [isInsufficientCoins, setIsInsufficientCoins] = useState(false); |
||||
|
const [isOutcomeSheetOpen, setIsOutcomeSheetOpen] = useState(false); |
||||
|
const [isNoContactConfirmOpen, setIsNoContactConfirmOpen] = useState(false); |
||||
|
const [isContactReceivedConfirmOpen, setIsContactReceivedConfirmOpen] = |
||||
|
useState(false); |
||||
|
const [noContactReportedSuccess, setNoContactReportedSuccess] = |
||||
|
useState(false); |
||||
|
const [hasConfirmedFemaleContact, setHasConfirmedFemaleContact] = |
||||
|
useState(false); |
||||
|
const profileHref = localizePath("/new-match/profile", locale); |
||||
|
const { data: profile, isLoading } = useMarriageProfileQuery({ |
||||
|
refetchInterval: 3000, |
||||
|
}); |
||||
|
|
||||
|
const isFemaleProfile = profile?.gender === "female"; |
||||
|
const contactSharedAtStr = profile?.active_case?.contact_shared_at; |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (!profile || noContactReportedSuccess) { |
||||
|
return; |
||||
|
} |
||||
|
const targetPath = getSubmitPath(profile); |
||||
|
if (targetPath !== "/request-accepted") { |
||||
|
router.replace(localizePath(targetPath, locale)); |
||||
|
} |
||||
|
}, [profile, router, locale, noContactReportedSuccess]); |
||||
|
|
||||
|
// Signal Flutter to lift its loading cover once the profile is available.
|
||||
|
useEffect(() => { |
||||
|
if (profile && !isLoading) { |
||||
|
window.__announceHabibWebReady?.(); |
||||
|
} |
||||
|
}, [profile, isLoading]); |
||||
|
|
||||
|
const isRedirecting = useMemo(() => { |
||||
|
if (!profile) return false; |
||||
|
return getSubmitPath(profile) !== "/request-accepted"; |
||||
|
}, [profile]); |
||||
|
|
||||
|
const caseId = profile?.active_case?.case_id; |
||||
|
const caseStatus = profile?.active_case?.status; |
||||
|
const isFemaleContactConfirmed = |
||||
|
isFemaleProfile && |
||||
|
(caseStatus === "contacted" || hasConfirmedFemaleContact); |
||||
|
const recommendedPlanId = profile?.recommended_plan?.id; |
||||
|
const paymentMutation = useHabcoinPaymentMutation(); |
||||
|
const outcomeMutation = useSubmitMarriageOutcomeMutation(caseId ?? ""); |
||||
|
const contactStatusMutation = useSubmitMarriageContactStatusMutation( |
||||
|
caseId ?? "", |
||||
|
{ |
||||
|
onSuccess: (_data, variables) => { |
||||
|
if (variables?.action === "no_contact") { |
||||
|
setNoContactReportedSuccess(true); |
||||
|
} else { |
||||
|
if (!isFemaleProfile) { |
||||
|
router.push(localizePath("/finding-match", locale)); |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
}, |
||||
|
); |
||||
|
const contactInfoQuery = useMarriageContactInfoQuery(caseId, { |
||||
|
enabled: false, |
||||
|
}); |
||||
|
|
||||
|
if (isLoading || isRedirecting) { |
||||
|
return <PageLoadingSkeleton />; |
||||
|
} |
||||
|
|
||||
|
const titleText = isFemaleProfile |
||||
|
? t["Request Approved"] |
||||
|
: caseStatus === "payment_done" || caseStatus === "contacted" |
||||
|
? t["Contact info released"] |
||||
|
: t["Request approved!"]; |
||||
|
const primaryActionText = isFemaleProfile |
||||
|
? t["No Contact Received"] |
||||
|
: t["View profile"]; |
||||
|
const secondaryActionText = isFemaleProfile |
||||
|
? t["Contact Received"] |
||||
|
: caseStatus === "payment_done" || caseStatus === "contacted" |
||||
|
? t["View contact number"] |
||||
|
: t["Pay and get contact"]; |
||||
|
const contactInfoPhoneItems = getContactInfoPhoneItems( |
||||
|
contactInfoQuery.data?.contact_info, |
||||
|
); |
||||
|
|
||||
|
const handleSecondaryAction = async () => { |
||||
|
if (isFemaleProfile) { |
||||
|
setIsContactReceivedConfirmOpen(true); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (caseStatus === "female_accepted" || caseStatus === "payment_pending") { |
||||
|
setIsSubscriptionSheetOpen(true); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (caseStatus === "payment_done" || caseStatus === "contacted") { |
||||
|
if (!caseId) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (!contactInfoQuery.data) { |
||||
|
await contactInfoQuery.refetch(); |
||||
|
} |
||||
|
|
||||
|
setIsContactInfoSheetOpen(true); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handlePayment = async () => { |
||||
|
if (!recommendedPlanId || paymentMutation.isPending) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
setPaymentError(null); |
||||
|
setIsInsufficientCoins(false); |
||||
|
const paymentResponse = |
||||
|
await paymentMutation.mutateAsync(recommendedPlanId); |
||||
|
const paymentUrl = extractHabcoinPaymentUrl(paymentResponse); |
||||
|
|
||||
|
if (paymentUrl) { |
||||
|
window.location.assign(paymentUrl); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
setIsSubscriptionSheetOpen(false); |
||||
|
|
||||
|
if (caseId) { |
||||
|
await contactInfoQuery.refetch(); |
||||
|
setIsContactInfoSheetOpen(true); |
||||
|
} |
||||
|
} catch (err: any) { |
||||
|
console.error("Habcoin payment request failed", err); |
||||
|
const msg = |
||||
|
err?.response?.data?.error || err?.message || "Payment failed"; |
||||
|
if (msg === "Not enough coins") { |
||||
|
setIsInsufficientCoins(true); |
||||
|
setPaymentError( |
||||
|
t["Insufficient coin balance. Please recharge your account."] || |
||||
|
"Insufficient coin balance. Please recharge your account.", |
||||
|
); |
||||
|
} else { |
||||
|
setPaymentError(msg); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handleNoContactReport = async () => { |
||||
|
if (!caseId || contactStatusMutation.isPending) return; |
||||
|
await contactStatusMutation.mutateAsync({ |
||||
|
action: "no_contact", |
||||
|
custom_note: |
||||
|
"No contact reported by female candidate after decision window", |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
const isFinalized = |
||||
|
caseStatus === "finalized" || profile?.status === "matched"; |
||||
|
|
||||
|
return ( |
||||
|
<> |
||||
|
<PageBackground /> |
||||
|
|
||||
|
{isCallResultSheetOpen ? ( |
||||
|
<CallResultSheet |
||||
|
onClose={() => setIsCallResultSheetOpen(false)} |
||||
|
onOtherReasonsClick={() => setIsDismissReasonSheetOpen(true)} |
||||
|
onSubmit={async (value) => { |
||||
|
if (caseId) { |
||||
|
await contactStatusMutation.mutateAsync({ |
||||
|
action: "contacted", |
||||
|
custom_note: value, |
||||
|
}); |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
) : null} |
||||
|
|
||||
|
{isContactReceivedConfirmOpen ? ( |
||||
|
<FemaleConsentSheet |
||||
|
title={t.Confirm} |
||||
|
description={ |
||||
|
<p className="font-bold text-[#00AC78] text-center my-4 text-base"> |
||||
|
{t["Are you sure contact has been made?"]} |
||||
|
</p> |
||||
|
} |
||||
|
buttons={ |
||||
|
<SwipeButton |
||||
|
theme="green" |
||||
|
text={t["Confirm"]} |
||||
|
onCancel={() => setIsContactReceivedConfirmOpen(false)} |
||||
|
onSuccess={async () => { |
||||
|
setIsContactReceivedConfirmOpen(false); |
||||
|
setHasConfirmedFemaleContact(true); |
||||
|
|
||||
|
if (!caseId) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
await contactStatusMutation.mutateAsync({ |
||||
|
action: "contacted", |
||||
|
custom_note: |
||||
|
"Contact received confirmed by female candidate", |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
// The confirmation screen must advance immediately after a swipe.
|
||||
|
// Keep the local state visible while the profile query retries.
|
||||
|
console.error("Unable to persist received contact", error); |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
} |
||||
|
onClose={() => setIsContactReceivedConfirmOpen(false)} |
||||
|
closeOnOutside={true} |
||||
|
/> |
||||
|
) : null} |
||||
|
|
||||
|
{isDismissReasonSheetOpen ? ( |
||||
|
<DismissReasonSheet |
||||
|
onClose={() => setIsDismissReasonSheetOpen(false)} |
||||
|
onSubmit={async (value) => { |
||||
|
if (caseId) { |
||||
|
await outcomeMutation.mutateAsync({ |
||||
|
status: "failure", |
||||
|
custom_note: value, |
||||
|
}); |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
) : null} |
||||
|
|
||||
|
{isOutcomeSheetOpen ? ( |
||||
|
isFemaleProfile ? ( |
||||
|
<FemaleOutcomeSheet |
||||
|
onClose={() => setIsOutcomeSheetOpen(false)} |
||||
|
onSubmit={async (status, reason) => { |
||||
|
if (status === "success") { |
||||
|
// If they confirm they are in the acquaintance/proposal process and nothing is finalized yet:
|
||||
|
// No change is made to the profile, we just close the sheet.
|
||||
|
setIsOutcomeSheetOpen(false); |
||||
|
} else { |
||||
|
// If they cancel:
|
||||
|
if (caseId) { |
||||
|
await outcomeMutation.mutateAsync({ |
||||
|
status: "failure", |
||||
|
custom_note: reason, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
) : ( |
||||
|
<OutcomeSelectionSheet |
||||
|
onClose={() => setIsOutcomeSheetOpen(false)} |
||||
|
onSubmit={async (status) => { |
||||
|
if (status === "success") { |
||||
|
if (caseId) { |
||||
|
await outcomeMutation.mutateAsync({ |
||||
|
status: "success", |
||||
|
}); |
||||
|
} |
||||
|
} else { |
||||
|
setIsDismissReasonSheetOpen(true); |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
) |
||||
|
) : null} |
||||
|
|
||||
|
{isContactInfoSheetOpen ? ( |
||||
|
<FemaleConsentSheet |
||||
|
title={t["Contact Detail"]} |
||||
|
description={ |
||||
|
t[ |
||||
|
"Please mention during the call that you were introduced by the Habib Marriage app." |
||||
|
] |
||||
|
} |
||||
|
buttons={ |
||||
|
contactInfoPhoneItems.length ? ( |
||||
|
<div className="space-y-4"> |
||||
|
{contactInfoPhoneItems.map((item) => ( |
||||
|
<ContactInfoPhoneCard key={item.key} item={item} /> |
||||
|
))} |
||||
|
</div> |
||||
|
) : ( |
||||
|
<div className="rounded-[12px] bg-[#ECECEC] px-4 py-3 group-12 font-semibold text-[#555]"> |
||||
|
{t["Contact information is not available yet."]} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
onClose={() => setIsContactInfoSheetOpen(false)} |
||||
|
/> |
||||
|
) : null} |
||||
|
|
||||
|
{isSubscriptionSheetOpen ? ( |
||||
|
<SubscriptionRequiredSheet |
||||
|
onClose={() => { |
||||
|
setIsSubscriptionSheetOpen(false); |
||||
|
setPaymentError(null); |
||||
|
setIsInsufficientCoins(false); |
||||
|
}} |
||||
|
onPayment={handlePayment} |
||||
|
isPaymentPending={!recommendedPlanId || paymentMutation.isPending} |
||||
|
errorMessage={paymentError} |
||||
|
showBuyCoins={isInsufficientCoins} |
||||
|
/> |
||||
|
) : null} |
||||
|
|
||||
|
{isNoContactConfirmOpen ? ( |
||||
|
<FemaleConsentSheet |
||||
|
title={t.Confirm} |
||||
|
description={ |
||||
|
<p className="font-bold text-[#E03950] text-center my-4 text-base"> |
||||
|
{ |
||||
|
t[ |
||||
|
"No contact has been made with you in any way or by any party." |
||||
|
] |
||||
|
} |
||||
|
</p> |
||||
|
} |
||||
|
buttons={ |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={async () => { |
||||
|
setIsNoContactConfirmOpen(false); |
||||
|
await handleNoContactReport(); |
||||
|
}} |
||||
|
className="w-full h-[48px] rounded-[15px] bg-[#E03950] text-white font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95" |
||||
|
> |
||||
|
{t.Confirm} |
||||
|
</button> |
||||
|
} |
||||
|
onClose={() => setIsNoContactConfirmOpen(false)} |
||||
|
closeOnOutside={true} |
||||
|
/> |
||||
|
) : null} |
||||
|
|
||||
|
<main className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-[calc(20px+var(--safe-bottom))] text-center"> |
||||
|
<PageHeader className="-mx-[6px]" /> |
||||
|
|
||||
|
<div className="flex flex-1 flex-col justify-between gap-6 pt-4"> |
||||
|
<section className="flex flex-col items-center"> |
||||
|
{isFinalized ? ( |
||||
|
<div className="flex flex-col items-center max-w-[320px] text-center my-auto py-12"> |
||||
|
<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["Congratulations! 🎉"]} |
||||
|
</h1> |
||||
|
<p className="mt-6 text-[#4A4A4A] group-14 font-medium leading-relaxed"> |
||||
|
{ |
||||
|
t[ |
||||
|
"Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed." |
||||
|
] |
||||
|
} |
||||
|
</p> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<> |
||||
|
<div className="relative isolate flex items-center justify-center"> |
||||
|
<Image |
||||
|
src="/assets/images/Group 15978804fdasf68.svg" |
||||
|
alt={t["Request accepted"]} |
||||
|
width={131} |
||||
|
height={125} |
||||
|
priority |
||||
|
className="relative z-10" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<h1 className="mt-11 group-16 leading-none font-black tracking-[0.02em] text-[#171717] uppercase"> |
||||
|
{titleText} |
||||
|
</h1> |
||||
|
|
||||
|
{caseStatus === "contacted" || |
||||
|
isFemaleContactConfirmed || |
||||
|
(isFemaleProfile && contactStatusMutation.isPending) ? ( |
||||
|
<div className="w-full border border-[#ECECEC] bg-white rounded-[15px] mt-6 px-4 py-4 text-center shadow-sm max-w-[315px] flex flex-col items-center justify-center min-h-[100px]"> |
||||
|
{isFemaleProfile && contactStatusMutation.isPending ? ( |
||||
|
<LoadingThreeDot className="text-[#E03950]" /> |
||||
|
) : ( |
||||
|
<p className="text-[#555555] group-14 font-medium leading-relaxed"> |
||||
|
{isFemaleProfile |
||||
|
? t[ |
||||
|
"Thank you for your feedback. To complete the process, please submit the final outcome of this introduction/contact so that the final status can be determined. If the final status is not yet determined, you can stay in this state until it is finalized." |
||||
|
] |
||||
|
: t[ |
||||
|
"Thank you for giving us feedback, we would be very happy if you also let us know the final result." |
||||
|
]} |
||||
|
</p> |
||||
|
)} |
||||
|
</div> |
||||
|
) : ( |
||||
|
<p className="mt-4 max-w-[315px] group-12 leading-[1.45] font-semibold text-[#777777]"> |
||||
|
{noContactReportedSuccess |
||||
|
? t[ |
||||
|
"Thank you for your feedback. Our support team will investigate the matter and notify you of the result. Please wait patiently during the review; our support will contact you." |
||||
|
] |
||||
|
: isFemaleProfile |
||||
|
? t[ |
||||
|
"The selected candidate will contact your family shortly." |
||||
|
] |
||||
|
: t[ |
||||
|
"You can now view their family's contact details and arrange further steps." |
||||
|
]} |
||||
|
</p> |
||||
|
)} |
||||
|
|
||||
|
<> |
||||
|
{caseStatus === "contacted" || |
||||
|
isFemaleContactConfirmed || |
||||
|
(isFemaleProfile && contactStatusMutation.isPending) ? ( |
||||
|
<div className="flex mt-8 w-full gap-3 justify-center"> |
||||
|
{isFemaleProfile && |
||||
|
contactStatusMutation.isPending ? null : isFemaleProfile ? ( |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setIsOutcomeSheetOpen(true)} |
||||
|
disabled={outcomeMutation.isPending} |
||||
|
className="w-full max-w-[315px] 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 ? ( |
||||
|
<LoadingThreeDot className="text-white" /> |
||||
|
) : ( |
||||
|
t["Share Result"] |
||||
|
)} |
||||
|
</button> |
||||
|
) : ( |
||||
|
<> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => |
||||
|
router.push( |
||||
|
localizePath("/new-match/profile", locale), |
||||
|
) |
||||
|
} |
||||
|
className="flex-1 h-[52px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#F5F5F5]" |
||||
|
> |
||||
|
{t["View Profile"]} |
||||
|
</button> |
||||
|
|
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setIsOutcomeSheetOpen(true)} |
||||
|
disabled={outcomeMutation.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" |
||||
|
> |
||||
|
{outcomeMutation.isPending ? ( |
||||
|
<LoadingThreeDot className="text-white" /> |
||||
|
) : ( |
||||
|
t["Submit Final Outcome"] |
||||
|
)} |
||||
|
</button> |
||||
|
</> |
||||
|
)} |
||||
|
</div> |
||||
|
) : ( |
||||
|
<div className="flex mt-9 w-full justify-center gap-4 max-w-[315px] mx-auto"> |
||||
|
{isFemaleProfile ? ( |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setIsNoContactConfirmOpen(true)} |
||||
|
disabled={ |
||||
|
contactStatusMutation.isPending || |
||||
|
noContactReportedSuccess |
||||
|
} |
||||
|
className="flex-1 h-[44px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-all cursor-pointer hover:bg-[#F5F5F5] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white" |
||||
|
> |
||||
|
{contactStatusMutation.isPending ? ( |
||||
|
<LoadingThreeDot /> |
||||
|
) : ( |
||||
|
primaryActionText |
||||
|
)} |
||||
|
</button> |
||||
|
) : ( |
||||
|
<Link |
||||
|
href={profileHref} |
||||
|
className="max-w-[212px] flex-1" |
||||
|
> |
||||
|
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C] min-h-[38px] flex items-center justify-center"> |
||||
|
{primaryActionText} |
||||
|
</div> |
||||
|
</Link> |
||||
|
)} |
||||
|
|
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => { |
||||
|
void handleSecondaryAction(); |
||||
|
}} |
||||
|
disabled={paymentMutation.isPending} |
||||
|
className={ |
||||
|
isFemaleProfile |
||||
|
? "flex-1 h-[44px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-md shadow-[#FE6F82]/30 transition-all cursor-pointer hover:opacity-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none" |
||||
|
: "max-w-[212px] flex-1 appearance-none border-0 bg-transparent p-0 text-left" |
||||
|
} |
||||
|
> |
||||
|
{isFemaleProfile ? ( |
||||
|
paymentMutation.isPending ? ( |
||||
|
<LoadingThreeDot className="text-white" /> |
||||
|
) : ( |
||||
|
secondaryActionText |
||||
|
) |
||||
|
) : ( |
||||
|
<div className="bg-linear-180 from-[#FE6F82] to-[#E03950] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#fff] shadow-md shadow-[#F2596E]/60 flex items-center justify-center min-h-[38px]"> |
||||
|
{paymentMutation.isPending ? ( |
||||
|
<LoadingThreeDot /> |
||||
|
) : ( |
||||
|
secondaryActionText |
||||
|
)} |
||||
|
</div> |
||||
|
)} |
||||
|
</button> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{caseStatus !== "contacted" && |
||||
|
!isFemaleContactConfirmed && |
||||
|
!noContactReportedSuccess && |
||||
|
!(isFemaleProfile && contactStatusMutation.isPending) ? ( |
||||
|
<div className="border border-[#F0445B] bg-[#F0445B]/10 rounded-xl mt-4"> |
||||
|
<p className="text-[#F0445B] group-12 font-semibold py-2.5 px-3.5 whitespace-pre-line"> |
||||
|
{ |
||||
|
t[ |
||||
|
"Please be informed that from the time of this introduction, you have 48 hours (2 days) to contact the person or their respected family to declare your readiness and begin the acquaintance process. At this stage, merely an initial call to announce your presence is sufficient, and planning for further steps (such as an in-person meeting) depends entirely on your subsequent mutual agreements. Since failing to make contact within the specified time might be considered socially disrespectful, if no action is taken within these 2 days, the introduced match will be removed according to the platform's rules. We also remind you that this issue may lead to restrictions such as delays in future introductions and financial penalties." |
||||
|
] |
||||
|
} |
||||
|
</p> |
||||
|
</div> |
||||
|
) : null} |
||||
|
</> |
||||
|
</> |
||||
|
)} |
||||
|
</section> |
||||
|
|
||||
|
<div className="space-y-8 pb-20"> |
||||
|
{/* Advisor section */} |
||||
|
<AdvisorActionsCard |
||||
|
title={t["Get an advisor"]} |
||||
|
description={ |
||||
|
t[ |
||||
|
"Not sure what to do next? Our psychology section is here to guide you at every step." |
||||
|
] |
||||
|
} |
||||
|
avatars={advisorAvatars} |
||||
|
extraCount={7} |
||||
|
getAdvisorLabel={t["Get Advisor"]} |
||||
|
getAdvisorHref="/marriage-advisors" |
||||
|
/> |
||||
|
|
||||
|
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> |
||||
|
<div |
||||
|
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }} |
||||
|
className="pointer-events-auto px-[17px] pt-3" |
||||
|
> |
||||
|
<div className="flex items-center justify-center w-full gap-1 rounded-[11px] bg-[#DBDBDB] py-3.5"> |
||||
|
<Image |
||||
|
src="/assets/images/material-symbols_lock.svg" |
||||
|
width={24} |
||||
|
height={24} |
||||
|
alt="lock" |
||||
|
/> |
||||
|
|
||||
|
<h2 className="group-14 leading-none font-semibold text-[#747474]"> |
||||
|
{t["Profile is locked"]} |
||||
|
</h2> |
||||
|
</div> |
||||
|
</div> |
||||
|
</section> |
||||
|
</div> |
||||
|
</div> |
||||
|
</main> |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
@ -1,140 +1,39 @@ |
|||||
"use client"; |
|
||||
|
|
||||
import Image from "next/image"; |
|
||||
import Link from "next/link"; |
|
||||
import { useRouter } from "next/navigation"; |
|
||||
import { useEffect, useMemo } from "react"; |
|
||||
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; |
|
||||
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; |
|
||||
import PageHeader from "@/components/Componentes/page-header"; |
|
||||
import { PageBackground } from "@/components/Componentes/page-background"; |
|
||||
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
|
||||
import { getSubmitPath } from "@/lib/get-submit-path"; |
|
||||
import { localizePath } from "@/translations/config"; |
|
||||
import { useI18n } from "@/translations/provider"; |
|
||||
|
|
||||
const advisorAvatars = [ |
|
||||
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" }, |
|
||||
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" }, |
|
||||
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" }, |
|
||||
]; |
|
||||
|
|
||||
export default function RequestSentPage() { |
|
||||
const router = useRouter(); |
|
||||
const { dictionary: t, locale } = useI18n(); |
|
||||
const { data: profile, isLoading } = useMarriageProfileQuery({ |
|
||||
refetchInterval: 3000, |
|
||||
|
import { cookies } from "next/headers"; |
||||
|
import { |
||||
|
dehydrate, |
||||
|
HydrationBoundary, |
||||
|
QueryClient, |
||||
|
} from "@tanstack/react-query"; |
||||
|
import { isAuthenticatedToken } from "@/lib/entry-route-cache"; |
||||
|
import { fetchProfileSSR } from "@/lib/ssr-fetch"; |
||||
|
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; |
||||
|
import RequestSentClient from "./request-sent-client"; |
||||
|
|
||||
|
export const dynamic = "force-dynamic"; |
||||
|
|
||||
|
export default async function RequestSentPage() { |
||||
|
const cookieStore = await cookies(); |
||||
|
|
||||
|
const token = |
||||
|
cookieStore.get("HABIB_TOKEN")?.value ?? |
||||
|
cookieStore.get("habib_token")?.value; |
||||
|
|
||||
|
const queryClient = new QueryClient({ |
||||
|
defaultOptions: { |
||||
|
queries: { staleTime: 10 * 1000 }, |
||||
|
}, |
||||
}); |
}); |
||||
|
|
||||
useEffect(() => { |
|
||||
if (!profile) { |
|
||||
return; |
|
||||
} |
|
||||
const targetPath = getSubmitPath(profile); |
|
||||
if (targetPath !== "/request-sent") { |
|
||||
router.replace(localizePath(targetPath, locale)); |
|
||||
} |
|
||||
}, [profile, locale, router]); |
|
||||
|
|
||||
const isRedirecting = useMemo(() => { |
|
||||
if (!profile) return false; |
|
||||
return getSubmitPath(profile) !== "/request-sent"; |
|
||||
}, [profile]); |
|
||||
|
|
||||
if (isLoading || isRedirecting) { |
|
||||
return <PageLoadingSkeleton />; |
|
||||
|
if (isAuthenticatedToken(token)) { |
||||
|
await queryClient.prefetchQuery({ |
||||
|
queryKey: marriageQueryKeys.profile(), |
||||
|
queryFn: () => fetchProfileSSR(token!), |
||||
|
}); |
||||
} |
} |
||||
|
|
||||
const copy = { |
|
||||
advisorTitle: t["Get an advisor"], |
|
||||
advisorDescription: |
|
||||
t[ |
|
||||
"Not sure what to do next? Our psychology section is here to guide you at every step." |
|
||||
], |
|
||||
getAdvisor: t["Get Advisor"], |
|
||||
}; |
|
||||
const requestSentCopy = { |
|
||||
title: t["Request Sent"], |
|
||||
description: |
|
||||
t[ |
|
||||
"Your request has been sent. Once the lady reviews your request, you will be notified." |
|
||||
], |
|
||||
matchProfile: t["View More Details"], |
|
||||
profileLocked: t["Profile is locked"], |
|
||||
}; |
|
||||
|
|
||||
return ( |
return ( |
||||
<> |
|
||||
<PageBackground /> |
|
||||
|
|
||||
<main |
|
||||
style={{ paddingBottom: "calc(40px + var(--safe-bottom))" }} |
|
||||
className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] text-center" |
|
||||
> |
|
||||
<PageHeader className="-mx-[6px]" /> |
|
||||
|
|
||||
<div className="flex flex-1 flex-col justify-between gap-20 pt-[109px]"> |
|
||||
<section className="flex flex-col items-center"> |
|
||||
<div className="relative isolate flex items-center justify-center"> |
|
||||
<Image |
|
||||
src="/assets/images/Group 15978804fdasf68.svg" |
|
||||
alt="Request sent" |
|
||||
width={131} |
|
||||
height={125} |
|
||||
priority |
|
||||
className="relative z-10" |
|
||||
/> |
|
||||
</div> |
|
||||
|
|
||||
<h1 className="mt-11 group-16 leading-none font-black tracking-[0.02em] text-[#171717] uppercase"> |
|
||||
{requestSentCopy.title} |
|
||||
</h1> |
|
||||
|
|
||||
<p className="mt-4 max-w-[315px] group-12 leading-[1.45] font-semibold text-[#777777]"> |
|
||||
{requestSentCopy.description} |
|
||||
</p> |
|
||||
|
|
||||
<Link |
|
||||
href={localizePath("/new-match/profile", locale)} |
|
||||
className="mt-9 w-full max-w-[212px]" |
|
||||
> |
|
||||
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C]"> |
|
||||
{requestSentCopy.matchProfile} |
|
||||
</div> |
|
||||
</Link> |
|
||||
</section> |
|
||||
|
|
||||
<div className="space-y-8 pb-20"> |
|
||||
<AdvisorActionsCard |
|
||||
title={copy.advisorTitle} |
|
||||
description={copy.advisorDescription} |
|
||||
avatars={advisorAvatars} |
|
||||
extraCount={7} |
|
||||
getAdvisorLabel={copy.getAdvisor} |
|
||||
getAdvisorHref="/marriage-advisors" |
|
||||
/> |
|
||||
|
|
||||
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> |
|
||||
<div |
|
||||
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }} |
|
||||
className="pointer-events-auto px-[17px] pt-3" |
|
||||
> |
|
||||
<div className="flex items-center justify-center w-full gap-1 rounded-[11px] bg-[#DBDBDB] py-3.5"> |
|
||||
<Image |
|
||||
src={"/assets/images/material-symbols_lock.svg"} |
|
||||
width={24} |
|
||||
height={24} |
|
||||
alt="lock" |
|
||||
/> |
|
||||
<h2 className="group-14 leading-none font-semibold text-[#747474]"> |
|
||||
{requestSentCopy.profileLocked} |
|
||||
</h2> |
|
||||
</div> |
|
||||
</div> |
|
||||
</section> |
|
||||
</div> |
|
||||
</div> |
|
||||
</main> |
|
||||
</> |
|
||||
|
<HydrationBoundary state={dehydrate(queryClient)}> |
||||
|
<RequestSentClient /> |
||||
|
</HydrationBoundary> |
||||
); |
); |
||||
} |
} |
||||
@ -0,0 +1,147 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import Image from "next/image"; |
||||
|
import Link from "next/link"; |
||||
|
import { useRouter } from "next/navigation"; |
||||
|
import { useEffect, useMemo } from "react"; |
||||
|
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; |
||||
|
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; |
||||
|
import PageHeader from "@/components/Componentes/page-header"; |
||||
|
import { PageBackground } from "@/components/Componentes/page-background"; |
||||
|
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
||||
|
import { getSubmitPath } from "@/lib/get-submit-path"; |
||||
|
import { localizePath } from "@/translations/config"; |
||||
|
import { useI18n } from "@/translations/provider"; |
||||
|
|
||||
|
const advisorAvatars = [ |
||||
|
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" }, |
||||
|
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" }, |
||||
|
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" }, |
||||
|
]; |
||||
|
|
||||
|
export default function RequestSentClient() { |
||||
|
const router = useRouter(); |
||||
|
const { dictionary: t, locale } = useI18n(); |
||||
|
const { data: profile, isLoading } = useMarriageProfileQuery({ |
||||
|
refetchInterval: 3000, |
||||
|
}); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (!profile) { |
||||
|
return; |
||||
|
} |
||||
|
const targetPath = getSubmitPath(profile); |
||||
|
if (targetPath !== "/request-sent") { |
||||
|
router.replace(localizePath(targetPath, locale)); |
||||
|
} |
||||
|
}, [profile, locale, router]); |
||||
|
|
||||
|
// Signal Flutter to lift its loading cover once the profile is available.
|
||||
|
useEffect(() => { |
||||
|
if (profile && !isLoading) { |
||||
|
window.__announceHabibWebReady?.(); |
||||
|
} |
||||
|
}, [profile, isLoading]); |
||||
|
|
||||
|
const isRedirecting = useMemo(() => { |
||||
|
if (!profile) return false; |
||||
|
return getSubmitPath(profile) !== "/request-sent"; |
||||
|
}, [profile]); |
||||
|
|
||||
|
if (isLoading || isRedirecting) { |
||||
|
return <PageLoadingSkeleton />; |
||||
|
} |
||||
|
|
||||
|
const copy = { |
||||
|
advisorTitle: t["Get an advisor"], |
||||
|
advisorDescription: |
||||
|
t[ |
||||
|
"Not sure what to do next? Our psychology section is here to guide you at every step." |
||||
|
], |
||||
|
getAdvisor: t["Get Advisor"], |
||||
|
}; |
||||
|
const requestSentCopy = { |
||||
|
title: t["Request Sent"], |
||||
|
description: |
||||
|
t[ |
||||
|
"Your request has been sent. Once the lady reviews your request, you will be notified." |
||||
|
], |
||||
|
matchProfile: t["View More Details"], |
||||
|
profileLocked: t["Profile is locked"], |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<> |
||||
|
<PageBackground /> |
||||
|
|
||||
|
<main |
||||
|
style={{ paddingBottom: "calc(40px + var(--safe-bottom))" }} |
||||
|
className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] text-center" |
||||
|
> |
||||
|
<PageHeader className="-mx-[6px]" /> |
||||
|
|
||||
|
<div className="flex flex-1 flex-col justify-between gap-20 pt-[109px]"> |
||||
|
<section className="flex flex-col items-center"> |
||||
|
<div className="relative isolate flex items-center justify-center"> |
||||
|
<Image |
||||
|
src="/assets/images/Group 15978804fdasf68.svg" |
||||
|
alt="Request sent" |
||||
|
width={131} |
||||
|
height={125} |
||||
|
priority |
||||
|
className="relative z-10" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<h1 className="mt-11 group-16 leading-none font-black tracking-[0.02em] text-[#171717] uppercase"> |
||||
|
{requestSentCopy.title} |
||||
|
</h1> |
||||
|
|
||||
|
<p className="mt-4 max-w-[315px] group-12 leading-[1.45] font-semibold text-[#777777]"> |
||||
|
{requestSentCopy.description} |
||||
|
</p> |
||||
|
|
||||
|
<Link |
||||
|
href={localizePath("/new-match/profile", locale)} |
||||
|
className="mt-9 w-full max-w-[212px]" |
||||
|
> |
||||
|
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C]"> |
||||
|
{requestSentCopy.matchProfile} |
||||
|
</div> |
||||
|
</Link> |
||||
|
</section> |
||||
|
|
||||
|
<div className="space-y-8 pb-20"> |
||||
|
<AdvisorActionsCard |
||||
|
title={copy.advisorTitle} |
||||
|
description={copy.advisorDescription} |
||||
|
avatars={advisorAvatars} |
||||
|
extraCount={7} |
||||
|
getAdvisorLabel={copy.getAdvisor} |
||||
|
getAdvisorHref="/marriage-advisors" |
||||
|
/> |
||||
|
|
||||
|
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> |
||||
|
<div |
||||
|
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }} |
||||
|
className="pointer-events-auto px-[17px] pt-3" |
||||
|
> |
||||
|
<div className="flex items-center justify-center w-full gap-1 rounded-[11px] bg-[#DBDBDB] py-3.5"> |
||||
|
<Image |
||||
|
src={"/assets/images/material-symbols_lock.svg"} |
||||
|
width={24} |
||||
|
height={24} |
||||
|
alt="lock" |
||||
|
/> |
||||
|
<h2 className="group-14 leading-none font-semibold text-[#747474]"> |
||||
|
{requestSentCopy.profileLocked} |
||||
|
</h2> |
||||
|
</div> |
||||
|
</div> |
||||
|
</section> |
||||
|
</div> |
||||
|
</div> |
||||
|
</main> |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,42 @@ |
|||||
|
/** |
||||
|
* Helper to get the API base URL for server-side fetches. |
||||
|
* It reads from NEXT_PUBLIC_API_BASE_URL. |
||||
|
*/ |
||||
|
function getApiBaseUrl(): string { |
||||
|
return process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:8001"; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Fetches the marriage profile server-side. |
||||
|
* Runs only on the server, typically inside a Next.js Server Component. |
||||
|
* Returns null if the request fails, allowing the client-side React Query to handle retries. |
||||
|
* |
||||
|
* @param token - The user authentication token |
||||
|
* @returns Profile data or null on failure |
||||
|
*/ |
||||
|
export async function fetchProfileSSR(token: string): Promise<any | null> { |
||||
|
if (!token) return null; |
||||
|
|
||||
|
try { |
||||
|
const baseUrl = getApiBaseUrl(); |
||||
|
const url = `${baseUrl}/api/marriage/profile/main/`; |
||||
|
|
||||
|
const response = await fetch(url, { |
||||
|
method: "GET", |
||||
|
cache: "no-store", |
||||
|
headers: { |
||||
|
Accept: "application/json", |
||||
|
Authorization: `Token ${token}`, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
if (!response.ok) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
return await response.json(); |
||||
|
} catch (error) { |
||||
|
console.error("fetchProfileSSR Error:", error); |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue