From 7a173dc53943c070440d664176b219acdfc0be84 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Mon, 17 Aug 2026 18:41:05 +0330 Subject: [PATCH] ssr 1 --- .../candidate-contact-client.tsx | 387 +++++++++ src/app/candidate-contact/page.tsx | 405 +-------- .../finding-match/finding-match-client.tsx | 187 ++++ src/app/finding-match/page.tsx | 205 +---- src/app/layout.tsx | 30 +- src/app/new-match/new-match-client.tsx | 675 +++++++++++++++ src/app/new-match/page.tsx | 689 +-------------- src/app/questions-list/page.tsx | 796 +----------------- .../questions-list/questions-list-client.tsx | 775 +++++++++++++++++ src/app/request-accepted/page.tsx | 774 +---------------- .../request-accepted-client.tsx | 760 +++++++++++++++++ src/app/request-sent/page.tsx | 165 +--- src/app/request-sent/request-sent-client.tsx | 147 ++++ src/lib/ssr-fetch.ts | 42 + 14 files changed, 3195 insertions(+), 2842 deletions(-) create mode 100644 src/app/candidate-contact/candidate-contact-client.tsx create mode 100644 src/app/finding-match/finding-match-client.tsx create mode 100644 src/app/new-match/new-match-client.tsx create mode 100644 src/app/questions-list/questions-list-client.tsx create mode 100644 src/app/request-accepted/request-accepted-client.tsx create mode 100644 src/app/request-sent/request-sent-client.tsx create mode 100644 src/lib/ssr-fetch.ts diff --git a/src/app/candidate-contact/candidate-contact-client.tsx b/src/app/candidate-contact/candidate-contact-client.tsx new file mode 100644 index 0000000..96ab1e2 --- /dev/null +++ b/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 ; + } + + // If female, render the beautiful, customized layout matching the design + if (isFemale) { + return ( + <> + + + {isOutcomeSheetOpen ? ( + setIsOutcomeSheetOpen(false)} + onSubmit={async (status) => { + if (status === "success") { + if (caseId) { + await outcomeMutation.mutateAsync({ + status: "success", + }); + } + } else { + setIsDismissReasonSheetOpen(true); + } + }} + /> + ) : null} + + {isDismissReasonSheetOpen ? ( + setIsDismissReasonSheetOpen(false)} + onSubmit={async (value) => { + if (caseId) { + await outcomeMutation.mutateAsync({ + status: "failure", + custom_note: value, + }); + } + }} + /> + ) : null} + +
+ + +
+
+ {isFinalized ? ( +
+
+ 🎉 +
+

+ {t["Congratulations! 🎉"]} +

+

+ { + t[ + "Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed." + ] + } +

+
+ ) : ( + <> + {/* Illustration section */} +
+ {t["Selected +
+ + {/* Title / Status message */} +

+ { + t[ + "The selected candidate will contact your family shortly." + ] + } +

+ + {caseStatus === "contacted" ? ( + <> + {/* Outcome instructions and button */} +
+

+ { + t[ + "Thank you for giving us feedback, we would be very happy if you also let us know the final result." + ] + } +

+
+ +
+ + + +
+ + ) : ( + <> + {/* Action Buttons Row */} +
+ + + +
+ + {/* Red warning box */} +
+

+ { + 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." + ] + } +

+
+ + )} + + )} +
+ +
+ {/* Advisor section */} + + + {/* Profile Locked banner */} +
+
+
+ lock +

+ {t["Profile is locked"]} +

+
+
+
+
+
+
+ + ); + } + + // Fallback UI for male/other users + return ( + <> + + + {isCallResultSheetOpen ? ( + setIsCallResultSheetOpen(false)} + onOtherReasonsClick={() => setIsDismissReasonSheetOpen(true)} + onSubmit={async (value) => { + if (caseId) { + await contactStatusMutation.mutateAsync({ + action: "contacted", + custom_note: value, + }); + } + }} + /> + ) : null} + + {isDismissReasonSheetOpen ? ( + setIsDismissReasonSheetOpen(false)} + onSubmit={async (value) => { + if (caseId) { + await contactStatusMutation.mutateAsync({ + action: "contacted", + custom_note: value, + }); + } + }} + /> + ) : null} + +
+ + +
+
+ {t["Selected + +

+ {t["The selected candidate will contact your family shortly."]} +

+
+
+ +
+ + +
+
+ + ); +} diff --git a/src/app/candidate-contact/page.tsx b/src/app/candidate-contact/page.tsx index 0621ea5..0260429 100644 --- a/src/app/candidate-contact/page.tsx +++ b/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 { - 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 ; } - // If female, render the beautiful, customized layout matching the design - if (isFemale) { - return ( - <> - - - {isOutcomeSheetOpen ? ( - setIsOutcomeSheetOpen(false)} - onSubmit={async (status) => { - if (status === "success") { - if (caseId) { - await outcomeMutation.mutateAsync({ - status: "success", - }); - } - } else { - setIsDismissReasonSheetOpen(true); - } - }} - /> - ) : null} - - {isDismissReasonSheetOpen ? ( - setIsDismissReasonSheetOpen(false)} - onSubmit={async (value) => { - if (caseId) { - await outcomeMutation.mutateAsync({ - status: "failure", - custom_note: value, - }); - } - }} - /> - ) : null} - -
- - -
-
- {isFinalized ? ( -
-
- 🎉 -
-

- {t["Congratulations! 🎉"]} -

-

- { - t[ - "Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed." - ] - } -

-
- ) : ( - <> - {/* Illustration section */} -
- {t["Selected -
- - {/* Title / Status message */} -

- { - t[ - "The selected candidate will contact your family shortly." - ] - } -

- - {caseStatus === "contacted" ? ( - <> - {/* Outcome instructions and button */} -
-

- { - t[ - "Thank you for giving us feedback, we would be very happy if you also let us know the final result." - ] - } -

-
- -
- - - -
- - ) : ( - <> - {/* Action Buttons Row */} -
- - - -
- - {/* Red warning box */} -
-

- { - 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." - ] - } -

-
- - )} - - )} -
- -
- {/* Advisor section */} - - - {/* Profile Locked banner */} -
-
-
- lock -

- {t["Profile is locked"]} -

-
-
-
-
-
-
- - ); - } - - // Fallback UI for male/other users return ( - <> - - - {isCallResultSheetOpen ? ( - setIsCallResultSheetOpen(false)} - onOtherReasonsClick={() => setIsDismissReasonSheetOpen(true)} - onSubmit={async (value) => { - if (caseId) { - await contactStatusMutation.mutateAsync({ - action: "contacted", - custom_note: value, - }); - } - }} - /> - ) : null} - - {isDismissReasonSheetOpen ? ( - setIsDismissReasonSheetOpen(false)} - onSubmit={async (value) => { - if (caseId) { - await contactStatusMutation.mutateAsync({ - action: "contacted", - custom_note: value, - }); - } - }} - /> - ) : null} - -
- - -
-
- {t["Selected - -

- {t["The selected candidate will contact your family shortly."]} -

-
-
- -
- - -
-
- + + + ); } diff --git a/src/app/finding-match/finding-match-client.tsx b/src/app/finding-match/finding-match-client.tsx new file mode 100644 index 0000000..21ae336 --- /dev/null +++ b/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 ; + } + + 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 ( + <> + + +
+ + +
+ +

+ {copy.title} +

+

+ {copy.description} +

+ {unseenRejection && ( + + )}{" "} +
+ + + + + {profile?.can_edit_profile === false ? ( +
+
+ ) : ( + + )} +
+
+ + ); +} diff --git a/src/app/finding-match/page.tsx b/src/app/finding-match/page.tsx index 8e70cb1..2e2f3dd 100644 --- a/src/app/finding-match/page.tsx +++ b/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 ; + 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 ( - <> - - -
- - -
- -

- {copy.title} -

-

- {copy.description} -

- {unseenRejection && ( - - )}{" "} -
- - - - - {profile?.can_edit_profile === false ? ( -
-
- ) : ( - - )} -
-
- + + + ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e10e978..2e00657 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -236,7 +236,13 @@ export default async function RootLayout({ 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() { if (window.__habibWebReadySent || !window.HabibApp || !window.HabibApp.postMessage) return false; window.__habibWebReadySent = true; @@ -259,11 +265,29 @@ export default async function RootLayout({ 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 timer = setInterval(function() { attempts += 1; - if (tryAnnounce() || attempts >= 40) clearInterval(timer); + if ((window.HabibApp && window.HabibApp.postMessage) || attempts >= 40) { + clearInterval(timer); + } }, 50); } })(); diff --git a/src/app/new-match/new-match-client.tsx b/src/app/new-match/new-match-client.tsx new file mode 100644 index 0000000..7a5c142 --- /dev/null +++ b/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; + + 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, +) { + 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(); + 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 ( +

+ {field.label}: + {field.value} +

+ ); +} + +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(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 ( + <> + +
+ + +
+ {/* Header Section Skeleton */} +
+ + + + +
+ +
+ {/* Match Card Skeleton */} +
+ {/* Name line */} + + + {/* Subtitle / Details lines */} +
+ + + +
+ + {/* Button skeleton */} + +
+ + {/* Advisor Card Skeleton */} +
+
+ +
+ + +
+ +
+
+ + + + +
+ + +
+
+
+
+
+
+ + ); + } + 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 ( + <> + + +
+ + +
+
+ +

+ {matchHeadingTitle} +

+

+ {matchHeadingDescription} +

+
+
+ {isLoading ? ( +
+
+ {/* Name line */} + + + {/* Subtitle / Details lines */} +
+ + + +
+ + {/* Button skeleton */} + +
+
+ ) : ( +
+ {isError ? ( +

+ Unable to load match summary. +

+ ) : matchSummary ? ( + <> +

+ Name: + {matchDisplay.name} +

+ +
+ {matchDisplay.occupation ? ( + + ) : null} + + {pairedFields.length ? ( +

+ {pairedFields.map((field, index) => ( + + {index > 0 ? | : null} + + {field.label}: {field.value} + + + ))} +

+ ) : null} + + {matchDisplay.maritalStatus ? ( + + ) : null} + {matchDisplay.cityPreference ? ( + + ) : null} +
+ + + + ) : ( +

+ No match summary is available yet. +

+ )} +
+ )} +
+ +
+
+
+
+ + {profile?.can_edit_profile === false && ( +
+
+
+
+ )} + + {isPaymentSheetOpen && ( +
+
+ + +
+
+ +
+ +

+ {t["Verification & Subscription Activation"] || + "Verification & Subscription Activation"} +

+ +

+ {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."} +

+ +
+ + {t["Valid for 3 months"] || "Valid for 3 months"} + + + {t["50 Coins"] || "50 Habib Coins"} + +
+ +

+ {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."} +

+ + {paymentError && ( +
+ {paymentError} +
+ )} + + {isInsufficientCoins && isInFlutterWebView() && ( + + )} + +
+ + + +
+
+
+
+ )} + + ); +} diff --git a/src/app/new-match/page.tsx b/src/app/new-match/page.tsx index d5e6547..f51ac53 100644 --- a/src/app/new-match/page.tsx +++ b/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 { - 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; - - 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, -) { - 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(); - 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 ( -

- {field.label}: - {field.value} -

- ); -} - -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(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 ( - <> - -
- - -
- {/* Header Section Skeleton */} -
- - - - -
- -
- {/* Match Card Skeleton */} -
- {/* Name line */} - - - {/* Subtitle / Details lines */} -
- - - -
- - {/* Button skeleton */} - -
- - {/* Advisor Card Skeleton */} -
-
- -
- - -
- -
-
- - - - -
- - -
-
-
-
-
-
- - ); + 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 ( - <> - - -
- - -
-
- -

- {matchHeadingTitle} -

-

- {matchHeadingDescription} -

-
-
- {isLoading ? ( -
-
- {/* Name line */} - - - {/* Subtitle / Details lines */} -
- - - -
- - {/* Button skeleton */} - -
-
- ) : ( -
- {isError ? ( -

- Unable to load match summary. -

- ) : matchSummary ? ( - <> -

- Name: - {matchDisplay.name} -

- -
- {matchDisplay.occupation ? ( - - ) : null} - - {pairedFields.length ? ( -

- {pairedFields.map((field, index) => ( - - {index > 0 ? | : null} - - {field.label}: {field.value} - - - ))} -

- ) : null} - - {matchDisplay.maritalStatus ? ( - - ) : null} - {matchDisplay.cityPreference ? ( - - ) : null} -
- - - - ) : ( -

- No match summary is available yet. -

- )} -
- )} -
- -
-
-
-
- - {profile?.can_edit_profile === false && ( -
-
-
-
- )} - - {isPaymentSheetOpen && ( -
-
- - -
-
- -
- -

- {t["Verification & Subscription Activation"] || - "Verification & Subscription Activation"} -

- -

- {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."} -

- -
- - {t["Valid for 3 months"] || "Valid for 3 months"} - - - {t["50 Coins"] || "50 Habib Coins"} - -
- -

- {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."} -

- - {paymentError && ( -
- {paymentError} -
- )} - - {isInsufficientCoins && isInFlutterWebView() && ( - - )} - -
- - - -
-
-
-
- )} - + + + ); } diff --git a/src/app/questions-list/page.tsx b/src/app/questions-list/page.tsx index 4690eeb..b1bf27d 100644 --- a/src/app/questions-list/page.tsx +++ b/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 { - 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(null); - const questionListItems = useMemo( - () => convertOverviewToFrontendItems(overview), - [overview], - ); - const [localAssessmentProgress, setLocalAssessmentProgress] = useState< - Map - >(new Map()); - - useEffect(() => { - const next = new Map(); - 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(); - - 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(null); - - const syncPromiseRef = useRef | 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( - 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()); - 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 ( - <> - -
-
-
- -
- { - e.preventDefault(); - if (typeof window !== "undefined" && (window as any).HabibApp) { - (window as any).HabibApp.postMessage( - JSON.stringify({ action: "close_service" }), - ); - } else { - router.push("/"); - } - }} - /> -

- {t["Profile registration"]} -

- -
- -
- - - {/* Section Card Skeletons (solid blocks like the Meet/checkup - AppShimmer loading — one sweep band runs across each card) */} -
- {Array.from({ length: 6 }).map((_, idx) => ( -
- ))} -
-
- - - - -
- - ); - } - - if (isProfileError || isSchemaError) { - return ( - <> - -
-
-
- -
- { - e.preventDefault(); - if (typeof window !== "undefined" && (window as any).HabibApp) { - (window as any).HabibApp.postMessage( - JSON.stringify({ action: "close_service" }), - ); - } else { - router.push("/"); - } - }} - /> -

- {t["Profile registration"]} -

- -
- - { - if (isProfileError) refetchProfile(); - if (isSchemaError) refetchOverview(); - }} - /> -
- - ); } return ( - <> - {toastMessage && ( - - )} - {isOptionalInfoSheetOpen ? ( - - { - t[ - "You've completed all required fields. However, filling in all sections will help us find better matches for you" - ] - } -

- } - onClose={() => setIsOptionalInfoSheetOpen(false)} - buttons={({ close }) => ( -
- - -
- )} - /> - ) : null} - {selectedSection ? ( - ( - - - {selectedSection.title} - - - - )} - description={ -

- {selectedSection.summary} -

- } - onClose={() => setSelectedSection(null)} - className="text-left" - /> - ) : null} - - - -
-
-
- -
- { - e.preventDefault(); - if (typeof window !== "undefined" && (window as any).HabibApp) { - (window as any).HabibApp.postMessage( - JSON.stringify({ action: "close_service" }), - ); - } else { - router.push("/"); - } - }} - /> -

- {t["Profile registration"]} -

- -
- -
-
- -
- -
- {questionListItems.map((item) => ( - setSelectedSection(section)} - onNearViewport={enqueueViewportPrefetch} - onPrefetch={prefetchSection} - /> - ))} -
-
- - - - -
- + + + ); } diff --git a/src/app/questions-list/questions-list-client.tsx b/src/app/questions-list/questions-list-client.tsx new file mode 100644 index 0000000..6967f59 --- /dev/null +++ b/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(null); + const questionListItems = useMemo( + () => convertOverviewToFrontendItems(overview), + [overview], + ); + const [localAssessmentProgress, setLocalAssessmentProgress] = useState< + Map + >(new Map()); + + useEffect(() => { + const next = new Map(); + 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(); + + 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(null); + + const syncPromiseRef = useRef | 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( + 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()); + 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 ( + <> + +
+
+
+ +
+ { + e.preventDefault(); + if (typeof window !== "undefined" && (window as any).HabibApp) { + (window as any).HabibApp.postMessage( + JSON.stringify({ action: "close_service" }), + ); + } else { + router.push("/"); + } + }} + /> +

+ {t["Profile registration"]} +

+ +
+ +
+ + + {/* Section Card Skeletons (solid blocks like the Meet/checkup + AppShimmer loading — one sweep band runs across each card) */} +
+ {Array.from({ length: 6 }).map((_, idx) => ( +
+ ))} +
+
+ + + + +
+ + ); + } + + if (isProfileError || isSchemaError) { + return ( + <> + +
+
+
+ +
+ { + e.preventDefault(); + if (typeof window !== "undefined" && (window as any).HabibApp) { + (window as any).HabibApp.postMessage( + JSON.stringify({ action: "close_service" }), + ); + } else { + router.push("/"); + } + }} + /> +

+ {t["Profile registration"]} +

+ +
+ + { + if (isProfileError) refetchProfile(); + if (isSchemaError) refetchOverview(); + }} + /> +
+ + ); + } + + return ( + <> + {toastMessage && ( + + )} + {isOptionalInfoSheetOpen ? ( + + { + t[ + "You've completed all required fields. However, filling in all sections will help us find better matches for you" + ] + } +

+ } + onClose={() => setIsOptionalInfoSheetOpen(false)} + buttons={({ close }) => ( +
+ + +
+ )} + /> + ) : null} + {selectedSection ? ( + ( + + + {selectedSection.title} + + + + )} + description={ +

+ {selectedSection.summary} +

+ } + onClose={() => setSelectedSection(null)} + className="text-left" + /> + ) : null} + + + +
+
+
+ +
+ { + e.preventDefault(); + if (typeof window !== "undefined" && (window as any).HabibApp) { + (window as any).HabibApp.postMessage( + JSON.stringify({ action: "close_service" }), + ); + } else { + router.push("/"); + } + }} + /> +

+ {t["Profile registration"]} +

+ +
+ +
+
+ +
+ +
+ {questionListItems.map((item) => ( + setSelectedSection(section)} + onNearViewport={enqueueViewportPrefetch} + onPrefetch={prefetchSection} + /> + ))} +
+
+ + + + +
+ + ); +} diff --git a/src/app/request-accepted/page.tsx b/src/app/request-accepted/page.tsx index 0f94681..b6f1e3a 100644 --- a/src/app/request-accepted/page.tsx +++ b/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 { - 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; - - 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 ( - - ); -} - -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(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 ; - } - - 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 ( - <> - - - {isCallResultSheetOpen ? ( - setIsCallResultSheetOpen(false)} - onOtherReasonsClick={() => setIsDismissReasonSheetOpen(true)} - onSubmit={async (value) => { - if (caseId) { - await contactStatusMutation.mutateAsync({ - action: "contacted", - custom_note: value, - }); - } - }} - /> - ) : null} - - {isContactReceivedConfirmOpen ? ( - - {t["Are you sure contact has been made?"]} -

- } - buttons={ - 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 ? ( - setIsDismissReasonSheetOpen(false)} - onSubmit={async (value) => { - if (caseId) { - await outcomeMutation.mutateAsync({ - status: "failure", - custom_note: value, - }); - } - }} - /> - ) : null} - - {isOutcomeSheetOpen ? ( - isFemaleProfile ? ( - 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, - }); - } - } - }} - /> - ) : ( - setIsOutcomeSheetOpen(false)} - onSubmit={async (status) => { - if (status === "success") { - if (caseId) { - await outcomeMutation.mutateAsync({ - status: "success", - }); - } - } else { - setIsDismissReasonSheetOpen(true); - } - }} - /> - ) - ) : null} - - {isContactInfoSheetOpen ? ( - - {contactInfoPhoneItems.map((item) => ( - - ))} - - ) : ( -
- {t["Contact information is not available yet."]} -
- ) - } - onClose={() => setIsContactInfoSheetOpen(false)} - /> - ) : null} - - {isSubscriptionSheetOpen ? ( - { - setIsSubscriptionSheetOpen(false); - setPaymentError(null); - setIsInsufficientCoins(false); - }} - onPayment={handlePayment} - isPaymentPending={!recommendedPlanId || paymentMutation.isPending} - errorMessage={paymentError} - showBuyCoins={isInsufficientCoins} - /> - ) : null} - - {isNoContactConfirmOpen ? ( - - { - t[ - "No contact has been made with you in any way or by any party." - ] - } -

- } - buttons={ - - } - onClose={() => setIsNoContactConfirmOpen(false)} - closeOnOutside={true} - /> - ) : null} - -
- - -
-
- {isFinalized ? ( -
-
- 🎉 -
-

- {t["Congratulations! 🎉"]} -

-

- { - t[ - "Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed." - ] - } -

-
- ) : ( - <> -
- {t["Request -
- -

- {titleText} -

- - {caseStatus === "contacted" || - isFemaleContactConfirmed || - (isFemaleProfile && contactStatusMutation.isPending) ? ( -
- {isFemaleProfile && contactStatusMutation.isPending ? ( - - ) : ( -

- {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." - ]} -

- )} -
- ) : ( -

- {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." - ]} -

- )} - - <> - {caseStatus === "contacted" || - isFemaleContactConfirmed || - (isFemaleProfile && contactStatusMutation.isPending) ? ( -
- {isFemaleProfile && - contactStatusMutation.isPending ? null : isFemaleProfile ? ( - - ) : ( - <> - - - - - )} -
- ) : ( -
- {isFemaleProfile ? ( - - ) : ( - -
- {primaryActionText} -
- - )} - - -
- )} - - {caseStatus !== "contacted" && - !isFemaleContactConfirmed && - !noContactReportedSuccess && - !(isFemaleProfile && contactStatusMutation.isPending) ? ( -
-

- { - 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." - ] - } -

-
- ) : null} - - - )} -
- -
- {/* Advisor section */} - - -
-
-
- lock - -

- {t["Profile is locked"]} -

-
-
-
-
-
-
- + + + ); } diff --git a/src/app/request-accepted/request-accepted-client.tsx b/src/app/request-accepted/request-accepted-client.tsx new file mode 100644 index 0000000..801aa03 --- /dev/null +++ b/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; + + 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 ( + + ); +} + +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(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 ; + } + + 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 ( + <> + + + {isCallResultSheetOpen ? ( + setIsCallResultSheetOpen(false)} + onOtherReasonsClick={() => setIsDismissReasonSheetOpen(true)} + onSubmit={async (value) => { + if (caseId) { + await contactStatusMutation.mutateAsync({ + action: "contacted", + custom_note: value, + }); + } + }} + /> + ) : null} + + {isContactReceivedConfirmOpen ? ( + + {t["Are you sure contact has been made?"]} +

+ } + buttons={ + 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 ? ( + setIsDismissReasonSheetOpen(false)} + onSubmit={async (value) => { + if (caseId) { + await outcomeMutation.mutateAsync({ + status: "failure", + custom_note: value, + }); + } + }} + /> + ) : null} + + {isOutcomeSheetOpen ? ( + isFemaleProfile ? ( + 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, + }); + } + } + }} + /> + ) : ( + setIsOutcomeSheetOpen(false)} + onSubmit={async (status) => { + if (status === "success") { + if (caseId) { + await outcomeMutation.mutateAsync({ + status: "success", + }); + } + } else { + setIsDismissReasonSheetOpen(true); + } + }} + /> + ) + ) : null} + + {isContactInfoSheetOpen ? ( + + {contactInfoPhoneItems.map((item) => ( + + ))} + + ) : ( +
+ {t["Contact information is not available yet."]} +
+ ) + } + onClose={() => setIsContactInfoSheetOpen(false)} + /> + ) : null} + + {isSubscriptionSheetOpen ? ( + { + setIsSubscriptionSheetOpen(false); + setPaymentError(null); + setIsInsufficientCoins(false); + }} + onPayment={handlePayment} + isPaymentPending={!recommendedPlanId || paymentMutation.isPending} + errorMessage={paymentError} + showBuyCoins={isInsufficientCoins} + /> + ) : null} + + {isNoContactConfirmOpen ? ( + + { + t[ + "No contact has been made with you in any way or by any party." + ] + } +

+ } + buttons={ + + } + onClose={() => setIsNoContactConfirmOpen(false)} + closeOnOutside={true} + /> + ) : null} + +
+ + +
+
+ {isFinalized ? ( +
+
+ 🎉 +
+

+ {t["Congratulations! 🎉"]} +

+

+ { + t[ + "Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed." + ] + } +

+
+ ) : ( + <> +
+ {t["Request +
+ +

+ {titleText} +

+ + {caseStatus === "contacted" || + isFemaleContactConfirmed || + (isFemaleProfile && contactStatusMutation.isPending) ? ( +
+ {isFemaleProfile && contactStatusMutation.isPending ? ( + + ) : ( +

+ {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." + ]} +

+ )} +
+ ) : ( +

+ {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." + ]} +

+ )} + + <> + {caseStatus === "contacted" || + isFemaleContactConfirmed || + (isFemaleProfile && contactStatusMutation.isPending) ? ( +
+ {isFemaleProfile && + contactStatusMutation.isPending ? null : isFemaleProfile ? ( + + ) : ( + <> + + + + + )} +
+ ) : ( +
+ {isFemaleProfile ? ( + + ) : ( + +
+ {primaryActionText} +
+ + )} + + +
+ )} + + {caseStatus !== "contacted" && + !isFemaleContactConfirmed && + !noContactReportedSuccess && + !(isFemaleProfile && contactStatusMutation.isPending) ? ( +
+

+ { + 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." + ] + } +

+
+ ) : null} + + + )} +
+ +
+ {/* Advisor section */} + + +
+
+
+ lock + +

+ {t["Profile is locked"]} +

+
+
+
+
+
+
+ + ); +} diff --git a/src/app/request-sent/page.tsx b/src/app/request-sent/page.tsx index ec8c208..4e9b4d2 100644 --- a/src/app/request-sent/page.tsx +++ b/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 ; + 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 ( - <> - - -
- - -
-
-
- Request sent -
- -

- {requestSentCopy.title} -

- -

- {requestSentCopy.description} -

- - -
- {requestSentCopy.matchProfile} -
- -
- -
- - -
-
-
- lock -

- {requestSentCopy.profileLocked} -

-
-
-
-
-
-
- + + + ); } diff --git a/src/app/request-sent/request-sent-client.tsx b/src/app/request-sent/request-sent-client.tsx new file mode 100644 index 0000000..d72fed1 --- /dev/null +++ b/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 ; + } + + 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 ( + <> + + +
+ + +
+
+
+ Request sent +
+ +

+ {requestSentCopy.title} +

+ +

+ {requestSentCopy.description} +

+ + +
+ {requestSentCopy.matchProfile} +
+ +
+ +
+ + +
+
+
+ lock +

+ {requestSentCopy.profileLocked} +

+
+
+
+
+
+
+ + ); +} diff --git a/src/lib/ssr-fetch.ts b/src/lib/ssr-fetch.ts new file mode 100644 index 0000000..828fb03 --- /dev/null +++ b/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 { + 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; + } +}