Browse Source

ssr 1

master
mortezaei 1 week ago
parent
commit
7a173dc539
  1. 387
      src/app/candidate-contact/candidate-contact-client.tsx
  2. 405
      src/app/candidate-contact/page.tsx
  3. 187
      src/app/finding-match/finding-match-client.tsx
  4. 205
      src/app/finding-match/page.tsx
  5. 30
      src/app/layout.tsx
  6. 675
      src/app/new-match/new-match-client.tsx
  7. 689
      src/app/new-match/page.tsx
  8. 796
      src/app/questions-list/page.tsx
  9. 775
      src/app/questions-list/questions-list-client.tsx
  10. 774
      src/app/request-accepted/page.tsx
  11. 760
      src/app/request-accepted/request-accepted-client.tsx
  12. 165
      src/app/request-sent/page.tsx
  13. 147
      src/app/request-sent/request-sent-client.tsx
  14. 42
      src/lib/ssr-fetch.ts

387
src/app/candidate-contact/candidate-contact-client.tsx

@ -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>
</>
);
}

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

@ -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>
); );
} }

187
src/app/finding-match/finding-match-client.tsx

@ -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>
</>
);
}

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

@ -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>
); );
} }

30
src/app/layout.tsx

@ -236,7 +236,13 @@ export default async function RootLayout({
root.dataset.webBootstrap = 'pending'; root.dataset.webBootstrap = 'pending';
} }
// 4. Instant web_ready Announcement Bridge
// 4. Deferred web_ready Announcement Bridge
//
// web_ready is NOT sent immediately. Pages call
// window.__announceHabibWebReady() after their critical data
// (profile) is loaded so Flutter removes the cover only when
// the UI is actually ready. A 3-second safety fallback ensures
// the cover is never stuck forever.
function announce() { function announce() {
if (window.__habibWebReadySent || !window.HabibApp || !window.HabibApp.postMessage) return false; if (window.__habibWebReadySent || !window.HabibApp || !window.HabibApp.postMessage) return false;
window.__habibWebReadySent = true; window.__habibWebReadySent = true;
@ -259,11 +265,29 @@ export default async function RootLayout({
return true; return true;
} }
if (!tryAnnounce()) {
// Do NOT auto-announce immediately. Pages with SSR-prefetched
// data will call __announceHabibWebReady() once hydrated.
// Safety fallback: auto-announce after 3s if nothing called it.
var _habibAutoAnnounceTimer = setTimeout(function() {
tryAnnounce();
}, 3000);
// If the page calls announce early, clear the fallback timer.
var _origAnnounce = announce;
window.__announceHabibWebReady = function() {
clearTimeout(_habibAutoAnnounceTimer);
return _origAnnounce();
};
// Also keep polling for HabibApp if it wasn't available at
// parse time (non-WebView or slow bridge injection).
if (!window.HabibApp || !window.HabibApp.postMessage) {
var attempts = 0; var attempts = 0;
var timer = setInterval(function() { var timer = setInterval(function() {
attempts += 1; attempts += 1;
if (tryAnnounce() || attempts >= 40) clearInterval(timer);
if ((window.HabibApp && window.HabibApp.postMessage) || attempts >= 40) {
clearInterval(timer);
}
}, 50); }, 50);
} }
})(); })();

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

@ -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>
)}
</>
);
}

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

@ -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>
); );
} }

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

@ -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>
); );
} }

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

@ -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>
</>
);
}

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

@ -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>
); );
} }

760
src/app/request-accepted/request-accepted-client.tsx

@ -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>
</>
);
}

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

@ -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>
); );
} }

147
src/app/request-sent/request-sent-client.tsx

@ -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>
</>
);
}

42
src/lib/ssr-fetch.ts

@ -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;
}
}
Loading…
Cancel
Save