From 64b7239ed033d50e44b9eee99717a48f538b18fd Mon Sep 17 00:00:00 2001 From: mortezaei Date: Mon, 17 Aug 2026 18:10:47 +0330 Subject: [PATCH 01/35] refactor: offload authentication route resolution to client and improve redirect logic in landing pages --- src/app/[lang]/page.tsx | 34 ++-------------------------------- src/app/page.tsx | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 37 deletions(-) diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx index a0099b7..af36864 100644 --- a/src/app/[lang]/page.tsx +++ b/src/app/[lang]/page.tsx @@ -7,10 +7,6 @@ import { isAuthenticatedToken, MARRIAGE_ENTRY_PATH_COOKIE, } from "@/lib/entry-route-cache"; -import { - getSubmitPath, - hasCompletedMarriageProfileBasics, -} from "@/lib/get-submit-path"; import { localizePath } from "@/translations/config"; export const dynamic = "force-dynamic"; @@ -34,38 +30,12 @@ export default async function LocaleEntryPage({ redirect(localizePath(cachedEntryPath, lang)); } - // 1. If not authenticated, render instantly on the server (0ms client delay) + // 1. If not authenticated, render instantly in SSR (0ms client delay) if (!hasToken) { return ; } - // 2. If authenticated without cached route, resolve target route directly on the server - let targetPath: string | null = null; - const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; - if (apiBaseUrl) { - try { - const res = await fetch(`${apiBaseUrl}/api/marriage/profile/main/`, { - headers: { - Authorization: `Token ${token}`, - Accept: "application/json", - }, - cache: "no-store", - }); - if (res.ok) { - const profile = await res.json(); - targetPath = hasCompletedMarriageProfileBasics(profile) - ? getSubmitPath(profile) - : "/intro"; - } - } catch { - // Graceful fallback to client-side resolver if server fetch fails - } - } - - if (targetPath) { - redirect(localizePath(targetPath, lang)); - } - + // 2. If authenticated without cached route, deliver head/web_ready immediately and resolve in client return ( <> diff --git a/src/app/page.tsx b/src/app/page.tsx index 894b332..b42f0bf 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,6 +2,7 @@ import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; import { getAuthenticatedCachedEntryPath, + isAuthenticatedToken, MARRIAGE_ENTRY_PATH_COOKIE, } from "@/lib/entry-route-cache"; import { @@ -36,14 +37,20 @@ export default async function RootPage() { const token = cookieStore.get("HABIB_TOKEN")?.value ?? cookieStore.get("habib_token")?.value; + const hasToken = isAuthenticatedToken(token); + + if (!hasToken) { + redirect(`/${targetLocale}`); + } + const cachedEntryPath = getAuthenticatedCachedEntryPath( token, cookieStore.get(MARRIAGE_ENTRY_PATH_COOKIE)?.value, ); - redirect( - cachedEntryPath - ? localizePath(cachedEntryPath, targetLocale) - : `/${targetLocale}`, - ); + if (cachedEntryPath) { + redirect(localizePath(cachedEntryPath, targetLocale)); + } + + redirect(`/${targetLocale}`); } From ae9c42add07d86ebebaf16cfc29eb24964f30a7a Mon Sep 17 00:00:00 2001 From: mortezaei Date: Mon, 17 Aug 2026 18:16:12 +0330 Subject: [PATCH 02/35] feat: expose and trigger web ready announcement bridge for entry route navigation --- src/app/layout.tsx | 6 ++++-- src/components/Componentes/entry-route-resolver.tsx | 9 +++++++++ src/types/window.d.ts | 1 + 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2077197..e10e978 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -236,7 +236,7 @@ export default async function RootLayout({ root.dataset.webBootstrap = 'pending'; } - // 4. Instant web_ready Announcement + // 4. Instant web_ready Announcement Bridge function announce() { if (window.__habibWebReadySent || !window.HabibApp || !window.HabibApp.postMessage) return false; window.__habibWebReadySent = true; @@ -247,13 +247,15 @@ export default async function RootLayout({ return true; } + window.__announceHabibWebReady = announce; + function tryAnnounce() { if (!announce()) return false; setTimeout(function() { if (root.dataset.webBootstrap === 'pending') { root.dataset.webBootstrap = 'ready'; } - }, 3000); + }, 2500); return true; } diff --git a/src/components/Componentes/entry-route-resolver.tsx b/src/components/Componentes/entry-route-resolver.tsx index dd95254..bda5667 100644 --- a/src/components/Componentes/entry-route-resolver.tsx +++ b/src/components/Componentes/entry-route-resolver.tsx @@ -33,7 +33,14 @@ export default function EntryRouteResolver({ useEffect(() => { let isActive = true; + const announceReady = () => { + if (typeof window !== "undefined") { + window.__announceHabibWebReady?.(); + } + }; + const goToIntro = () => { + announceReady(); if (!anonymousEntryVisible) { router.replace(localizePath("/intro", locale)); } @@ -49,6 +56,7 @@ export default function EntryRouteResolver({ const cachedEntryPath = getCachedMarriageEntryPath(); if (cachedEntryPath) { + announceReady(); router.replace(localizePath(cachedEntryPath, locale)); return; } @@ -67,6 +75,7 @@ export default function EntryRouteResolver({ return; } + announceReady(); router.replace(localizePath(getSubmitPath(profile), locale)); } catch (error) { console.warn("Could not resolve entry route", error); diff --git a/src/types/window.d.ts b/src/types/window.d.ts index 3ed3342..20fdafa 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -87,6 +87,7 @@ declare global { sendToFlutter?: (action: string, data?: Record) => void; __HABIB_BOOTSTRAP__?: NonNullable; __habibWebReadySent?: boolean; + __announceHabibWebReady?: () => boolean; } } From 7a173dc53943c070440d664176b219acdfc0be84 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Mon, 17 Aug 2026 18:41:05 +0330 Subject: [PATCH 03/35] 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; + } +} From c6837bb5742fbbace7a0227bb1be5fb381a61f71 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Mon, 17 Aug 2026 18:49:38 +0330 Subject: [PATCH 04/35] ssr 2 --- src/app/[lang]/page.tsx | 24 ++++++++++++++++++++---- src/app/page.tsx | 13 +++++++++++++ src/lib/ssr-fetch.ts | 16 ++++++++++++---- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx index af36864..7465e02 100644 --- a/src/app/[lang]/page.tsx +++ b/src/app/[lang]/page.tsx @@ -7,6 +7,11 @@ import { isAuthenticatedToken, MARRIAGE_ENTRY_PATH_COOKIE, } from "@/lib/entry-route-cache"; +import { + getSubmitPath, + hasCompletedMarriageProfileBasics, +} from "@/lib/get-submit-path"; +import { fetchProfileSSR } from "@/lib/ssr-fetch"; import { localizePath } from "@/translations/config"; export const dynamic = "force-dynamic"; @@ -21,6 +26,13 @@ export default async function LocaleEntryPage({ cookieStore.get("HABIB_TOKEN")?.value ?? cookieStore.get("habib_token")?.value; const hasToken = isAuthenticatedToken(token); + + // 1. If not authenticated, render instantly in SSR + if (!hasToken) { + return ; + } + + // 2. Check cached entry path first const cachedEntryPath = getAuthenticatedCachedEntryPath( token, cookieStore.get(MARRIAGE_ENTRY_PATH_COOKIE)?.value, @@ -30,12 +42,16 @@ export default async function LocaleEntryPage({ redirect(localizePath(cachedEntryPath, lang)); } - // 1. If not authenticated, render instantly in SSR (0ms client delay) - if (!hasToken) { - return ; + // 3. Deep SSR Profile Resolution: resolve profile & redirect directly on server + const profile = await fetchProfileSSR(token); + if (profile) { + const targetPath = hasCompletedMarriageProfileBasics(profile) + ? getSubmitPath(profile) + : "/intro"; + redirect(localizePath(targetPath, lang)); } - // 2. If authenticated without cached route, deliver head/web_ready immediately and resolve in client + // 4. Graceful Fallback if server fetch was unreachable return ( <> diff --git a/src/app/page.tsx b/src/app/page.tsx index b42f0bf..aae23df 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,6 +5,11 @@ import { isAuthenticatedToken, MARRIAGE_ENTRY_PATH_COOKIE, } from "@/lib/entry-route-cache"; +import { + getSubmitPath, + hasCompletedMarriageProfileBasics, +} from "@/lib/get-submit-path"; +import { fetchProfileSSR } from "@/lib/ssr-fetch"; import { defaultLocale, isLocale, @@ -52,5 +57,13 @@ export default async function RootPage() { redirect(localizePath(cachedEntryPath, targetLocale)); } + const profile = await fetchProfileSSR(token); + if (profile) { + const targetPath = hasCompletedMarriageProfileBasics(profile) + ? getSubmitPath(profile) + : "/intro"; + redirect(localizePath(targetPath, targetLocale)); + } + redirect(`/${targetLocale}`); } diff --git a/src/lib/ssr-fetch.ts b/src/lib/ssr-fetch.ts index 828fb03..e46470d 100644 --- a/src/lib/ssr-fetch.ts +++ b/src/lib/ssr-fetch.ts @@ -14,8 +14,14 @@ function getApiBaseUrl(): string { * @param token - The user authentication token * @returns Profile data or null on failure */ -export async function fetchProfileSSR(token: string): Promise { - if (!token) return null; +export async function fetchProfileSSR( + token: string, + timeoutMs = 1500, +): Promise { + if (!token || token === "NO_TOKEN" || token.trim() === "") return null; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const baseUrl = getApiBaseUrl(); @@ -24,9 +30,10 @@ export async function fetchProfileSSR(token: string): Promise { const response = await fetch(url, { method: "GET", cache: "no-store", + signal: controller.signal, headers: { Accept: "application/json", - Authorization: `Token ${token}`, + Authorization: `Token ${token.trim()}`, }, }); @@ -36,7 +43,8 @@ export async function fetchProfileSSR(token: string): Promise { return await response.json(); } catch (error) { - console.error("fetchProfileSSR Error:", error); return null; + } finally { + clearTimeout(timeoutId); } } From be434a03624a23205b2b010316195a93a1575716 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Mon, 17 Aug 2026 18:51:07 +0330 Subject: [PATCH 05/35] ssr 2 --- src/lib/ssr-fetch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/ssr-fetch.ts b/src/lib/ssr-fetch.ts index e46470d..473d7e9 100644 --- a/src/lib/ssr-fetch.ts +++ b/src/lib/ssr-fetch.ts @@ -15,7 +15,7 @@ function getApiBaseUrl(): string { * @returns Profile data or null on failure */ export async function fetchProfileSSR( - token: string, + token?: string | null, timeoutMs = 1500, ): Promise { if (!token || token === "NO_TOKEN" || token.trim() === "") return null; From ce49189a3b4f11066ec1de411960c0139149d9d4 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Mon, 17 Aug 2026 18:54:32 +0330 Subject: [PATCH 06/35] fix: improve date string validation, parsing, and formatting logic in date components --- .../Componentes/question-date-sheet.tsx | 19 ++++-- src/components/Componentes/question-date.tsx | 66 +++++++++++++++---- 2 files changed, 67 insertions(+), 18 deletions(-) diff --git a/src/components/Componentes/question-date-sheet.tsx b/src/components/Componentes/question-date-sheet.tsx index b07c62f..1fd0854 100644 --- a/src/components/Componentes/question-date-sheet.tsx +++ b/src/components/Componentes/question-date-sheet.tsx @@ -156,12 +156,23 @@ export function QuestionDateSheet({ const { dictionary: t, locale } = useI18n(); const [isClosing, setIsClosing] = useState(false); - const parts = value ? value.split("-") : []; - const [selectedYear, setSelectedYear] = useState(parts[0] || YEARS[0]); + const parts = + value && typeof value === "string" ? value.trim().split("-") : []; + const rawYear = parts.length === 3 ? parts[0] : ""; + const rawMonth = parts.length === 3 ? parts[1]?.padStart(2, "0") : ""; + const rawDay = parts.length === 3 ? parts[2]?.padStart(2, "0") : ""; + + const [selectedYear, setSelectedYear] = useState( + rawYear && YEARS.includes(rawYear) ? rawYear : YEARS[0], + ); const [selectedMonth, setSelectedMonth] = useState( - parts[1] || MONTH_VALUES[0], + rawMonth && MONTH_VALUES.includes(rawMonth) ? rawMonth : MONTH_VALUES[0], + ); + const [selectedDay, setSelectedDay] = useState( + rawDay && Number.parseInt(rawDay, 10) >= 1 && Number.parseInt(rawDay, 10) <= 31 + ? rawDay + : "01", ); - const [selectedDay, setSelectedDay] = useState(parts[2] || "01"); const dirtyRef = useRef(false); const closeSheet = useCallback(() => { diff --git a/src/components/Componentes/question-date.tsx b/src/components/Componentes/question-date.tsx index ca4af7d..8e9c1b8 100644 --- a/src/components/Componentes/question-date.tsx +++ b/src/components/Componentes/question-date.tsx @@ -19,11 +19,33 @@ function parseDateParts(dateValue: string): { month: string; day: string; } { - const parts = dateValue ? dateValue.split("-") : []; + if (typeof dateValue !== "string") { + return { year: "", month: "", day: "" }; + } + const parts = dateValue.trim().split("-"); + if (parts.length !== 3) { + return { year: "", month: "", day: "" }; + } + const [y, m, d] = parts; + const yearNum = Number.parseInt(y, 10); + const monthNum = Number.parseInt(m, 10); + const dayNum = Number.parseInt(d, 10); + if ( + Number.isNaN(yearNum) || + Number.isNaN(monthNum) || + Number.isNaN(dayNum) || + yearNum <= 0 || + monthNum < 1 || + monthNum > 12 || + dayNum < 1 || + dayNum > 31 + ) { + return { year: "", month: "", day: "" }; + } return { - year: parts[0] || "", - month: parts[1] || "", - day: parts[2] || "", + year: y, + month: m.padStart(2, "0"), + day: d.padStart(2, "0"), }; } @@ -70,16 +92,32 @@ export function QuestionDate({ question, disabled }: QuestionDateProps) { const displayDate = useMemo(() => { if (!year || !month || !day) return ""; - const date = new Date( - Number.parseInt(year, 10), - Number.parseInt(month, 10) - 1, - Number.parseInt(day, 10), - ); - return new Intl.DateTimeFormat(`${locale}-u-ca-gregory`, { - day: "numeric", - month: "long", - year: "numeric", - }).format(date); + const y = Number.parseInt(year, 10); + const m = Number.parseInt(month, 10); + const d = Number.parseInt(day, 10); + if ( + !y || + !m || + !d || + Number.isNaN(y) || + Number.isNaN(m) || + Number.isNaN(d) + ) { + return ""; + } + const date = new Date(y, m - 1, d); + if (Number.isNaN(date.getTime()) || !Number.isFinite(date.getTime())) { + return ""; + } + try { + return new Intl.DateTimeFormat(`${locale}-u-ca-gregory`, { + day: "numeric", + month: "long", + year: "numeric", + }).format(date); + } catch { + return ""; + } }, [year, month, day, locale]); const openSheet = () => { From 93ea3b281c1891f0e7fd8f1cb9a62c2b499bf734 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Mon, 17 Aug 2026 19:05:40 +0330 Subject: [PATCH 07/35] fix: add confirm button to date sheet and add tests for date error handling --- .../Componentes/question-date-sheet.tsx | 16 ++++++++++++++++ .../Componentes/question-date.test.tsx | 15 ++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/components/Componentes/question-date-sheet.tsx b/src/components/Componentes/question-date-sheet.tsx index 1fd0854..7ecde83 100644 --- a/src/components/Componentes/question-date-sheet.tsx +++ b/src/components/Componentes/question-date-sheet.tsx @@ -11,6 +11,7 @@ import { import { createPortal } from "react-dom"; import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; +import { Button } from "./button"; import { useSheetScrollLock } from "./use-sheet-scroll-lock"; const EXIT_ANIMATION_MS = 300; @@ -349,6 +350,21 @@ export function QuestionDateSheet({ /> +
+ +
, diff --git a/src/components/Componentes/question-date.test.tsx b/src/components/Componentes/question-date.test.tsx index 9321f95..0f601dc 100644 --- a/src/components/Componentes/question-date.test.tsx +++ b/src/components/Componentes/question-date.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { QuestionField } from "@/lib/schema-adapter"; import { QuestionDate } from "./question-date"; @@ -36,6 +36,7 @@ const question = { describe("QuestionDate", () => { beforeEach(() => { + cleanup(); answerValue = null; }); @@ -57,4 +58,16 @@ describe("QuestionDate", () => { const yearWheel = screen.getByLabelText("Year"); expect(yearWheel.textContent).not.toMatch(/[,٬]/); }); + + it("handles invalid date strings gracefully without throwing RangeError", () => { + answerValue = "Mortezaei"; + const { rerender } = render(); + expect(screen.queryByText("Age")).toBeNull(); + expect(screen.getByText("Select date")).toBeDefined(); + + answerValue = "invalid-date-format"; + rerender(); + expect(screen.queryByText("Age")).toBeNull(); + expect(screen.getByText("Select date")).toBeDefined(); + }); }); From 8302f9ac9c81f9927e20e0894d3e869e6e1bd6b5 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Mon, 17 Aug 2026 23:36:03 +0330 Subject: [PATCH 08/35] ssr 3 --- .../candidate-contact-client.tsx | 7 +- .../finding-match/finding-match-client.tsx | 7 +- src/app/intro/intro-client.tsx | 197 ++++++++++++++++ src/app/intro/page.tsx | 222 +++--------------- src/app/layout.tsx | 71 +++--- src/app/new-match/new-match-client.tsx | 7 +- .../questions-list/questions-list-client.tsx | 7 +- .../request-accepted-client.tsx | 7 +- src/app/request-sent/request-sent-client.tsx | 7 +- src/app/terms/page.tsx | 5 + .../Componentes/entry-route-resolver.tsx | 20 +- .../Componentes/navigation-button.tsx | 12 +- src/components/Componentes/page-header.tsx | 25 +- src/hooks/marriage/query-keys.ts | 1 + src/hooks/marriage/use-marriage-config.ts | 3 +- src/hooks/use-habib-web-ready.ts | 25 ++ src/hooks/useFlutterBridge.ts | 51 ++-- src/lib/ssr-fetch.ts | 39 +++ src/types/window.d.ts | 2 +- 19 files changed, 420 insertions(+), 295 deletions(-) create mode 100644 src/app/intro/intro-client.tsx create mode 100644 src/hooks/use-habib-web-ready.ts diff --git a/src/app/candidate-contact/candidate-contact-client.tsx b/src/app/candidate-contact/candidate-contact-client.tsx index 96ab1e2..be5c42b 100644 --- a/src/app/candidate-contact/candidate-contact-client.tsx +++ b/src/app/candidate-contact/candidate-contact-client.tsx @@ -3,6 +3,7 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; +import { useHabibWebReady } from "@/hooks/use-habib-web-ready"; import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot"; import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; import CallResultSheet from "@/components/Componentes/call-result-sheet"; @@ -50,11 +51,7 @@ export default function CandidateContactClient() { }, [profile, router, locale]); // Signal Flutter to lift its loading cover once the profile is available. - useEffect(() => { - if (profile && !isProfileLoading) { - window.__announceHabibWebReady?.(); - } - }, [profile, isProfileLoading]); + useHabibWebReady(!!profile && !isProfileLoading); const isRedirecting = useMemo(() => { if (!profile) return false; diff --git a/src/app/finding-match/finding-match-client.tsx b/src/app/finding-match/finding-match-client.tsx index 21ae336..edae5af 100644 --- a/src/app/finding-match/finding-match-client.tsx +++ b/src/app/finding-match/finding-match-client.tsx @@ -3,6 +3,7 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; import { useEffect, useMemo } from "react"; +import { useHabibWebReady } from "@/hooks/use-habib-web-ready"; import { FaLock, FaPen } from "react-icons/fa6"; import { IoAlertCircle } from "react-icons/io5"; import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; @@ -44,11 +45,7 @@ export default function FindingMatchClient() { }, [profile, locale, router]); // Signal Flutter to lift its loading cover once the profile is available. - useEffect(() => { - if (profile && !isLoading) { - window.__announceHabibWebReady?.(); - } - }, [profile, isLoading]); + useHabibWebReady(!!profile && !isLoading); const isRedirecting = useMemo(() => { if (!profile) return false; diff --git a/src/app/intro/intro-client.tsx b/src/app/intro/intro-client.tsx new file mode 100644 index 0000000..bcf6d05 --- /dev/null +++ b/src/app/intro/intro-client.tsx @@ -0,0 +1,197 @@ +"use client"; + +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import Button from "@/components/Componentes/button"; +import NetworkImage from "@/components/Componentes/network-image"; +import PageHeader from "@/components/Componentes/page-header"; +import ReportActionsSheet from "@/components/Componentes/report-actions-sheet"; +import VideoPlayer from "@/components/Componentes/video-player"; +import { useMarriageConfigQuery } from "@/hooks/marriage/use-marriage-config"; +import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; +import { useHabibWebReady } from "@/hooks/use-habib-web-ready"; +import { authBridge } from "@/lib/auth-bridge"; +import { getSubmitPath } from "@/lib/get-submit-path"; +import { localizePath } from "@/translations/config"; +import { useI18n } from "@/translations/provider"; + +export default function IntroClient() { + const router = useRouter(); + const { dictionary: t, locale } = useI18n(); + const { data: profile, refetch } = useMarriageProfileQuery({ + enabled: false, + retry: false, + }); + const [isReportSheetOpen, setIsReportSheetOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isPlayerOpen, setIsPlayerOpen] = useState(false); + + const { data: config } = useMarriageConfigQuery(); + + // Signal Flutter that the Intro UI is ready. Config has a local fallback + // image so we don't need to wait for it — announce immediately on mount. + useHabibWebReady(true); + + const handleSubmit = async () => { + if (isSubmitting) { + return; + } + + setIsSubmitting(true); + + try { + if (!authBridge.isAuthenticated()) { + const token = await authBridge.ensureToken(); + if (!token) { + console.warn("No token from bridge – login was not completed"); + return; + } + } + + let profileResponse = profile; + try { + const { data: freshProfile } = await refetch(); + profileResponse = freshProfile ?? profile; + } catch (refetchError) { + console.warn( + "Could not refetch profile data – using fallback", + refetchError, + ); + } + + const submitPath = getSubmitPath(profileResponse); + const nextPath = localizePath( + submitPath === "/intro" ? "/terms" : submitPath, + locale, + ); + router.push(nextPath); + } catch (error) { + console.error("Submission/redirect failed", error); + router.push(localizePath("/terms", locale)); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ {isReportSheetOpen && ( + setIsReportSheetOpen(false)} /> + )} + setIsReportSheetOpen(true), + }} + /> +
+
+ {t["heavenly +

+ {t["A Path to Heavenly Marriage"]} +

+

+ { + t[ + 'We have come together with the goal of creating a secure and confidential path for "permanent marriage" among Muslims' + ] + } +

+
+
+
+ {t["user +
+

120

+

+ {t["user profiles"]} +

+
+
+
+ {t["matches"]} +
+

14

+

+ {t["matches"]} +

+
+
+
+ {t["marriages"]} +
+

14

+

+ {t["marriages"]} +

+
+
+
+
setIsPlayerOpen(true)} + > + +
+ {t["play"]} +
+ + setIsPlayerOpen(false)} + videoUrl={config?.intro_video_url} + /> +
+
+ +
+
+
+
+ ); +} diff --git a/src/app/intro/page.tsx b/src/app/intro/page.tsx index 87a8d1f..89223f9 100644 --- a/src/app/intro/page.tsx +++ b/src/app/intro/page.tsx @@ -1,192 +1,42 @@ -"use client"; - -import Image from "next/image"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import Button from "@/components/Componentes/button"; -import NetworkImage from "@/components/Componentes/network-image"; -import PageHeader from "@/components/Componentes/page-header"; -import ReportActionsSheet from "@/components/Componentes/report-actions-sheet"; -import VideoPlayer from "@/components/Componentes/video-player"; -import { useMarriageConfigQuery } from "@/hooks/marriage/use-marriage-config"; -import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; -import { authBridge } from "@/lib/auth-bridge"; -import { getSubmitPath } from "@/lib/get-submit-path"; -import { localizePath } from "@/translations/config"; -import { useI18n } from "@/translations/provider"; - -export default function Intro() { - const router = useRouter(); - const { dictionary: t, locale } = useI18n(); - const { data: profile, refetch } = useMarriageProfileQuery({ - enabled: false, - retry: false, +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from "@tanstack/react-query"; +import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; +import { fetchConfigSSR } from "@/lib/ssr-fetch"; +import IntroClient from "./intro-client"; + +export const dynamic = "force-dynamic"; + +/** + * Server Component wrapper for the Intro page. + * + * Follows the same SSR-prefetch pattern as questions-list/page.tsx: + * create a server QueryClient, prefetch critical data, dehydrate, and + * wrap the client component in HydrationBoundary so TanStack Query + * hydrates the cache instantly — no client-side waterfall. + * + * Config is the only data Intro needs. Even if the fetch fails, IntroClient + * has a local fallback image so the UI is never broken. + */ +export default async function IntroPage() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 30 * 1000 }, + }, }); - const [isReportSheetOpen, setIsReportSheetOpen] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [isPlayerOpen, setIsPlayerOpen] = useState(false); - - const { data: config } = useMarriageConfigQuery(); - - const handleSubmit = async () => { - if (isSubmitting) { - return; - } - - setIsSubmitting(true); - try { - if (!authBridge.isAuthenticated()) { - const token = await authBridge.ensureToken(); - if (!token) { - console.warn("No token from bridge – login was not completed"); - return; - } - } - - let profileResponse = profile; - try { - const { data: freshProfile } = await refetch(); - profileResponse = freshProfile ?? profile; - } catch (refetchError) { - console.warn( - "Could not refetch profile data – using fallback", - refetchError, - ); - } - - const submitPath = getSubmitPath(profileResponse); - const nextPath = localizePath( - submitPath === "/intro" ? "/terms" : submitPath, - locale, - ); - router.push(nextPath); - } catch (error) { - console.error("Submission/redirect failed", error); - router.push(localizePath("/terms", locale)); - } finally { - setIsSubmitting(false); - } - }; + // Prefetch marriage config — Intro uses it for the video thumbnail. + // Failure is non-blocking because IntroClient has a fallbackSrc. + await queryClient.prefetchQuery({ + queryKey: marriageQueryKeys.config(), + queryFn: () => fetchConfigSSR(), + }); return ( -
- {isReportSheetOpen && ( - setIsReportSheetOpen(false)} /> - )} - setIsReportSheetOpen(true), - }} - /> -
-
- {t["heavenly -

- {t["A Path to Heavenly Marriage"]} -

-

- { - t[ - 'We have come together with the goal of creating a secure and confidential path for "permanent marriage" among Muslims' - ] - } -

-
-
-
- {t["user -
-

120

-

- {t["user profiles"]} -

-
-
-
- {t["matches"]} -
-

14

-

- {t["matches"]} -

-
-
-
- {t["marriages"]} -
-

14

-

- {t["marriages"]} -

-
-
-
-
setIsPlayerOpen(true)} - > - -
- {t["play"]} -
- - setIsPlayerOpen(false)} - videoUrl={config?.intro_video_url} - /> -
-
- -
-
-
-
+ + + ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2e00657..a9de196 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -236,27 +236,33 @@ export default async function RootLayout({ root.dataset.webBootstrap = 'pending'; } - // 4. Deferred web_ready Announcement Bridge + // 4. Queued web_ready Delivery Protocol // - // 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; + // Pages call window.__announceHabibWebReady() when their + // destination UI is ready. This only sets a "readyRequested" + // flag and attempts delivery. If HabibApp is not yet injected, + // the request stays pending and is retried when HabibApp + // appears. The 3-second watchdog requests readiness as a + // safety net but does NOT cancel pending delivery attempts. + // + // Contract: + // readyRequested = a destination page says "my UI is ready" + // __habibWebReadySent = postMessage was actually executed + // + var readyRequested = false; + + function deliverReadyIfPossible() { + if (window.__habibWebReadySent) return true; + if (!readyRequested) return false; + if (!window.HabibApp || !window.HabibApp.postMessage) return false; + window.__habibWebReadySent = true; if (!configApplied) { root.dataset.webBootstrap = 'pending'; } window.HabibApp.postMessage(JSON.stringify({ action: 'web_ready' })); - return true; - } - window.__announceHabibWebReady = announce; - - function tryAnnounce() { - if (!announce()) return false; + // Safety: release bootstrap-pending if initial_config never arrives setTimeout(function() { if (root.dataset.webBootstrap === 'pending') { root.dataset.webBootstrap = 'ready'; @@ -265,28 +271,33 @@ export default async function RootLayout({ return true; } - // 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. + function requestWebReady() { + readyRequested = true; + deliverReadyIfPossible(); + } + + window.__announceHabibWebReady = requestWebReady; + + // Safety fallback: auto-request readiness after 3s if no page + // called __announceHabibWebReady(). This ensures the cover is + // never stuck forever, even for pages that forgot the call. var _habibAutoAnnounceTimer = setTimeout(function() { - tryAnnounce(); + requestWebReady(); }, 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). + // Poll for HabibApp if it wasn't available at parse time. + // When HabibApp appears, attempt delivery of any pending + // ready request. This closes the race where a page requests + // readiness before the bridge is injected. if (!window.HabibApp || !window.HabibApp.postMessage) { var attempts = 0; - var timer = setInterval(function() { + var bridgePollTimer = setInterval(function() { attempts += 1; - if ((window.HabibApp && window.HabibApp.postMessage) || attempts >= 40) { - clearInterval(timer); + if (window.HabibApp && window.HabibApp.postMessage) { + clearInterval(bridgePollTimer); + deliverReadyIfPossible(); + } else if (attempts >= 100) { + clearInterval(bridgePollTimer); } }, 50); } diff --git a/src/app/new-match/new-match-client.tsx b/src/app/new-match/new-match-client.tsx index 7a5c142..37064f8 100644 --- a/src/app/new-match/new-match-client.tsx +++ b/src/app/new-match/new-match-client.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; +import { useHabibWebReady } from "@/hooks/use-habib-web-ready"; import { FaLock } from "react-icons/fa6"; import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; import PageHeader from "@/components/Componentes/page-header"; @@ -280,11 +281,7 @@ export default function NewMatchClient() { }, [profile, locale, router]); // Signal Flutter to lift its loading cover once the profile is available. - useEffect(() => { - if (profile && !isLoading) { - window.__announceHabibWebReady?.(); - } - }, [profile, isLoading]); + useHabibWebReady(!!profile && !isLoading); const isRedirecting = useMemo(() => { if (!profile) return false; diff --git a/src/app/questions-list/questions-list-client.tsx b/src/app/questions-list/questions-list-client.tsx index 6967f59..39138ba 100644 --- a/src/app/questions-list/questions-list-client.tsx +++ b/src/app/questions-list/questions-list-client.tsx @@ -6,6 +6,7 @@ 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 { useHabibWebReady } from "@/hooks/use-habib-web-ready"; import DataErrorState from "@/components/Componentes/data-error-state"; import ErrorToast from "@/components/Componentes/error-toast"; import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; @@ -77,11 +78,7 @@ export default function QuestionsListClient() { // 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]); + useHabibWebReady(!!profile && !isProfileLoading); const startMatchMutation = useStartMarriageMatchMutation({ onSuccess: () => { diff --git a/src/app/request-accepted/request-accepted-client.tsx b/src/app/request-accepted/request-accepted-client.tsx index 801aa03..f9ca364 100644 --- a/src/app/request-accepted/request-accepted-client.tsx +++ b/src/app/request-accepted/request-accepted-client.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; +import { useHabibWebReady } from "@/hooks/use-habib-web-ready"; import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; import CallResultSheet from "@/components/Componentes/call-result-sheet"; import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet"; @@ -207,11 +208,7 @@ export default function RequestAcceptedClient() { }, [profile, router, locale, noContactReportedSuccess]); // Signal Flutter to lift its loading cover once the profile is available. - useEffect(() => { - if (profile && !isLoading) { - window.__announceHabibWebReady?.(); - } - }, [profile, isLoading]); + useHabibWebReady(!!profile && !isLoading); const isRedirecting = useMemo(() => { if (!profile) return false; diff --git a/src/app/request-sent/request-sent-client.tsx b/src/app/request-sent/request-sent-client.tsx index d72fed1..d368841 100644 --- a/src/app/request-sent/request-sent-client.tsx +++ b/src/app/request-sent/request-sent-client.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useMemo } from "react"; +import { useHabibWebReady } from "@/hooks/use-habib-web-ready"; import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; import PageHeader from "@/components/Componentes/page-header"; @@ -37,11 +38,7 @@ export default function RequestSentClient() { }, [profile, locale, router]); // Signal Flutter to lift its loading cover once the profile is available. - useEffect(() => { - if (profile && !isLoading) { - window.__announceHabibWebReady?.(); - } - }, [profile, isLoading]); + useHabibWebReady(!!profile && !isLoading); const isRedirecting = useMemo(() => { if (!profile) return false; diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx index c6fee7d..0f096ce 100644 --- a/src/app/terms/page.tsx +++ b/src/app/terms/page.tsx @@ -7,6 +7,7 @@ import { getSubmitPath, hasCompletedMarriageProfileBasics, } from "@/lib/get-submit-path"; +import { useHabibWebReady } from "@/hooks/use-habib-web-ready"; import SliderPage from "@/components/Componentes/slider-page"; import Button from "@/components/Componentes/button"; import { useI18n } from "@/translations/provider"; @@ -17,6 +18,10 @@ export default function TermsRoute() { const router = useRouter(); const { locale, dictionary: t } = useI18n(); + // Signal Flutter when profile data is available (or failed — the page + // has its own loading/error UI so the cover can safely be removed). + useHabibWebReady(!isLoading); + useEffect(() => { if (!isLoading && profile) { if ( diff --git a/src/components/Componentes/entry-route-resolver.tsx b/src/components/Componentes/entry-route-resolver.tsx index bda5667..acd97f1 100644 --- a/src/components/Componentes/entry-route-resolver.tsx +++ b/src/components/Componentes/entry-route-resolver.tsx @@ -19,6 +19,17 @@ type EntryRouteResolverProps = { anonymousEntryVisible?: boolean; }; +/** + * Client-side fallback entry route resolver. + * + * Used when SSR profile fetch failed and the server couldn't determine the + * correct destination. Resolves the route client-side and navigates. + * + * IMPORTANT: This component must NEVER call __announceHabibWebReady(). + * It renders null and navigates to a destination page — the destination + * page owns visual readiness. Announcing ready here would expose a blank + * frame to the user before the destination renders. + */ export default function EntryRouteResolver({ anonymousEntryVisible = false, }: EntryRouteResolverProps) { @@ -33,14 +44,7 @@ export default function EntryRouteResolver({ useEffect(() => { let isActive = true; - const announceReady = () => { - if (typeof window !== "undefined") { - window.__announceHabibWebReady?.(); - } - }; - const goToIntro = () => { - announceReady(); if (!anonymousEntryVisible) { router.replace(localizePath("/intro", locale)); } @@ -56,7 +60,6 @@ export default function EntryRouteResolver({ const cachedEntryPath = getCachedMarriageEntryPath(); if (cachedEntryPath) { - announceReady(); router.replace(localizePath(cachedEntryPath, locale)); return; } @@ -75,7 +78,6 @@ export default function EntryRouteResolver({ return; } - announceReady(); router.replace(localizePath(getSubmitPath(profile), locale)); } catch (error) { console.warn("Could not resolve entry route", error); diff --git a/src/components/Componentes/navigation-button.tsx b/src/components/Componentes/navigation-button.tsx index 21065a8..bd11d0a 100644 --- a/src/components/Componentes/navigation-button.tsx +++ b/src/components/Componentes/navigation-button.tsx @@ -69,7 +69,17 @@ export function NavigationButton({ const [isSubscriptionInfoOpen, setIsSubscriptionInfoOpen] = useState(false); const [isSubscriptionLoading, setIsSubscriptionLoading] = useState(false); const dropdownRef = useRef(null); - const { data: profile, refetch } = useMarriageProfileQuery(); + + // Only fetch profile for icons that actually need profile data. + // Simple icons like back, close, info, document don't need it. + const needsProfile = + icon === "support" || + icon === "subscription" || + icon === "more" || + icon === "consultation"; + const { data: profile, refetch } = useMarriageProfileQuery({ + enabled: needsProfile, + }); const paymentMutation = useHabcoinPaymentMutation(); const queryClient = useQueryClient(); const [toastMessage, setToastMessage] = useState(null); diff --git a/src/components/Componentes/page-header.tsx b/src/components/Componentes/page-header.tsx index 0585a59..6a6c4ce 100644 --- a/src/components/Componentes/page-header.tsx +++ b/src/components/Componentes/page-header.tsx @@ -14,22 +14,42 @@ type PageHeaderProps = { leftButton?: NavigationButtonProps; /** Props for the right button. Defaults to icon="support" with support label. */ rightButton?: NavigationButtonProps; + /** + * When false, skip the profile query entirely. Useful on pages where the + * right button has an explicit onClick and no icon resolution is needed + * (e.g. Intro's "support" icon that opens a report sheet). + * Defaults to true. + */ + enableProfileQuery?: boolean; }; export function PageHeader({ className, leftButton, rightButton, + enableProfileQuery = true, }: PageHeaderProps) { const { dictionary: t } = useI18n(); - const { data: profile } = useMarriageProfileQuery(); + // Only fetch profile when needed for icon resolution (subscription/support + // visibility depends on profile data). Pages that pass an explicit onClick + // for the right button can set enableProfileQuery=false to avoid an + // unnecessary API call (e.g. anonymous Intro). const iconFromProp = rightButton?.icon; + const needsProfile = + enableProfileQuery && + !rightButton?.onClick && + (iconFromProp === undefined || iconFromProp === "subscription"); + + const { data: profile } = useMarriageProfileQuery({ + enabled: needsProfile, + }); + const isDefaultOrSubscription = iconFromProp === undefined || iconFromProp === "subscription"; let finalIcon: NavigationButtonProps["icon"] = iconFromProp || "support"; - if (isDefaultOrSubscription) { + if (isDefaultOrSubscription && needsProfile) { if (hasSupportAccess(profile)) { finalIcon = "support"; } else if (profile?.gender === "male") { @@ -63,3 +83,4 @@ export function PageHeader({ } export default PageHeader; + diff --git a/src/hooks/marriage/query-keys.ts b/src/hooks/marriage/query-keys.ts index 144e1e0..4e7ea7c 100644 --- a/src/hooks/marriage/query-keys.ts +++ b/src/hooks/marriage/query-keys.ts @@ -2,6 +2,7 @@ import type { CaseId } from "./types"; export const marriageQueryKeys = { all: ["marriage"] as const, + config: () => [...marriageQueryKeys.all, "config"] as const, contactInfo: (caseId: CaseId | "") => [ ...marriageQueryKeys.all, diff --git a/src/hooks/marriage/use-marriage-config.ts b/src/hooks/marriage/use-marriage-config.ts index 4d03281..0e6c188 100644 --- a/src/hooks/marriage/use-marriage-config.ts +++ b/src/hooks/marriage/use-marriage-config.ts @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { http } from "@/lib/http"; +import { marriageQueryKeys } from "./query-keys"; export type MarriageConfig = { intro_video_url: string; @@ -16,7 +17,7 @@ export async function getMarriageConfig() { export function useMarriageConfigQuery() { return useQuery({ - queryKey: ["marriage", "config"], + queryKey: marriageQueryKeys.config(), queryFn: getMarriageConfig, }); } diff --git a/src/hooks/use-habib-web-ready.ts b/src/hooks/use-habib-web-ready.ts new file mode 100644 index 0000000..21f6ebf --- /dev/null +++ b/src/hooks/use-habib-web-ready.ts @@ -0,0 +1,25 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Shared destination readiness hook. + * + * Call this from final destination client components to signal that the page UI + * is ready and Flutter can remove its native loading cover. + * + * This hook only **requests** readiness — it does not directly interact with + * the Flutter bridge. The root bootstrap script in layout.tsx owns actual + * delivery and handles the case where HabibApp is injected late. + * + * @param ready - Whether the page considers itself visually ready. + * Pass `true` once critical data is available or the fallback + * UI is showing. The signal fires at most once per mount. + */ +export function useHabibWebReady(ready: boolean) { + useEffect(() => { + if (ready && typeof window !== "undefined") { + window.__announceHabibWebReady?.(); + } + }, [ready]); +} diff --git a/src/hooks/useFlutterBridge.ts b/src/hooks/useFlutterBridge.ts index bf613bd..a21480e 100644 --- a/src/hooks/useFlutterBridge.ts +++ b/src/hooks/useFlutterBridge.ts @@ -219,53 +219,34 @@ export function useFlutterBridge( }; }, [onEvent, enableLogging]); - // تشخیص آمادگی واقعی + ارسال خودکار WEB_READY - // در WebView واقعی Flutter، شیء window.HabibApp توسط addJavaScriptChannel - // تزریق می‌شود؛ وجودش یعنی پل برقرار است. منتظر INITIAL_CONFIG نمی‌مانیم، - // چون Flutter چنین ایونتی نمی‌فرستد. + // Detect HabibApp bridge availability and set isReady state. + // web_ready delivery is owned exclusively by the root bootstrap script + // in layout.tsx — this hook must NOT independently send web_ready. useEffect(() => { if (typeof window === "undefined") return; - let interval: ReturnType | undefined; - let timeout: ReturnType | undefined; - - const markReadyAndAnnounce = () => { - if (!window.HabibApp?.postMessage) return false; - + if (window.HabibApp?.postMessage) { setIsReady(true); + return; + } - // Single web_ready per page load: the root bootstrap script owns the - // flag (window.__habibWebReadySent). If it already announced, this - // hook must not send a second web_ready that would wake a duplicate - // initial_config round-trip. - if (!window.__habibWebReadySent) { - window.__habibWebReadySent = true; - sendToFlutter("WEB_READY", { - url: window.location.href, - userAgent: navigator.userAgent, - timestamp: Date.now(), - }); - addLog("✅ WEB_READY به‌صورت خودکار ارسال شد", "success"); - } - - return true; - }; + let interval: ReturnType | undefined; + let timeout: ReturnType | undefined; - // اگر کانال هنوز تزریق نشده، کمی صبر می‌کنیم (تزریق ممکن است با تأخیر باشد) - if (!markReadyAndAnnounce()) { - interval = setInterval(() => { - if (markReadyAndAnnounce() && interval) clearInterval(interval); - }, 100); - timeout = setTimeout(() => { + interval = setInterval(() => { + if (window.HabibApp?.postMessage) { + setIsReady(true); if (interval) clearInterval(interval); - }, 5000); - } + } + }, 100); + timeout = setTimeout(() => { + if (interval) clearInterval(interval); + }, 5000); return () => { if (interval) clearInterval(interval); if (timeout) clearTimeout(timeout); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // گوش‌دادن به پاسخ‌های واقعی Flutter از مسیر window.onFlutterResponse. diff --git a/src/lib/ssr-fetch.ts b/src/lib/ssr-fetch.ts index 473d7e9..8fe6da3 100644 --- a/src/lib/ssr-fetch.ts +++ b/src/lib/ssr-fetch.ts @@ -1,3 +1,5 @@ +// Server-only module: imported only by Server Components (page.tsx wrappers). + /** * Helper to get the API base URL for server-side fetches. * It reads from NEXT_PUBLIC_API_BASE_URL. @@ -48,3 +50,40 @@ export async function fetchProfileSSR( clearTimeout(timeoutId); } } + +/** + * Fetches the marriage config server-side. + * Returns null on failure so the client can fall back to its own fetch or + * local fallback images. + */ +export async function fetchConfigSSR( + timeoutMs = 1500, +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const baseUrl = getApiBaseUrl(); + const url = `${baseUrl}/api/marriage/config/`; + + const response = await fetch(url, { + method: "GET", + cache: "no-store", + signal: controller.signal, + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + return null; + } + + return await response.json(); + } catch (error) { + return null; + } finally { + clearTimeout(timeoutId); + } +} + diff --git a/src/types/window.d.ts b/src/types/window.d.ts index 20fdafa..631a738 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -87,7 +87,7 @@ declare global { sendToFlutter?: (action: string, data?: Record) => void; __HABIB_BOOTSTRAP__?: NonNullable; __habibWebReadySent?: boolean; - __announceHabibWebReady?: () => boolean; + __announceHabibWebReady?: () => void; } } From e5b503b48382d77445a001aa6822ffb0c66d7ebb Mon Sep 17 00:00:00 2001 From: mortezaei Date: Tue, 18 Aug 2026 01:03:35 +0330 Subject: [PATCH 09/35] fix back button --- src/app/layout.tsx | 15 ++++ src/app/providers.tsx | 2 + .../[slug]/question-detail-client.tsx | 19 ++++- .../questions-list/questions-list-client.tsx | 15 +++- .../Componentes/hardware-back-bridge.tsx | 28 +++++++ .../question-exit-navigation-button.tsx | 2 +- .../Componentes/question-section-flow.tsx | 2 +- .../Componentes/use-sheet-scroll-lock.ts | 15 +++- src/hooks/use-hardware-back-handler.ts | 84 +++++++++++++++++++ src/types/window.d.ts | 11 +++ 10 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 src/components/Componentes/hardware-back-bridge.tsx create mode 100644 src/hooks/use-hardware-back-handler.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a9de196..1c12781 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -301,6 +301,21 @@ export default async function RootLayout({ } }, 50); } + + // 5. Document Boot ID — reload detection instrumentation. + // If this ID changes across a back navigation, a hard reload + // or WebView recreation happened (not SPA navigation). + window.__habibDocumentBootId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + + // 6. Hardware Back contract stub. + // Flutter should call: window.__habibHandleHardwareBack() + // The real handler stack is set up by React (use-hardware-back-handler.ts). + // This stub ensures the function exists even before React hydrates. + if (!window.__habibHandleHardwareBack) { + window.__habibHandleHardwareBack = function() { + return Promise.resolve({ handled: false }); + }; + } })(); `, }} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 72e5bcb..7be85c1 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -6,6 +6,7 @@ import { } from "@tanstack/react-query"; import { type ReactNode, useState } from "react"; import FlutterLocaleSync from "@/components/Componentes/flutter-locale-sync"; +import HardwareBackBridge from "@/components/Componentes/hardware-back-bridge"; import SilentReloader from "@/components/Componentes/silent-reloader"; import { ViewPaddingsProvider } from "@/components/Componentes/view-paddings-provider"; @@ -44,6 +45,7 @@ export default function Providers({ children }: ProvidersProps) { + {children} diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index 264ffb4..f92e846 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -1,7 +1,8 @@ "use client"; import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import Button from "@/components/Componentes/button"; import DataErrorState from "@/components/Componentes/data-error-state"; import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; @@ -166,6 +167,16 @@ export default function QuestionDetailClient({ const [hasTestProgress, setHasTestProgress] = useState(false); const queryClient = useQueryClient(); + // Hardware back in the detail page = navigate back to questions list. + // QuestionAnswersProvider's pagehide/unmount safety net will flush + // any pending answers automatically when the component unmounts. + const handleHardwareBack = useCallback(async () => { + router.replace(questionsListHref); + return true; // handled — keep WebView open + }, [router, questionsListHref]); + + useHardwareBackHandler(handleHardwareBack); + useEffect(() => { if (typeof window !== "undefined") { const draftKey = `marriage:tests:${itemSlug}:draft`; @@ -360,7 +371,7 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} - onClick={() => router.push(questionsListHref)} + onClick={() => router.replace(questionsListHref)} />

{loadingTitle} @@ -412,7 +423,7 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} - onClick={() => router.push(questionsListHref)} + onClick={() => router.replace(questionsListHref)} />

{errorTitle} @@ -455,7 +466,7 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} - onClick={() => router.push(questionsListHref)} + onClick={() => router.replace(questionsListHref)} />

{errorTitle} diff --git a/src/app/questions-list/questions-list-client.tsx b/src/app/questions-list/questions-list-client.tsx index 39138ba..f92ca89 100644 --- a/src/app/questions-list/questions-list-client.tsx +++ b/src/app/questions-list/questions-list-client.tsx @@ -28,7 +28,7 @@ import { applyProfilePatchResultToCache, updateMarriageSectionData, } from "@/hooks/marriage/use-section-data"; -import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { getAssessmentLocalProgress } from "@/lib/assessment-progress"; import { getSubmitPath, @@ -47,7 +47,18 @@ import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch"; import SectionsRequest from "./sections-request"; export default function QuestionsListClient() { - useCloseServiceOnBack(); + // Hardware back on the root questions list = close the Flutter service. + // Unlike the old useCloseServiceOnBack, this does NOT push fake history + // entries. Flutter calls __habibHandleHardwareBack() and we return false + // (meaning "I didn't handle it — you should close"). + useHardwareBackHandler(() => { + if (typeof window !== "undefined" && (window as any).HabibApp?.postMessage) { + (window as any).HabibApp.postMessage( + JSON.stringify({ action: "close_service" }), + ); + } + return false; // Tell Flutter to close the WebView screen + }); const { dictionary: t, locale } = useI18n(); const router = useRouter(); const queryClient = useQueryClient(); diff --git a/src/components/Componentes/hardware-back-bridge.tsx b/src/components/Componentes/hardware-back-bridge.tsx new file mode 100644 index 0000000..5f71810 --- /dev/null +++ b/src/components/Componentes/hardware-back-bridge.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; +import { handleHardwareBack } from "@/hooks/use-hardware-back-handler"; + +/** + * Wires the React hardware-back handler stack to the global + * window.__habibHandleHardwareBack function. + * + * Mount this once in the Providers tree (after React hydration). + * It replaces the bootstrap stub with the real handler that walks + * the registered stack. + */ +export function HardwareBackBridge() { + useEffect(() => { + window.__habibHandleHardwareBack = handleHardwareBack; + + return () => { + // On unmount (shouldn't happen in practice), restore the stub. + window.__habibHandleHardwareBack = () => + Promise.resolve({ handled: false }); + }; + }, []); + + return null; +} + +export default HardwareBackBridge; diff --git a/src/components/Componentes/question-exit-navigation-button.tsx b/src/components/Componentes/question-exit-navigation-button.tsx index 084b462..09ba455 100644 --- a/src/components/Componentes/question-exit-navigation-button.tsx +++ b/src/components/Componentes/question-exit-navigation-button.tsx @@ -45,7 +45,7 @@ export function QuestionExitNavigationButton({ // ignore } finally { const target = localizePath(exitHref || "/questions-list", locale); - router.push(target); + router.replace(target); } }} /> diff --git a/src/components/Componentes/question-section-flow.tsx b/src/components/Componentes/question-section-flow.tsx index bb58def..a73e1a1 100644 --- a/src/components/Componentes/question-section-flow.tsx +++ b/src/components/Componentes/question-section-flow.tsx @@ -64,7 +64,7 @@ function SectionFlowContent({ // ignore } finally { const target = localizePath(exitHref || "/questions-list", locale); - router.push(target); + router.replace(target); } }, [exitHref, flushAnswers, locale, router, isSubmitting]); diff --git a/src/components/Componentes/use-sheet-scroll-lock.ts b/src/components/Componentes/use-sheet-scroll-lock.ts index d9b1204..72d4854 100644 --- a/src/components/Componentes/use-sheet-scroll-lock.ts +++ b/src/components/Componentes/use-sheet-scroll-lock.ts @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; let activeSheetCount = 0; let bodyHadDropdownClass = false; @@ -23,6 +24,18 @@ export function useSheetScrollLock( const onBackRef = useRef(onBack); onBackRef.current = onBack; + // Register in the hardware-back handler stack so that Flutter's + // __habibHandleHardwareBack() closes the sheet instead of navigating. + const handleHardwareBack = useCallback(() => { + if (onBackRef.current) { + onBackRef.current(); + return true; // handled — sheet closed + } + return false; + }, []); + + useHardwareBackHandler(handleHardwareBack, isOpen); + useEffect(() => { if (!isOpen) return; diff --git a/src/hooks/use-hardware-back-handler.ts b/src/hooks/use-hardware-back-handler.ts new file mode 100644 index 0000000..1b6f5f7 --- /dev/null +++ b/src/hooks/use-hardware-back-handler.ts @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/** + * Global stack of hardware-back handlers. + * + * When Flutter sends a hardware back event, the root handler in layout.tsx + * pops the last handler from this stack and calls it. If the handler returns + * true, it means "I handled it — keep the WebView open". If it returns false + * (or the stack is empty), Flutter should close the WebView screen. + * + * Pages register themselves with useHardwareBackHandler(). Sheets and modals + * also register — the last one wins, matching the visual stacking order. + */ +const backHandlerStack: Array<() => boolean | Promise> = []; + +/** + * Register a hardware-back handler. When the user presses the hardware back + * button (Android), Flutter calls window.__habibHandleHardwareBack(). The + * root handler walks the stack from top to bottom and calls the first handler. + * + * @param handler - Return true if you handled the back (e.g. closed a sheet, + * flushed answers and navigated). Return false if you didn't handle it + * (Flutter should close the WebView). + * @param enabled - When false, the handler is not registered. Useful for + * conditionally enabling back handling. + * + * @example + * // In questions-list (root): back = close service + * useHardwareBackHandler(() => false); + * + * // In question-detail: back = flush + navigate + * useHardwareBackHandler(async () => { + * await flushAnswers({ force: true }); + * router.replace(questionsListHref); + * return true; + * }); + */ +export function useHardwareBackHandler( + handler: () => boolean | Promise, + enabled = true, +) { + const handlerRef = useRef(handler); + handlerRef.current = handler; + + useEffect(() => { + if (!enabled) return; + + // Wrap in a stable closure so we can remove the exact reference. + const stableHandler = () => handlerRef.current(); + backHandlerStack.push(stableHandler); + + return () => { + const index = backHandlerStack.lastIndexOf(stableHandler); + if (index !== -1) { + backHandlerStack.splice(index, 1); + } + }; + }, [enabled]); +} + +/** + * Called by the root bootstrap script when Flutter sends a hardware back event. + * Returns { handled: true } if a web handler consumed the event, or + * { handled: false } if Flutter should close the WebView screen. + */ +export async function handleHardwareBack(): Promise<{ handled: boolean }> { + if (backHandlerStack.length === 0) { + return { handled: false }; + } + + // Pop the topmost handler (last registered = topmost in visual stack). + const handler = backHandlerStack[backHandlerStack.length - 1]; + try { + const result = await handler(); + return { handled: result }; + } catch (error) { + console.warn("[HardwareBack] Handler threw:", error); + return { handled: false }; + } +} + +export default useHardwareBackHandler; diff --git a/src/types/window.d.ts b/src/types/window.d.ts index 631a738..85af656 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -88,6 +88,17 @@ declare global { __HABIB_BOOTSTRAP__?: NonNullable; __habibWebReadySent?: boolean; __announceHabibWebReady?: () => void; + /** + * Called by Flutter when the user presses the hardware back button. + * Returns { handled: true } if web consumed the event (keep WebView open), + * or { handled: false } if Flutter should close the WebView screen. + */ + __habibHandleHardwareBack?: () => Promise<{ handled: boolean }>; + /** + * Unique ID per document load. If this changes on back navigation, + * it proves a hard reload / WebView recreation happened. + */ + __habibDocumentBootId?: string; } } From 28a907b3da563cbb0eb62360c0a3492d0d8362b6 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Tue, 18 Aug 2026 02:06:17 +0330 Subject: [PATCH 10/35] fix: sections --- src/app/providers.tsx | 11 +- src/app/questions-list/[slug]/loading.tsx | 21 ++ .../[slug]/question-detail-client.tsx | 69 +++-- .../questions-list/questions-list-client.tsx | 172 ++++------- .../Componentes/auth-data-boundary.tsx | 99 ++++++ .../Componentes/dev-tap-instrumentation.tsx | 57 ++++ .../Componentes/question-answer-storage.tsx | 217 ++++++++----- src/components/Componentes/question-card.tsx | 1 - .../Componentes/test-questions-flow.tsx | 18 +- src/hooks/use-current-profile-id.ts | 12 + src/lib/user-scoped-storage.ts | 285 ++++++++++++++++++ 11 files changed, 752 insertions(+), 210 deletions(-) create mode 100644 src/app/questions-list/[slug]/loading.tsx create mode 100644 src/components/Componentes/auth-data-boundary.tsx create mode 100644 src/components/Componentes/dev-tap-instrumentation.tsx create mode 100644 src/hooks/use-current-profile-id.ts create mode 100644 src/lib/user-scoped-storage.ts diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 7be85c1..689d5b0 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -5,6 +5,7 @@ import { QueryClientProvider, } from "@tanstack/react-query"; import { type ReactNode, useState } from "react"; +import AuthDataBoundary from "@/components/Componentes/auth-data-boundary"; import FlutterLocaleSync from "@/components/Componentes/flutter-locale-sync"; import HardwareBackBridge from "@/components/Componentes/hardware-back-bridge"; import SilentReloader from "@/components/Componentes/silent-reloader"; @@ -43,10 +44,12 @@ export default function Providers({ children }: ProvidersProps) { return ( - - - - {children} + + + + + {children} + ); diff --git a/src/app/questions-list/[slug]/loading.tsx b/src/app/questions-list/[slug]/loading.tsx new file mode 100644 index 0000000..f14b0ab --- /dev/null +++ b/src/app/questions-list/[slug]/loading.tsx @@ -0,0 +1,21 @@ +export default function Loading() { + return ( +
+ {/* Header placeholder */} +
+
+
+ {/* Center spinner */} +
+ +
+
+ ); +} diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index f92e846..c224bef 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -45,6 +45,12 @@ import { } from "@/lib/schema-adapter"; import { defaultLocale, type Locale } from "@/translations/config"; import { useI18n } from "@/translations/provider"; +import { useCurrentProfileId } from "@/hooks/use-current-profile-id"; +import { + getScopedAssessmentDraftKey, + readScopedAssessmentDraft, + removeScopedAssessmentDraft, +} from "@/lib/user-scoped-storage"; type QuestionDetailClientProps = { closeLabel: string; @@ -68,12 +74,14 @@ type StoredAnswers = { fields?: StoredQuestionField[]; }; -function getTestDraftStorageKey(slug: string) { - return `marriage:tests:${slug}:draft`; +function getTestDraftStorageKey(slug: string, profileId: number | null) { + if (!profileId) return null; + return getScopedAssessmentDraftKey(profileId, slug); } -function getQuestionStorageKey(slug: string) { - return `marriage:sections:${slug}:answers`; +function getQuestionStorageKey(slug: string, profileId: number | null) { + if (!profileId) return null; + return `marriage:user:${profileId}:sections:${slug}:completed`; } function QuestionFlowWrapper({ @@ -166,6 +174,7 @@ export default function QuestionDetailClient({ const [isTestStarted, setIsTestStarted] = useState(false); const [hasTestProgress, setHasTestProgress] = useState(false); const queryClient = useQueryClient(); + const profileId = useCurrentProfileId(); // Hardware back in the detail page = navigate back to questions list. // QuestionAnswersProvider's pagehide/unmount safety net will flush @@ -178,26 +187,20 @@ export default function QuestionDetailClient({ useHardwareBackHandler(handleHardwareBack); useEffect(() => { - if (typeof window !== "undefined") { - const draftKey = `marriage:tests:${itemSlug}:draft`; - const draftRaw = window.localStorage.getItem(draftKey); - if (draftRaw) { - try { - const parsed = JSON.parse(draftRaw); - if ( - parsed && - typeof parsed.answers === "object" && - parsed.answers !== null && - Object.keys(parsed.answers).length > 0 - ) { - setHasTestProgress(true); - return; - } - } catch {} + if (typeof window !== "undefined" && profileId) { + const draft = readScopedAssessmentDraft(profileId, itemSlug); + if ( + draft && + typeof draft.answers === "object" && + draft.answers !== null && + Object.keys(draft.answers).length > 0 + ) { + setHasTestProgress(true); + return; } setHasTestProgress(false); } - }, [itemSlug, isTestStarted]); + }, [itemSlug, isTestStarted, profileId]); const isCattellSlug = itemSlug === "personality_test"; const isGlasserSlug = itemSlug === "glasser_5_needs_test"; @@ -570,10 +573,13 @@ export default function QuestionDetailClient({ })); await submitCattellMutation.mutateAsync({ responses }); try { - window.localStorage.setItem( - getQuestionStorageKey(item.slug), - JSON.stringify({ completed: true }), - ); + const completionKey = getQuestionStorageKey(item.slug, profileId); + if (completionKey) { + window.localStorage.setItem( + completionKey, + JSON.stringify({ completed: true }), + ); + } } catch {} } else if (isGlasserSlug) { const responses = Object.entries(answers).map(([qNum, score]) => ({ @@ -582,10 +588,13 @@ export default function QuestionDetailClient({ })); await submitGlasserMutation.mutateAsync({ responses }); try { - window.localStorage.setItem( - getQuestionStorageKey(item.slug), - JSON.stringify({ completed: true }), - ); + const completionKey = getQuestionStorageKey(item.slug, profileId); + if (completionKey) { + window.localStorage.setItem( + completionKey, + JSON.stringify({ completed: true }), + ); + } } catch {} } }; @@ -598,7 +607,7 @@ export default function QuestionDetailClient({ informationLabel={informationLabel} onClose={() => setIsTestStarted(false)} onFinish={handleTestFinish} - draftStorageKey={getTestDraftStorageKey(item.slug)} + draftStorageKey={getTestDraftStorageKey(item.slug, profileId)} /> ); } diff --git a/src/app/questions-list/questions-list-client.tsx b/src/app/questions-list/questions-list-client.tsx index f92ca89..1253e88 100644 --- a/src/app/questions-list/questions-list-client.tsx +++ b/src/app/questions-list/questions-list-client.tsx @@ -30,6 +30,11 @@ import { } from "@/hooks/marriage/use-section-data"; import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { getAssessmentLocalProgress } from "@/lib/assessment-progress"; +import { + readScopedAssessmentDraft, + readScopedSectionDraft, + removeScopedSectionDraft, +} from "@/lib/user-scoped-storage"; import { getSubmitPath, hasCompletedMarriageProfileBasics, @@ -44,6 +49,7 @@ import { convertOverviewToFrontendItems } from "@/lib/schema-adapter"; import { localizePath } from "@/translations/config"; import { useI18n } from "@/translations/provider"; import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch"; +import DevTapInstrumentation from "@/components/Componentes/dev-tap-instrumentation"; import SectionsRequest from "./sections-request"; export default function QuestionsListClient() { @@ -115,10 +121,17 @@ export default function QuestionsListClient() { useEffect(() => { const next = new Map(); + const profileId = profile?.id; 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; + let draft: any = null; + if (profileId) { + draft = readScopedAssessmentDraft(profileId, slug); + } + if (!draft) { + const raw = window.localStorage.getItem(`marriage:tests:${slug}:draft`); + draft = raw ? JSON.parse(raw) : null; + } const progress = getAssessmentLocalProgress(draft, false); if (progress > 0) next.set(slug, progress); } catch { @@ -126,7 +139,7 @@ export default function QuestionsListClient() { } } setLocalAssessmentProgress(next); - }, [overview]); + }, [overview, profile?.id]); const sectionProgressBySlug = useMemo(() => { const progressBySlug = new Map(); @@ -237,23 +250,19 @@ export default function QuestionsListClient() { const syncPromiseRef = useRef | null>(null); const syncPendingAnswers = useCallback(async () => { - if (!overview) return; + if (!overview || !profile?.id || profile?.can_edit_profile === false) return; if (syncPromiseRef.current) { return syncPromiseRef.current; } + const profileId = profile.id; + const task = (async () => { const pendingSections: Array<{ - storageKey: string; - storedValue: { - current_step: number; - fields: MarriageField[]; - pending_keys: string[]; - pending_sync: boolean; - }; - fields: MarriageField[]; slug: string; + fields: MarriageField[]; }> = []; + for (const item of questionListItems) { if ( item.slug === "personality_test" || @@ -261,60 +270,36 @@ export default function QuestionsListClient() { ) { 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; + // 1. Read scoped draft for current profile + const scopedDraft = readScopedSectionDraft(profileId, item.slug); + if (scopedDraft && Object.keys(scopedDraft.pending).length > 0) { + const fields: MarriageField[] = Object.values(scopedDraft.pending).map( + (p) => + ({ + key: p.key, + label: p.label, + type: p.type, + value: p.value, + option_id: p.option_id ?? undefined, + private: p.private, + }) as MarriageField, + ); + if (fields.length > 0) { + pendingSections.push({ slug: item.slug, fields }); + } } - 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)); - } + + for (const section of pendingSections) { + const result = await updateMarriageSectionData(section.slug, { + current_step: 0, + fields: section.fields, + }); + applyProfilePatchResultToCache(queryClient, locale, result); + removeScopedSectionDraft(profileId, section.slug); } })(); @@ -325,12 +310,10 @@ export default function QuestionsListClient() { } finally { syncPromiseRef.current = null; } - }, [locale, overview, queryClient, questionListItems]); + }, [locale, overview, profile?.id, profile?.can_edit_profile, 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" @@ -342,36 +325,6 @@ export default function QuestionsListClient() { 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], ); @@ -400,18 +353,27 @@ export default function QuestionsListClient() { 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, - ); + const startPrefetch = () => { + if (cancelled) return; + void prefetchSectionsWithBoundedConcurrency( + profileSections, + (item) => + queryClient.fetchQuery({ + queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), + queryFn: () => getFormSection("profile", item.slug, locale), + staleTime: 30 * 1000, + }), + () => cancelled, + ); + }; + const idle = typeof requestIdleCallback === "function" + ? requestIdleCallback(startPrefetch) + : setTimeout(startPrefetch, 200); return () => { cancelled = true; + if (typeof cancelIdleCallback === "function" && typeof idle === "number") { + cancelIdleCallback(idle); + } }; }, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]); @@ -685,6 +647,7 @@ export default function QuestionsListClient() { /> ) : null} + {process.env.NODE_ENV === "development" ? : null}
setSelectedSection(section)} - onNearViewport={enqueueViewportPrefetch} onPrefetch={prefetchSection} /> ))} diff --git a/src/components/Componentes/auth-data-boundary.tsx b/src/components/Componentes/auth-data-boundary.tsx new file mode 100644 index 0000000..c6f8087 --- /dev/null +++ b/src/components/Componentes/auth-data-boundary.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, type ReactNode } from "react"; +import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; +import { HABIB_AUTH_TOKEN_CHANGED_EVENT } from "@/lib/auth-bridge"; +import { + isLegacyMigrationDone, + migrateLegacyStorageKeys, +} from "@/lib/user-scoped-storage"; + +/** + * Central auth-data boundary that resets user-specific React Query caches + * and localStorage drafts when the authenticated identity changes. + * + * Mount this inside (or near) the QueryClientProvider. + * + * On `habib:auth-token-changed`: + * 1. Cancels all in-flight user queries (profile, sections, assessments) + * 2. Removes previous-user data from the query cache + * 3. Keeps config/public queries intact + * + * This prevents Account A's cached data from being served to Account B + * after a token switch in the same WebView. + */ +export default function AuthDataBoundary({ + children, +}: { + children: ReactNode; +}) { + const queryClient = useQueryClient(); + const queryClientRef = useRef(queryClient); + queryClientRef.current = queryClient; + + // Run V3 legacy migration once on mount + useEffect(() => { + if (typeof window === "undefined") return; + if (!isLegacyMigrationDone()) { + migrateLegacyStorageKeys(); + } + }, []); + + useEffect(() => { + if (typeof window === "undefined") return; + + const handleAuthChange = () => { + const qc = queryClientRef.current; + + // 1. Cancel all in-flight user-specific queries + void qc.cancelQueries({ queryKey: marriageQueryKeys.profile() }); + void qc.cancelQueries({ queryKey: marriageQueryKeys.sections() }); + void qc.cancelQueries({ + queryKey: marriageQueryKeys.all, + predicate: (query) => { + const key = query.queryKey; + // Preserve config queries — they're user-independent + if ( + Array.isArray(key) && + key[0] === "marriage" && + key[1] === "config" + ) { + return false; + } + return true; + }, + }); + + // 2. Remove all user-owned query data from cache + // Remove profile + qc.removeQueries({ queryKey: marriageQueryKeys.profile() }); + + // Remove form overview + sections + qc.removeQueries({ + queryKey: marriageQueryKeys.all, + predicate: (query) => { + const key = query.queryKey; + if (!Array.isArray(key) || key[0] !== "marriage") return false; + + const kind = key[1]; + // Remove: profile, form-overview, form-section, sections, + // cattell, glasser, advisors, cases (contact-info) + // Keep: config + return kind !== "config"; + }, + }); + }; + + window.addEventListener(HABIB_AUTH_TOKEN_CHANGED_EVENT, handleAuthChange); + + return () => { + window.removeEventListener( + HABIB_AUTH_TOKEN_CHANGED_EVENT, + handleAuthChange, + ); + }; + }, []); + + return <>{children}; +} diff --git a/src/components/Componentes/dev-tap-instrumentation.tsx b/src/components/Componentes/dev-tap-instrumentation.tsx new file mode 100644 index 0000000..9423a5a --- /dev/null +++ b/src/components/Componentes/dev-tap-instrumentation.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Development-only capture-phase instrumentation for debugging section-card + * tap responsiveness. Records pointerdown → pointerup → click timing and + * whether the event reaches the DOM at all (vs being swallowed by a native + * overlay such as Flutter's loading cover). + * + * Mount this inside the questions-list page during development. Remove or + * gate behind process.env.NODE_ENV check for production. + */ +export default function DevTapInstrumentation() { + useEffect(() => { + if (process.env.NODE_ENV !== "development") return; + + const events = ["pointerdown", "pointerup", "pointercancel", "click"] as const; + const startTime = performance.now(); + let sequenceId = 0; + + const handler = (event: Event) => { + const e = event as PointerEvent; + const target = e.target as HTMLElement | null; + const anchor = target?.closest?.("a"); + const elapsed = (performance.now() - startTime).toFixed(1); + sequenceId += 1; + + console.debug( + `[tap-debug #${sequenceId}] %c${e.type}%c @ ${elapsed}ms`, + "color: #E03950; font-weight: bold", + "color: inherit", + { + pointerType: (e as PointerEvent).pointerType || "n/a", + target: target?.tagName, + targetId: target?.id, + closestAnchorHref: anchor?.getAttribute("href") || null, + defaultPrevented: e.defaultPrevented, + pathname: window.location.pathname, + timestamp: performance.now(), + }, + ); + }; + + for (const eventName of events) { + document.addEventListener(eventName, handler, { capture: true }); + } + + return () => { + for (const eventName of events) { + document.removeEventListener(eventName, handler, { capture: true }); + } + }; + }, []); + + return null; +} diff --git a/src/components/Componentes/question-answer-storage.tsx b/src/components/Componentes/question-answer-storage.tsx index 83d63de..98660b0 100644 --- a/src/components/Componentes/question-answer-storage.tsx +++ b/src/components/Componentes/question-answer-storage.tsx @@ -27,6 +27,13 @@ import { } from "@/hooks/marriage/use-section-data"; import { getApiRequestUrl } from "@/lib/http"; import type { QuestionField } from "@/lib/schema-adapter"; +import { + getScopedSectionDraftKey, + readScopedSectionDraft, + removeScopedSectionDraft, + writeScopedSectionDraft, + type ScopedPendingField, +} from "@/lib/user-scoped-storage"; const STORAGE_VERSION = 2; @@ -343,53 +350,105 @@ export function QuestionAnswersProvider({ storageKeyRef.current = storageKey; slugRef.current = slug; - const stored = readStoredAnswers(storageKey, slug); - let finalAnswers = stored.answers; - let finalPendingSync = stored.pendingSync; - dirtyKeysRef.current = new Set(stored.pendingKeys); + const profileId = profile?.id; + + // ── NEW RECONCILIATION (V3): Server is canonical ────────────── + // + // displayAnswers = canonicalServerAnswers + // THEN overlay ONLY local fields explicitly listed as pending/dirty + // for the CURRENT user. + // + // A single pending question must never make every historical local + // answer override server data. A successful server response containing + // no answers must render an empty section unless the current user has + // genuine unsynced pending fields. if (serverSectionData?.data) { + // Step 1: Server is canonical const serverAnswers = fieldsToAnswers(serverSectionData.data); - if (stored.pendingSync && canEdit) { - // Merge: local answers override server answers for unsynced changes - finalAnswers = { ...serverAnswers, ...stored.answers }; - } else { - // A section response can temporarily omit fields (most notably while - // the combined family section is being refreshed). Keep locally known - // fields that the response did not include, while letting explicit - // server values, including null/empty values, win for matching keys. - finalAnswers = { ...stored.answers, ...serverAnswers }; - finalPendingSync = false; + let finalAnswers: QuestionAnswersByKey = { ...serverAnswers }; + let finalPendingSync = false; + const nextDirtyKeys = new Set(); + + // Step 2: If we have a valid profile, read the user-scoped draft + if (profileId && canEdit) { + const scopedDraft = readScopedSectionDraft(profileId, slug); + if (scopedDraft && Object.keys(scopedDraft.pending).length > 0) { + // Overlay ONLY the pending dirty fields from the current user + for (const [key, pendingField] of Object.entries(scopedDraft.pending)) { + finalAnswers[key] = { + key: pendingField.key, + label: pendingField.label, + type: pendingField.type, + value: pendingField.value, + option_id: pendingField.option_id ?? undefined, + private: pendingField.private, + } as MarriageField; + nextDirtyKeys.add(key); + } + finalPendingSync = true; + } } - } - answersRef.current = finalAnswers; - hasPendingSyncRef.current = finalPendingSync; - - setAnswers((prev) => { - const prevKeys = Object.keys(prev); - const nextKeys = Object.keys(finalAnswers); - if (prevKeys.length === nextKeys.length) { - const isSame = prevKeys.every( - (k) => prev[k]?.value === finalAnswers[k]?.value, - ); - if (isSame) return prev; + // Also check the old V2 storage for backward compatibility during + // the transition period. Any old V2 pending edits are treated as + // a one-time overlay but NOT migrated (ownership can't be proven). + // After this render they won't be re-read because the V2 key is + // cleaned up by the legacy migration in AuthDataBoundary. + if (nextDirtyKeys.size === 0) { + const legacyStored = readStoredAnswers(storageKey, slug); + if (legacyStored.pendingSync && legacyStored.pendingKeys.length > 0 && canEdit) { + for (const pendingKey of legacyStored.pendingKeys) { + const pendingField = legacyStored.answers[pendingKey]; + if (pendingField) { + finalAnswers[pendingKey] = pendingField; + nextDirtyKeys.add(pendingKey); + } + } + if (nextDirtyKeys.size > 0) { + finalPendingSync = true; + // Migrate these to scoped storage if we have a profile + if (profileId) { + const pendingMap: Record = {}; + for (const key of nextDirtyKeys) { + const field = finalAnswers[key]; + if (field) { + pendingMap[key] = { + key: field.key, + label: field.label, + type: field.type, + value: field.value, + option_id: (field as any).option_id, + private: field.private, + }; + } + } + writeScopedSectionDraft(profileId, slug, pendingMap); + } + // Remove old V2 key + try { window.localStorage.removeItem(storageKey); } catch {} + } + } } - return finalAnswers; - }); - setHasPendingSync(finalPendingSync); - // Update localStorage to stay in sync - writeStoredAnswers( - getQuestionAnswersStorageKey(slug), - slug, - questions, - finalAnswers, - finalPendingSync, - serverSectionData?.data || undefined, - [...dirtyKeysRef.current], - ); - }, [slug, storageKey, serverSectionData, questions, canEdit]); + dirtyKeysRef.current = nextDirtyKeys; + answersRef.current = finalAnswers; + hasPendingSyncRef.current = finalPendingSync; + + setAnswers((prev) => { + const prevKeys = Object.keys(prev); + const nextKeys = Object.keys(finalAnswers); + if (prevKeys.length === nextKeys.length) { + const isSame = prevKeys.every( + (k) => prev[k]?.value === finalAnswers[k]?.value, + ); + if (isSame) return prev; + } + return finalAnswers; + }); + setHasPendingSync(finalPendingSync); + } + }, [slug, storageKey, serverSectionData, questions, canEdit, profile?.id]); const syncTimeoutRef = useRef(null); @@ -426,15 +485,26 @@ export function QuestionAnswersProvider({ hasPendingSyncRef.current = true; answersRevisionRef.current += 1; dirtyKeysRef.current.add(field.key); - writeStoredAnswers( - storageKeyRef.current, - slugRef.current, - questionsRef.current, - nextAnswers, - true, - undefined, - [...dirtyKeysRef.current], - ); + + // Write only dirty fields to profile-scoped localStorage + const currentProfileId = profile?.id; + if (currentProfileId) { + const pendingMap: Record = {}; + for (const dirtyKey of dirtyKeysRef.current) { + const dirtyField = nextAnswers[dirtyKey]; + if (dirtyField) { + pendingMap[dirtyKey] = { + key: dirtyField.key, + label: dirtyField.label, + type: dirtyField.type, + value: dirtyField.value, + option_id: (dirtyField as any).option_id, + private: dirtyField.private, + }; + } + } + writeScopedSectionDraft(currentProfileId, slugRef.current, pendingMap); + } if (syncTimeoutRef.current !== null) { clearTimeout(syncTimeoutRef.current); @@ -552,17 +622,13 @@ export function QuestionAnswersProvider({ hasPendingSyncRef.current = false; setHasPendingSync(false); - writeStoredAnswers( - storageKeyRef.current, - slugRef.current, - questionsRef.current, - answersRef.current, - false, - undefined, - [], - ); + // All dirty fields acknowledged — remove the draft entirely + const currentProfileId = profile?.id; + if (currentProfileId) { + removeScopedSectionDraft(currentProfileId, slugRef.current); + } }, - [mutateAsync, canEdit, locale, queryClient], + [mutateAsync, canEdit, locale, queryClient, profile?.id], ); const flushAnswersRef = useRef(flushAnswers); @@ -638,15 +704,30 @@ export function QuestionAnswersProvider({ const stillPending = dirtyKeysRef.current.size > 0; hasPendingSyncRef.current = stillPending; setHasPendingSync(stillPending); - writeStoredAnswers( - storageKeyRef.current, - slugRef.current, - questionsRef.current, - answersRef.current, - stillPending, - undefined, - [...dirtyKeysRef.current], - ); + + // Update scoped draft: remove acknowledged fields or delete draft + const currentProfileId = profile?.id; + if (currentProfileId) { + if (stillPending) { + const remainingPending: Record = {}; + for (const dirtyKey of dirtyKeysRef.current) { + const field = answersRef.current[dirtyKey]; + if (field) { + remainingPending[dirtyKey] = { + key: field.key, + label: field.label, + type: field.type, + value: field.value, + option_id: (field as any).option_id, + private: field.private, + }; + } + } + writeScopedSectionDraft(currentProfileId, slugRef.current, remainingPending); + } else { + removeScopedSectionDraft(currentProfileId, slugRef.current); + } + } void queryClient.invalidateQueries({ queryKey: marriageQueryKeys.profile(), diff --git a/src/components/Componentes/question-card.tsx b/src/components/Componentes/question-card.tsx index 029584d..5229a13 100644 --- a/src/components/Componentes/question-card.tsx +++ b/src/components/Componentes/question-card.tsx @@ -72,7 +72,6 @@ export function QuestionCard({ aria-label={t["Open {title}"].replace("{title}", item.title)} className="block rounded-[20px] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#F26C85]" onFocus={() => onPrefetch?.(item)} - onPointerDown={() => onPrefetch?.(item)} onPointerEnter={() => onPrefetch?.(item)} >
{ if (!draftStorageKey) return; try { + const match = draftStorageKey.match( + /^marriage:user:(\d+):tests:([^:]+):draft:v(\d+)$/, + ); + const ownerProfileId = match ? Number(match[1]) : undefined; + const version = match ? Number(match[3]) : undefined; + const slug = match ? match[2] : undefined; + window.localStorage.setItem( draftStorageKey, - JSON.stringify({ answers, currentIndex, totalQuestions }), + JSON.stringify({ + answers, + currentIndex, + totalQuestions, + ...(ownerProfileId !== undefined ? { ownerProfileId } : {}), + ...(version !== undefined ? { version } : {}), + ...(slug !== undefined ? { slug } : {}), + }), ); } catch {} - }, [answers, currentIndex, draftStorageKey]); + }, [answers, currentIndex, draftStorageKey, totalQuestions]); const handleOptionSelect = (value: string | number) => { if (!currentQuestion) return; diff --git a/src/hooks/use-current-profile-id.ts b/src/hooks/use-current-profile-id.ts new file mode 100644 index 0000000..105b6fd --- /dev/null +++ b/src/hooks/use-current-profile-id.ts @@ -0,0 +1,12 @@ +"use client"; + +import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; + +/** + * Returns the current authenticated user's profile ID. + * Returns null when profile hasn't loaded yet or user is unauthenticated. + */ +export function useCurrentProfileId(): number | null { + const { data: profile } = useMarriageProfileQuery(); + return profile?.id ?? null; +} diff --git a/src/lib/user-scoped-storage.ts b/src/lib/user-scoped-storage.ts new file mode 100644 index 0000000..1754649 --- /dev/null +++ b/src/lib/user-scoped-storage.ts @@ -0,0 +1,285 @@ +/** + * User-scoped localStorage utilities. + * + * All draft/answer data is keyed by the authenticated user's profile ID + * so that Account A's unsynced edits can never be displayed or synced + * for Account B. + * + * The raw auth token is NEVER used as a key component. + */ + +export const SCOPED_STORAGE_VERSION = 3; + +// ─── Types ─────────────────────────────────────────────────────────── + +export interface ScopedSectionDraft { + version: typeof SCOPED_STORAGE_VERSION; + ownerProfileId: number; + slug: string; + pending: Record; + updatedAt: string; +} + +export interface ScopedPendingField { + key: string; + label: string; + type: string; + value: unknown; + option_id?: string | string[] | null; + private?: boolean; +} + +export interface ScopedAssessmentDraft { + version: typeof SCOPED_STORAGE_VERSION; + ownerProfileId: number; + slug: string; + answers: Record; + currentIndex: number; + totalQuestions: number; +} + +// ─── Key Generators ────────────────────────────────────────────────── + +export function getScopedSectionDraftKey( + profileId: number, + slug: string, +): string { + return `marriage:user:${profileId}:sections:${slug}:draft:v${SCOPED_STORAGE_VERSION}`; +} + +export function getScopedAssessmentDraftKey( + profileId: number, + slug: string, +): string { + return `marriage:user:${profileId}:tests:${slug}:draft:v${SCOPED_STORAGE_VERSION}`; +} + +// ─── Ownership Verification ────────────────────────────────────────── + +export function isDraftOwnedBy( + draft: { ownerProfileId?: number } | null | undefined, + profileId: number, +): boolean { + if (!draft || typeof draft !== "object") return false; + return draft.ownerProfileId === profileId; +} + +// ─── Reading ───────────────────────────────────────────────────────── + +export function readScopedSectionDraft( + profileId: number, + slug: string, +): ScopedSectionDraft | null { + try { + const key = getScopedSectionDraftKey(profileId, slug); + const raw = window.localStorage.getItem(key); + if (!raw) return null; + + const parsed = JSON.parse(raw); + if ( + !parsed || + parsed.version !== SCOPED_STORAGE_VERSION || + parsed.ownerProfileId !== profileId || + parsed.slug !== slug + ) { + // Corrupted or mismatched — remove it + window.localStorage.removeItem(key); + return null; + } + + return parsed as ScopedSectionDraft; + } catch { + return null; + } +} + +export function readScopedAssessmentDraft( + profileId: number, + slug: string, +): ScopedAssessmentDraft | null { + try { + const key = getScopedAssessmentDraftKey(profileId, slug); + const raw = window.localStorage.getItem(key); + if (!raw) return null; + + const parsed = JSON.parse(raw); + if ( + !parsed || + parsed.version !== SCOPED_STORAGE_VERSION || + parsed.ownerProfileId !== profileId + ) { + window.localStorage.removeItem(key); + return null; + } + + return parsed as ScopedAssessmentDraft; + } catch { + return null; + } +} + +// ─── Writing ───────────────────────────────────────────────────────── + +export function writeScopedSectionDraft( + profileId: number, + slug: string, + pending: Record, +): void { + try { + const key = getScopedSectionDraftKey(profileId, slug); + + if (Object.keys(pending).length === 0) { + window.localStorage.removeItem(key); + return; + } + + const draft: ScopedSectionDraft = { + version: SCOPED_STORAGE_VERSION, + ownerProfileId: profileId, + slug, + pending, + updatedAt: new Date().toISOString(), + }; + + window.localStorage.setItem(key, JSON.stringify(draft)); + } catch { + // localStorage can fail in private mode or when storage quota is exhausted. + } +} + +export function writeScopedAssessmentDraft( + profileId: number, + slug: string, + answers: Record, + currentIndex: number, + totalQuestions: number, +): void { + try { + const key = getScopedAssessmentDraftKey(profileId, slug); + + if (Object.keys(answers).length === 0) { + window.localStorage.removeItem(key); + return; + } + + const draft: ScopedAssessmentDraft = { + version: SCOPED_STORAGE_VERSION, + ownerProfileId: profileId, + slug, + answers, + currentIndex, + totalQuestions, + }; + + window.localStorage.setItem(key, JSON.stringify(draft)); + } catch { + // localStorage can fail. + } +} + +export function removeScopedSectionDraft( + profileId: number, + slug: string, +): void { + try { + window.localStorage.removeItem( + getScopedSectionDraftKey(profileId, slug), + ); + } catch {} +} + +export function removeScopedAssessmentDraft( + profileId: number, + slug: string, +): void { + try { + window.localStorage.removeItem( + getScopedAssessmentDraftKey(profileId, slug), + ); + } catch {} +} + +// ─── Legacy Migration ──────────────────────────────────────────────── + +/** + * Remove old V2 unscoped answer keys. Since they have no owner metadata, + * ownership cannot be proven, so we do NOT migrate them. + * We also do NOT call localStorage.clear() because the application stores + * unrelated device/UI flags. + */ +export function migrateLegacyStorageKeys(): void { + try { + const keysToRemove: string[] = []; + + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (!key) continue; + + // Old V2 normal section keys: marriage:sections:{slug}:answers:v2 + if (key.match(/^marriage:sections:[^:]+:answers:v2$/)) { + keysToRemove.push(key); + } + + // Old unscoped assessment draft keys: marriage:tests:{slug}:draft + if (key.match(/^marriage:tests:[^:]+:draft$/) && !key.includes(":user:")) { + keysToRemove.push(key); + } + + // Old unscoped completion markers: marriage:sections:{slug}:answers + // (used by assessments to store {completed: true}) + if (key.match(/^marriage:sections:[^:]+:answers$/) && !key.includes(":user:")) { + keysToRemove.push(key); + } + } + + for (const key of keysToRemove) { + window.localStorage.removeItem(key); + } + + // Mark migration as done so we don't re-scan every page load + window.localStorage.setItem("marriage:storage-migration:v3", "done"); + } catch { + // Non-critical; will retry next page load. + } +} + +/** + * Check if the V3 migration has already been performed. + */ +export function isLegacyMigrationDone(): boolean { + try { + return window.localStorage.getItem("marriage:storage-migration:v3") === "done"; + } catch { + return false; + } +} + +/** + * Enumerate all scoped section draft keys for a given profile. + */ +export function getAllScopedSectionDraftKeys( + profileId: number, +): string[] { + const prefix = `marriage:user:${profileId}:sections:`; + const suffix = `:draft:v${SCOPED_STORAGE_VERSION}`; + const keys: string[] = []; + + try { + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (key && key.startsWith(prefix) && key.endsWith(suffix)) { + keys.push(key); + } + } + } catch {} + + return keys; +} + +/** + * Extract the slug from a scoped section draft key. + */ +export function extractSlugFromScopedKey(key: string): string | null { + const match = key.match(/^marriage:user:\d+:sections:([^:]+):draft:v\d+$/); + return match ? match[1] : null; +} From db2ee3ec6cd3e19957143b13f825412794b6709f Mon Sep 17 00:00:00 2001 From: mortezaei Date: Tue, 18 Aug 2026 02:09:33 +0330 Subject: [PATCH 11/35] fix question storage --- .../Componentes/question-answer-storage.tsx | 12 +- .../Componentes/slider-page.test.tsx | 1 + .../Componentes/test-questions-flow.tsx | 2 +- src/lib/user-scoped-storage.test.ts | 163 ++++++++++++++++++ 4 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 src/lib/user-scoped-storage.test.ts diff --git a/src/components/Componentes/question-answer-storage.tsx b/src/components/Componentes/question-answer-storage.tsx index 98660b0..3dd58f6 100644 --- a/src/components/Componentes/question-answer-storage.tsx +++ b/src/components/Componentes/question-answer-storage.tsx @@ -553,14 +553,12 @@ export function QuestionAnswersProvider({ questionsRef.current, backendFieldsRef.current, ); - const nextDirtyKey = fullPayload.fields.find((field) => + const pendingFields = fullPayload.fields.filter((field) => dirtyKeysRef.current.has(field.key), - )?.key; + ); const payload = { ...fullPayload, - fields: nextDirtyKey - ? fullPayload.fields.filter((field) => field.key === nextDirtyKey) - : [], + fields: pendingFields, }; const revision = answersRevisionRef.current; @@ -613,7 +611,9 @@ export function QuestionAnswersProvider({ return; } - if (nextDirtyKey) dirtyKeysRef.current.delete(nextDirtyKey); + payload.fields.forEach((field) => { + dirtyKeysRef.current.delete(field.key); + }); if (dirtyKeysRef.current.size > 0) { await flushAnswersRef.current(); diff --git a/src/components/Componentes/slider-page.test.tsx b/src/components/Componentes/slider-page.test.tsx index 69f9242..b0f678c 100644 --- a/src/components/Componentes/slider-page.test.tsx +++ b/src/components/Componentes/slider-page.test.tsx @@ -25,6 +25,7 @@ vi.mock('@/hooks/marriage/use-profile-main', () => ({ vi.mock('@/hooks/marriage/query-keys', () => ({ marriageQueryKeys: { profile: () => ['marriage', 'profile'], + config: () => ['marriage', 'config'], }, })); diff --git a/src/components/Componentes/test-questions-flow.tsx b/src/components/Componentes/test-questions-flow.tsx index 0a933ac..d7e674a 100644 --- a/src/components/Componentes/test-questions-flow.tsx +++ b/src/components/Componentes/test-questions-flow.tsx @@ -35,7 +35,7 @@ type TestQuestionsFlowProps = { stepsLabel?: string; onFinish?: (answers: Record) => void; onClose?: () => void; - draftStorageKey?: string; + draftStorageKey?: string | null; }; type StoredTestDraft = { answers?: Record; diff --git a/src/lib/user-scoped-storage.test.ts b/src/lib/user-scoped-storage.test.ts new file mode 100644 index 0000000..e90004b --- /dev/null +++ b/src/lib/user-scoped-storage.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + SCOPED_STORAGE_VERSION, + getScopedSectionDraftKey, + getScopedAssessmentDraftKey, + readScopedSectionDraft, + readScopedAssessmentDraft, + writeScopedSectionDraft, + writeScopedAssessmentDraft, + removeScopedSectionDraft, + removeScopedAssessmentDraft, + migrateLegacyStorageKeys, + isLegacyMigrationDone, + isDraftOwnedBy, + getAllScopedSectionDraftKeys, + extractSlugFromScopedKey, +} from "./user-scoped-storage"; + +describe("user-scoped-storage", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + describe("Key generators", () => { + it("generates profile-scoped section draft keys with version", () => { + const key = getScopedSectionDraftKey(42, "personal_info"); + expect(key).toBe(`marriage:user:42:sections:personal_info:draft:v${SCOPED_STORAGE_VERSION}`); + }); + + it("generates profile-scoped assessment draft keys with version", () => { + const key = getScopedAssessmentDraftKey(42, "personality_test"); + expect(key).toBe(`marriage:user:42:tests:personality_test:draft:v${SCOPED_STORAGE_VERSION}`); + }); + + it("extracts slug correctly from scoped key", () => { + const key = getScopedSectionDraftKey(101, "family_background"); + expect(extractSlugFromScopedKey(key)).toBe("family_background"); + }); + }); + + describe("Section draft isolation across accounts", () => { + it("isolates Account A edits from Account B", () => { + const accountAProfileId = 1001; + const accountBProfileId = 2002; + + // Account A writes a draft + writeScopedSectionDraft(accountAProfileId, "job_info", { + job_title: { + key: "job_title", + label: "Job Title", + type: "text", + value: "Engineer", + }, + }); + + // Account A can read it + const draftA = readScopedSectionDraft(accountAProfileId, "job_info"); + expect(draftA).not.toBeNull(); + expect(draftA?.pending.job_title.value).toBe("Engineer"); + expect(draftA?.ownerProfileId).toBe(accountAProfileId); + + // Account B CANNOT read Account A's draft + const draftB = readScopedSectionDraft(accountBProfileId, "job_info"); + expect(draftB).toBeNull(); + }); + + it("removes draft when pending is empty", () => { + const profileId = 123; + writeScopedSectionDraft(profileId, "bio", { + about: { key: "about", label: "About", type: "text", value: "Hello" }, + }); + expect(readScopedSectionDraft(profileId, "bio")).not.toBeNull(); + + // Write empty pending map -> draft is removed + writeScopedSectionDraft(profileId, "bio", {}); + expect(readScopedSectionDraft(profileId, "bio")).toBeNull(); + }); + + it("removes draft explicitly via removeScopedSectionDraft", () => { + const profileId = 123; + writeScopedSectionDraft(profileId, "bio", { + about: { key: "about", label: "About", type: "text", value: "Hello" }, + }); + removeScopedSectionDraft(profileId, "bio"); + expect(readScopedSectionDraft(profileId, "bio")).toBeNull(); + }); + + it("lists all scoped draft keys for a given profile", () => { + writeScopedSectionDraft(1, "sec_1", { q: { key: "q", label: "Q", type: "text", value: "A" } }); + writeScopedSectionDraft(1, "sec_2", { q: { key: "q", label: "Q", type: "text", value: "B" } }); + writeScopedSectionDraft(2, "sec_3", { q: { key: "q", label: "Q", type: "text", value: "C" } }); + + const keys1 = getAllScopedSectionDraftKeys(1); + expect(keys1).toHaveLength(2); + expect(keys1.map(extractSlugFromScopedKey)).toEqual(expect.arrayContaining(["sec_1", "sec_2"])); + + const keys2 = getAllScopedSectionDraftKeys(2); + expect(keys2).toHaveLength(1); + expect(extractSlugFromScopedKey(keys2[0])).toBe("sec_3"); + }); + }); + + describe("Assessment draft isolation across accounts", () => { + it("isolates assessment answers across accounts", () => { + const userA = 501; + const userB = 502; + + writeScopedAssessmentDraft(userA, "personality_test", { 1: "A", 2: "B" }, 2, 10); + + const draftA = readScopedAssessmentDraft(userA, "personality_test"); + expect(draftA).not.toBeNull(); + expect(draftA?.answers).toEqual({ 1: "A", 2: "B" }); + expect(draftA?.currentIndex).toBe(2); + expect(draftA?.totalQuestions).toBe(10); + + const draftB = readScopedAssessmentDraft(userB, "personality_test"); + expect(draftB).toBeNull(); + }); + + it("removes assessment draft explicitly", () => { + writeScopedAssessmentDraft(100, "glasser_5_needs_test", { 1: 5 }, 1, 20); + removeScopedAssessmentDraft(100, "glasser_5_needs_test"); + expect(readScopedAssessmentDraft(100, "glasser_5_needs_test")).toBeNull(); + }); + }); + + describe("Legacy migration", () => { + it("removes old unscoped keys while preserving unrelated localStorage", () => { + // Setup old keys + window.localStorage.setItem("marriage:sections:about:answers:v2", JSON.stringify({ fields: [] })); + window.localStorage.setItem("marriage:tests:personality_test:draft", JSON.stringify({ answers: { 1: "A" } })); + window.localStorage.setItem("marriage:sections:glasser_5_needs_test:answers", JSON.stringify({ completed: true })); + + // Setup unrelated keys that MUST NOT be touched + window.localStorage.setItem("marriage:device_id", "device-12345"); + window.localStorage.setItem("theme_preference", "dark"); + + expect(isLegacyMigrationDone()).toBe(false); + + migrateLegacyStorageKeys(); + + expect(isLegacyMigrationDone()).toBe(true); + + // Old keys should be gone + expect(window.localStorage.getItem("marriage:sections:about:answers:v2")).toBeNull(); + expect(window.localStorage.getItem("marriage:tests:personality_test:draft")).toBeNull(); + expect(window.localStorage.getItem("marriage:sections:glasser_5_needs_test:answers")).toBeNull(); + + // Unrelated keys must be preserved + expect(window.localStorage.getItem("marriage:device_id")).toBe("device-12345"); + expect(window.localStorage.getItem("theme_preference")).toBe("dark"); + }); + }); + + describe("Ownership verification helper", () => { + it("verifies draft ownership correctly", () => { + expect(isDraftOwnedBy({ ownerProfileId: 99 }, 99)).toBe(true); + expect(isDraftOwnedBy({ ownerProfileId: 99 }, 100)).toBe(false); + expect(isDraftOwnedBy(null, 99)).toBe(false); + expect(isDraftOwnedBy(undefined, 99)).toBe(false); + }); + }); +}); From 78862ef7135e0abbcdfec7393209f95e5ab49bd4 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Tue, 18 Aug 2026 02:11:51 +0330 Subject: [PATCH 12/35] refactor(storage): improve question answer synchronization and isolation Refactor the question answer storage mechanism to support multiple pending fields simultaneously and improve data isolation. - Update `QuestionAnswersProvider` to process all dirty fields in a single payload instead of just the first one found. - Ensure all processed dirty keys are cleared from the tracking set. - Update `TestQuestionsFlow` props to allow null `draftStorageKey`. - Add comprehensive unit tests for `user-scoped-storage` to ensure account isolation and correct key generation. - Update `slider-page` mocks to include marriage config query keys. --- .../Componentes/auth-data-boundary.test.tsx | 77 +++++++++++++++++++ .../Componentes/test-questions-flow.tsx | 2 +- 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/components/Componentes/auth-data-boundary.test.tsx diff --git a/src/components/Componentes/auth-data-boundary.test.tsx b/src/components/Componentes/auth-data-boundary.test.tsx new file mode 100644 index 0000000..fda280f --- /dev/null +++ b/src/components/Componentes/auth-data-boundary.test.tsx @@ -0,0 +1,77 @@ +import { render, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import AuthDataBoundary from "./auth-data-boundary"; +import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; +import { HABIB_AUTH_TOKEN_CHANGED_EVENT } from "@/lib/auth-bridge"; + +describe("AuthDataBoundary", () => { + let queryClient: QueryClient; + + beforeEach(() => { + window.localStorage.clear(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + }); + + it("purges user-specific caches on habib:auth-token-changed while preserving config", async () => { + // Populate query cache with user data + config + queryClient.setQueryData(marriageQueryKeys.profile(), { id: 100, can_edit_profile: true }); + queryClient.setQueryData(marriageQueryKeys.formOverview("profile", "en"), { form_id: "profile", sections: [] }); + queryClient.setQueryData(marriageQueryKeys.formSection("profile", "job", "en"), { form_id: "profile", section: { id: "job" } }); + queryClient.setQueryData(marriageQueryKeys.cattellQuestions("en"), { questions: [] }); + queryClient.setQueryData(marriageQueryKeys.glasserQuestions("en"), { questions: [] }); + queryClient.setQueryData(marriageQueryKeys.config(), { min_age: 18 }); + + render( + + +
Child Content
+
+
, + ); + + // Verify initial cached data is present + expect(queryClient.getQueryData(marriageQueryKeys.profile())).toBeDefined(); + expect(queryClient.getQueryData(marriageQueryKeys.formOverview("profile", "en"))).toBeDefined(); + expect(queryClient.getQueryData(marriageQueryKeys.formSection("profile", "job", "en"))).toBeDefined(); + expect(queryClient.getQueryData(marriageQueryKeys.cattellQuestions("en"))).toBeDefined(); + expect(queryClient.getQueryData(marriageQueryKeys.config())).toBeDefined(); + + // Fire auth identity change (e.g. Account A logs out or switches to Account B) + window.dispatchEvent(new CustomEvent(HABIB_AUTH_TOKEN_CHANGED_EVENT, { detail: { token: "new-token" } })); + + // All user-owned queries should be removed from the cache + await waitFor(() => { + expect(queryClient.getQueryData(marriageQueryKeys.profile())).toBeUndefined(); + }); + expect(queryClient.getQueryData(marriageQueryKeys.formOverview("profile", "en"))).toBeUndefined(); + expect(queryClient.getQueryData(marriageQueryKeys.formSection("profile", "job", "en"))).toBeUndefined(); + expect(queryClient.getQueryData(marriageQueryKeys.cattellQuestions("en"))).toBeUndefined(); + expect(queryClient.getQueryData(marriageQueryKeys.glasserQuestions("en"))).toBeUndefined(); + + // Config query MUST be preserved (public/global) + expect(queryClient.getQueryData(marriageQueryKeys.config())).toEqual({ min_age: 18 }); + }); + + it("runs legacy V3 migration once on mount", async () => { + window.localStorage.setItem("marriage:sections:old_slug:answers:v2", JSON.stringify({ pending_sync: true })); + expect(window.localStorage.getItem("marriage:storage-migration:v3")).toBeNull(); + + render( + + +
Mounted
+
+
, + ); + + await waitFor(() => { + expect(window.localStorage.getItem("marriage:storage-migration:v3")).toBe("done"); + }); + expect(window.localStorage.getItem("marriage:sections:old_slug:answers:v2")).toBeNull(); + }); +}); diff --git a/src/components/Componentes/test-questions-flow.tsx b/src/components/Componentes/test-questions-flow.tsx index d7e674a..9cbd01d 100644 --- a/src/components/Componentes/test-questions-flow.tsx +++ b/src/components/Componentes/test-questions-flow.tsx @@ -44,7 +44,7 @@ type StoredTestDraft = { }; function getStoredDraft( - storageKey: string | undefined, + storageKey: string | null | undefined, totalQuestions: number, ) { if (!storageKey || typeof window === "undefined") From 026de60748d6b26ad1bf636f5f79464a36ffa655 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Tue, 18 Aug 2026 12:26:59 +0330 Subject: [PATCH 13/35] feat: implement SectionOverlayHost for animating question details and expose onExit callbacks for modular navigation handling --- ...ss_02d1b36a-d03c-400c-8d4c-a423d16e661f.md | 87 +++++++++ .../[lang]/questions-list/[slug]/loading.tsx | 1 + src/app/globals.css | 66 ++++++- .../[slug]/question-detail-client.tsx | 31 +++- .../questions-list/questions-list-client.tsx | 170 +++++++++++++++++- .../section-prefetch-race.test.ts | 87 +++++++++ src/components/Componentes/question-card.tsx | 10 ++ .../question-exit-navigation-button.tsx | 10 +- .../Componentes/question-section-flow.tsx | 15 +- .../Componentes/section-overlay-host.test.tsx | 116 ++++++++++++ .../Componentes/section-overlay-host.tsx | 148 +++++++++++++++ 11 files changed, 720 insertions(+), 21 deletions(-) create mode 100644 .zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md create mode 100644 src/app/[lang]/questions-list/[slug]/loading.tsx create mode 100644 src/app/questions-list/section-prefetch-race.test.ts create mode 100644 src/components/Componentes/section-overlay-host.test.tsx create mode 100644 src/components/Componentes/section-overlay-host.tsx diff --git a/.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md b/.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md new file mode 100644 index 0000000..d34e5f4 --- /dev/null +++ b/.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md @@ -0,0 +1,87 @@ +# انیمیشن اسلاید صفحه جزئیات سکشن روی لیست سکشن‌ها (مطابق حسینیه‌اپ) + +## خلاصه تحقیق + +**حسینیه‌اپ** (مرجع): پنل مداح URL-driven است (`?panel=provider:id`) و صفحه لیست هرگز unmount نمی‌شود؛ پنل با CSS خالص (بدون کتابخانه) با `translateX(±100%) → 0` و زمان‌بندی **0.28s / cubic-bezier(0.32, 0.72, 0, 1)** اسلاید می‌شود. جهت با `side` فیزیکی انتخاب می‌شود: اپ RTL → پنل از **چپ** می‌آید (قرینه سایدبار راست). الگوی `PanelSlot` محتوای پنل را تا پایان انیمیشن خروج mount نگه می‌دارد. + +**اپ مریج**: Next.js 16.2.10 App Router، بدون هیچ کتابخانه انیمیشن. تمام مسیرهای بستن صفحه جزئیات از نوع `router.replace(questionsListHref)` هستند (۳ دکمه هدر، دکمه خروج با flush، هاردور بک) و دکمه close حالت تست به‌صورت پیش‌فرض `router.back()` می‌زند — یعنی طراحی باید مثل حسینیه «URL-driven با retention» باشد تا همه مسیرها خودکار انیمیت شوند، بدون دستکاری تک‌تک call-siteها. + +**معماری انتخابی** (طبق انتخاب شما): Parallel Route `@modal` + Intercepting Route `(.)[slug]` + هاست retention — الگوی رسمی modal مستندات همین نسخه Next (`node_modules/next/dist/docs/.../parallel-routes.md` و `intercepting-routes.md`، هر دو verify شد). + +## ساختار فایل‌ها + +### ۱. فایل‌های جدید (تماماً additive) + +``` +src/app/[lang]/questions-list/ +├── layout.tsx ← رندر {children} + {modal} (server) +└── @modal/ + ├── layout.tsx ← wrapper نازک → SectionOverlayHost + ├── default.tsx ← return null (الگوی رسمی؛ جلوگیری از 404 در hard-load) + └── (.)[slug]/ + └── page.tsx ← export { default } from "@/app/questions-list/[slug]/page" +``` + +``` +src/components/Componentes/section-overlay-host.tsx ← "use client" — قلب مکانیزم +``` + +### ۲. `SectionOverlayHost` (ترجمه‌ی PanelSlot حسینیه به Next App Router) + +- در `@modal/layout.ts` رندر می‌شود؛ چون layout اسلات در ناوبری‌های soft زنده می‌ماند، state آن پایدار است. +- **State machine**: `phase: 'enter' | 'open' | 'closing' | 'closed'` + - **باز شدن**: فرزند اسلات (صفحه intercept شده) mount می‌شود → `SectionOverlayContext` که host ارائه می‌دهد با `markActive()` از داخل `QuestionDetailClient` صدا زده می‌شود → host پنل را با کلاس off-screen رندر کرده و با `requestAnimationFrame` کلاس `open` اضافه می‌کند → CSS transition اسلاید ورود. + - **بسته شدن (هر مسیری: replace / back / هاردور)**: children اسلات به `default` (null) تغییر می‌کند → host عنصر قبلی را در state نگه می‌دارد (retention) و کلاس `closing` می‌دهد → ۲۹۰ms بعد unmount واقعی. دقیقاً همان `activeDescriptor`/`mounted` در PanelSlot. +- **RTL**: از `useI18n().locale` + `localeDirections` → `data-dir` روی پنل. **LTR از راست، RTL از چپ** (قرینه حسینیه که در RTL از چپ می‌آید). +- **قفل اسکرول**: کلاس `section-overlay-open` روی `body` (قرینه الگوی موجود `body.dropdown-open .app-shell` در globals.css خط ۲۳۲). +- **سایزینگ**: `position: fixed; inset-inline: 0; top/bottom: 0; margin-inline: auto` + `w-full sm:w-[375px]` + `padding-inline: 17px` + `padding-bottom: var(--safe-bottom)` — دقیقاً قرینه `.app-shell` (body فلکس و وسط‌چین است، globals.css خط ۱۵۴) تا `main` با `-mx-[17px]` داخل پنل مثل قبل رفتار کند. + +### ۳. تغییر در `question-detail-client.tsx` (حداقلی، ~۶ خط) + +```tsx +const overlay = useSectionOverlay(); // خارج از overlay → undefined → no-op +useEffect(() => overlay?.markActive(), [overlay]); +``` +تست‌های موجود (`question-detail-client.test.tsx`) مستقیم رندر می‌کنند و context ندارند → بدون تغییر رفتار. + +### ۴. CSS در `globals.css` + +```css +.section-overlay { + transform: translateX(100%); /* LTR: ورود از راست */ + transition: transform 280ms cubic-bezier(0.32, 0.72, 0, 1); + will-change: transform; +} +[dir="rtl"] .section-overlay { transform: translateX(-100%); } /* RTL: ورود از چپ */ +.section-overlay[data-open="true"], .section-overlay[data-open="closing"] { transform: translateX(0); } +/* closing = همان حالت 0 که با برداشتن data-open="true" به سمت ابتدایی برمی‌گردد */ +body.section-overlay-open .app-shell { overflow-y: hidden; } +@media (prefers-reduced-motion: reduce) { .section-overlay { transition: none; } } +``` ++ سایه لبه داخلی پنل مثل `shadow-[-18px_0_50px_rgba(0,0,0,.45)]` حسینیه (جهت سایه هم با RTL برعکس). + +منحنی و مدت زمان عیناً از `anim-sheet-left` حسینیه (`0.28s cubic-bezier(0.32, 0.72, 0, 1)`). + +## رفتار نهایی + +- کلیک روی سکشن در `/en/questions-list` → URL به `/en/questions-list/personal_identity` تغییر می‌کند، لیست زیر پنل می‌ماند (اسکرول حفظ می‌شود)، پنل از **راست** (در `/fa` از **چپ**) با همان انیمیشن حسینیه اسلاید می‌شود. +- هر مسیر بستن (دکمه close با flush پاسخ‌ها، back مرورگر، هاردور بک اندروید، اتمام سابمیت) → پنل با همان انیمیشن به همان سمت جمع می‌شود و لیست از زیر پیدا می‌شود. +- بارگذاری مستقیم/refresh روی URL جزئیات → صفحه کامل فعلی بدون انیمیشن (مطابق رفتار حسینیه در hard-load). +- متن جدیدی اضافه نمی‌شود → بدون تغییر فایل‌های locale. + +## نکات اجرا و ریسک + +- Dev با `--webpack` اجرا می‌شود؛ interception با webpack پشتیبانی می‌شود. +- `router.prefetch` از لیست (که الان هم هست) نسخه intercept شده را prefetch می‌کند → ورود آنی. +- اگر build روی static-params اسلات گیر کرد (بعید، همه force-dynamic هستند): `export const dynamic = "force-dynamic"` به صفحه intercept شده اضافه می‌شود. +- کد مرده `info-progress-card.tsx` (لینک non-localized بدون استفاده) دست نمی‌خورد. + +## تست و راستی‌آزمایی + +1. `npm run test` (vitest) — تست‌های موجود نباید بشکنند. +2. `npm run lint` (biome). +3. دستی با dev server روی پورت 3001: + - `/en/questions-list` → کلیک سکشن: اسلاید از راست؛ `/fa/questions-list` → اسلاید از چپ. + - بستن با دکمه close (flush)، back مرورگر، و شبیه‌سازی هاردور بک — همه با انیمیشن خروج. + - refresh مستقیم روی `/en/questions-list/personal_identity` → صفحه کامل. + - حفظ اسکرول لیست پس از بستن؛ قفل اسکرول پشت پنل هنگام باز بودن. \ No newline at end of file diff --git a/src/app/[lang]/questions-list/[slug]/loading.tsx b/src/app/[lang]/questions-list/[slug]/loading.tsx new file mode 100644 index 0000000..d4534a7 --- /dev/null +++ b/src/app/[lang]/questions-list/[slug]/loading.tsx @@ -0,0 +1 @@ +export { default } from "@/app/questions-list/[slug]/loading"; diff --git a/src/app/globals.css b/src/app/globals.css index c2b9939..a377990 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -229,10 +229,74 @@ body[data-page-background="custom"] .app-shell { background-image: var(--page-background-image); } -body.dropdown-open .app-shell { +body.dropdown-open .app-shell, +body.section-overlay-open .app-shell { overflow-y: hidden; } +/* ── Section Overlay Slide-in Panel (Exact Flutter Najm Matching: 350ms in, 200ms out, easeInOut) ── */ +.section-overlay { + position: fixed; + inset-block: 0; + inset-inline: 0; + margin-inline: auto; + width: 100%; + height: 100%; + height: 100dvh; + z-index: 50; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: none; + touch-action: pan-y; + -webkit-overflow-scrolling: touch; + padding-inline: 17px; + padding-bottom: var(--safe-bottom, 0px); + box-sizing: border-box; + background-color: var(--background); + background-image: var(--default-page-background-image); + background-position: top; + background-repeat: no-repeat; + background-size: cover; + transform: translate3d(100%, 0, 0); + box-shadow: -18px 0 50px rgba(0, 0, 0, 0.18); + transition: transform 350ms cubic-bezier(0.42, 0, 0.58, 1); + will-change: transform; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + contain: paint layout; +} + +@media (min-width: 640px) { + .section-overlay { + width: 375px; + } +} + +/* RTL: slide in from left instead of right */ +[dir="rtl"] .section-overlay, +.section-overlay[data-dir="rtl"], +[dir="rtl"] .section-overlay[data-state="closed"], +[dir="rtl"] .section-overlay[data-state="closing"], +.section-overlay[data-dir="rtl"][data-state="closed"], +.section-overlay[data-dir="rtl"][data-state="closing"] { + transform: translate3d(-100%, 0, 0); + box-shadow: 18px 0 50px rgba(0, 0, 0, 0.18); +} + +.section-overlay[data-state="open"] { + transform: translate3d(0, 0, 0) !important; +} + +.section-overlay[data-state="closing"] { + transition: transform 200ms cubic-bezier(0.42, 0, 0.58, 1) !important; +} + +@media (prefers-reduced-motion: reduce) { + .section-overlay { + transition: none !important; + } +} + .question-detail-header { max-height: 120px; overflow: hidden; diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index c224bef..0949d43 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; +import { useSectionOverlay } from "@/components/Componentes/section-overlay-host"; import Button from "@/components/Componentes/button"; import DataErrorState from "@/components/Componentes/data-error-state"; import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; @@ -61,6 +62,7 @@ type QuestionDetailClientProps = { locale?: Locale; questionsListHref: string; title: string; + onClose?: () => void; }; type StoredQuestionField = { @@ -90,6 +92,7 @@ function QuestionFlowWrapper({ dobQuestion, continueLabel, questionsListHref, + onExit, }: { visibleQuestions: QuestionField[]; itemSlug: string; @@ -97,6 +100,7 @@ function QuestionFlowWrapper({ requiredQuestionsCount: number; continueLabel: string; questionsListHref: string; + onExit?: () => void; }) { const { getAnswerValue } = useQuestionAnswers(); @@ -114,6 +118,7 @@ function QuestionFlowWrapper({ total={requiredCount} continueLabel={continueLabel} exitHref={questionsListHref} + onExit={onExit} optionalQuestionIndexes={dynamicQuestions.flatMap((question, index) => question.required ? [] : [index], )} @@ -168,6 +173,7 @@ export default function QuestionDetailClient({ locale = defaultLocale, questionsListHref, title, + onClose, }: QuestionDetailClientProps) { const router = useRouter(); const { dictionary: t } = useI18n(); @@ -176,13 +182,21 @@ export default function QuestionDetailClient({ const queryClient = useQueryClient(); const profileId = useCurrentProfileId(); + const handleExit = useCallback(() => { + if (onClose) { + onClose(); + return; + } + router.replace(questionsListHref); + }, [onClose, questionsListHref, router]); + // Hardware back in the detail page = navigate back to questions list. // QuestionAnswersProvider's pagehide/unmount safety net will flush // any pending answers automatically when the component unmounts. const handleHardwareBack = useCallback(async () => { - router.replace(questionsListHref); + handleExit(); return true; // handled — keep WebView open - }, [router, questionsListHref]); + }, [handleExit]); useHardwareBackHandler(handleHardwareBack); @@ -352,9 +366,9 @@ export default function QuestionDetailClient({ useEffect(() => { if (!isSchemaLoading && !isSchemaError && !item) { - router.replace(questionsListHref); + handleExit(); } - }, [isSchemaLoading, isSchemaError, item, questionsListHref, router]); + }, [isSchemaLoading, isSchemaError, item, handleExit]); if (isSchemaLoading) { if (!isAssessment) { @@ -374,7 +388,7 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} - onClick={() => router.replace(questionsListHref)} + onClick={handleExit} />

{loadingTitle} @@ -426,7 +440,7 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} - onClick={() => router.replace(questionsListHref)} + onClick={handleExit} />

{errorTitle} @@ -469,7 +483,7 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} - onClick={() => router.replace(questionsListHref)} + onClick={handleExit} />

{errorTitle} @@ -653,6 +667,7 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} + onClick={handleExit} />

{item.title} @@ -827,6 +842,7 @@ export default function QuestionDetailClient({ icon="close" iconLabel={closeLabel} exitHref={questionsListHref} + onExit={handleExit} />

{item.title} @@ -850,6 +866,7 @@ export default function QuestionDetailClient({ requiredQuestionsCount={requiredQuestionsCount} continueLabel={continueLabel} questionsListHref={questionsListHref} + onExit={handleExit} />

diff --git a/src/app/questions-list/questions-list-client.tsx b/src/app/questions-list/questions-list-client.tsx index 1253e88..b838db4 100644 --- a/src/app/questions-list/questions-list-client.tsx +++ b/src/app/questions-list/questions-list-client.tsx @@ -18,6 +18,8 @@ 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 { getCattellQuestions } from "@/hooks/marriage/use-cattell"; +import { getGlasserQuestions } from "@/hooks/marriage/use-glasser"; import { getFormSection, useFormOverviewQuery, @@ -51,6 +53,8 @@ import { useI18n } from "@/translations/provider"; import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch"; import DevTapInstrumentation from "@/components/Componentes/dev-tap-instrumentation"; import SectionsRequest from "./sections-request"; +import SectionOverlayHost from "@/components/Componentes/section-overlay-host"; +import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client"; export default function QuestionsListClient() { // Hardware back on the root questions list = close the Flutter service. @@ -58,6 +62,10 @@ export default function QuestionsListClient() { // entries. Flutter calls __habibHandleHardwareBack() and we return false // (meaning "I didn't handle it — you should close"). useHardwareBackHandler(() => { + if (activeSectionSlug) { + handleCloseSection(); + return true; // Handled: closed the section sheet, do not close WebView + } if (typeof window !== "undefined" && (window as any).HabibApp?.postMessage) { (window as any).HabibApp.postMessage( JSON.stringify({ action: "close_service" }), @@ -111,10 +119,63 @@ export default function QuestionsListClient() { const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false); const [selectedSection, setSelectedSection] = useState(null); + const [activeSectionSlug, setActiveSectionSlug] = useState( + null, + ); + + useEffect(() => { + const readSectionFromUrl = () => { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + const section = params.get("section"); + setActiveSectionSlug(section || null); + }; + + readSectionFromUrl(); + window.addEventListener("popstate", readSectionFromUrl); + return () => window.removeEventListener("popstate", readSectionFromUrl); + }, []); + + const handleOpenSection = useCallback((slug: string) => { + setActiveSectionSlug(slug); + if (typeof window !== "undefined") { + const url = new URL(window.location.href); + url.searchParams.set("section", slug); + window.history.pushState({ section: slug }, "", url.toString()); + } + }, []); + + const handleCloseSection = useCallback(() => { + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + if (params.get("section")) { + setActiveSectionSlug(null); + window.history.back(); + return; + } + } + setActiveSectionSlug(null); + }, []); + const questionListItems = useMemo( () => convertOverviewToFrontendItems(overview), [overview], ); + + const activeSectionItem = useMemo(() => { + if (!activeSectionSlug) return null; + return ( + questionListItems.find((i) => i.slug === activeSectionSlug) ?? { + slug: activeSectionSlug, + title: "", + estimate: "", + required: false, + icon: "profile" as const, + progress: 0, + summary: "", + } + ); + }, [questionListItems, activeSectionSlug]); const [localAssessmentProgress, setLocalAssessmentProgress] = useState< Map >(new Map()); @@ -310,22 +371,64 @@ export default function QuestionsListClient() { } finally { syncPromiseRef.current = null; } - }, [locale, overview, profile?.id, profile?.can_edit_profile, queryClient, questionListItems]); + }, [ + locale, + overview, + profile?.id, + profile?.can_edit_profile, + queryClient, + questionListItems, + ]); + + useEffect(() => { + if (typeof window === "undefined") return; + const preloadDetailModule = () => { + // Lazily preload question-detail-client bundle & its subcomponents into memory + import("@/app/questions-list/[slug]/question-detail-client").catch(() => {}); + }; + if ("requestIdleCallback" in window) { + const handle = (window as any).requestIdleCallback(preloadDetailModule, { + timeout: 1500, + }); + return () => (window as any).cancelIdleCallback(handle); + } else { + const timer = setTimeout(preloadDetailModule, 300); + return () => clearTimeout(timer); + } + }, []); const prefetchSection = useCallback( (item: QuestionListItem) => { + const sectionUrl = localizePath(`/questions-list/${item.slug}`, locale); + router.prefetch(sectionUrl); + if ( item.slug === "personality_test" || item.slug === "glasser_5_needs_test" - ) + ) { + if (item.slug === "personality_test") { + void queryClient.prefetchQuery({ + queryKey: marriageQueryKeys.cattellQuestions(locale), + queryFn: () => getCattellQuestions(locale), + staleTime: 30 * 1000, + }); + } else { + void queryClient.prefetchQuery({ + queryKey: marriageQueryKeys.glasserQuestions(locale), + queryFn: () => getGlasserQuestions(locale), + staleTime: 30 * 1000, + }); + } return; + } + void queryClient.prefetchQuery({ queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), queryFn: () => getFormSection("profile", item.slug, locale), staleTime: 30 * 1000, }); }, - [locale, queryClient], + [locale, queryClient, router], ); const prefetchQueueStarted = useRef(false); @@ -353,10 +456,39 @@ export default function QuestionsListClient() { if (profileSections.length === 0) return; prefetchQueueStarted.current = true; let cancelled = false; - const startPrefetch = () => { + + // ── Immediate: prefetch ALL routes in Next.js Router Cache ── + // This is cheap (no data fetch) and ensures instant navigation shell. + for (const section of profileSections) { + const sectionUrl = localizePath( + `/questions-list/${section.slug}`, + locale, + ); + router.prefetch(sectionUrl); + } + + // ── Immediate: top-priority section data (critical path) ── + // This section (usually personal_identity / first incomplete required + // section) is the most likely tap target. Prefetch its TanStack Query + // data right away so navigation + mount is instant. + if (profileSections[0]) { + void queryClient.prefetchQuery({ + queryKey: marriageQueryKeys.formSection("profile", profileSections[0].slug, locale), + queryFn: () => getFormSection("profile", profileSections[0].slug, locale), + staleTime: 30 * 1000, + }); + } + + // ── Deferred: remaining sections via bounded concurrency in idle ── + // Less critical — these are background-warmed. If idle is cancelled + // by a rerender, IntersectionObserver and onPointerDown still cover them. + const remaining = profileSections.slice(1); + if (remaining.length === 0) return; + + const startRemainingPrefetch = () => { if (cancelled) return; void prefetchSectionsWithBoundedConcurrency( - profileSections, + remaining, (item) => queryClient.fetchQuery({ queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), @@ -366,16 +498,17 @@ export default function QuestionsListClient() { () => cancelled, ); }; + const idle = typeof requestIdleCallback === "function" - ? requestIdleCallback(startPrefetch) - : setTimeout(startPrefetch, 200); + ? requestIdleCallback(startRemainingPrefetch, { timeout: 3000 }) + : setTimeout(startRemainingPrefetch, 200); return () => { cancelled = true; if (typeof cancelIdleCallback === "function" && typeof idle === "number") { cancelIdleCallback(idle); } }; - }, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]); + }, [locale, overview, queryClient, questionListItems, router, sectionProgressBySlug]); useEffect(() => { void syncPendingAnswers().catch((err) => { @@ -703,6 +836,8 @@ export default function QuestionsListClient() { progress={sectionProgressBySlug.get(item.slug) ?? null} onInfoClick={(section) => setSelectedSection(section)} onPrefetch={prefetchSection} + onNearViewport={prefetchSection} + onSelect={(item) => handleOpenSection(item.slug)} /> ))} @@ -740,6 +875,25 @@ export default function QuestionsListClient() { + + + {activeSectionItem ? ( + + ) : null} + ); } diff --git a/src/app/questions-list/section-prefetch-race.test.ts b/src/app/questions-list/section-prefetch-race.test.ts new file mode 100644 index 0000000..823f388 --- /dev/null +++ b/src/app/questions-list/section-prefetch-race.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +/** + * Regression test for P0-1: prefetchQueueStarted race condition. + * + * The bug: prefetchQueueStarted.current was set to `true` before + * requestIdleCallback fired. If the effect re-ran (e.g. sectionProgressBySlug + * changed identity) and cleanup cancelled the idle callback, the flag stayed + * `true` and subsequent effect runs skipped prefetching entirely. + * + * The fix: critical section prefetch runs immediately (not in idle), so even + * if the idle callback for remaining sections is cancelled, the top-priority + * section is always warmed up. + */ +describe("prefetch queue race condition (P0-1)", () => { + let idleCallbacks: Map void>; + let nextIdleId: number; + + beforeEach(() => { + idleCallbacks = new Map(); + nextIdleId = 1; + + // Simulate requestIdleCallback / cancelIdleCallback + (globalThis as any).requestIdleCallback = vi.fn((cb: () => void) => { + const id = nextIdleId++; + idleCallbacks.set(id, cb); + return id; + }); + (globalThis as any).cancelIdleCallback = vi.fn((id: number) => { + idleCallbacks.delete(id); + }); + }); + + afterEach(() => { + delete (globalThis as any).requestIdleCallback; + delete (globalThis as any).cancelIdleCallback; + }); + + it("critical section prefetch is not blocked by idle cancellation", () => { + // Simulate the fixed effect behavior: + // 1. Critical section prefetch runs immediately (not in idle) + // 2. Remaining sections are deferred to idle + + const criticalPrefetch = vi.fn(); + const remainingPrefetch = vi.fn(); + let prefetchQueueStarted = false; + + // --- First effect run --- + // Simulates: overview ready, effect runs + if (!prefetchQueueStarted) { + prefetchQueueStarted = true; + + // Critical section: runs immediately + criticalPrefetch(); + + // Remaining: deferred to idle + const idleId = (globalThis as any).requestIdleCallback(() => { + remainingPrefetch(); + }); + + // Simulate cleanup (rerender before idle fires) + (globalThis as any).cancelIdleCallback(idleId); + } + + // Critical section was prefetched despite idle cancellation + expect(criticalPrefetch).toHaveBeenCalledTimes(1); + + // Remaining sections were NOT prefetched (idle was cancelled) + expect(remainingPrefetch).not.toHaveBeenCalled(); + }); + + it("idle callback with timeout eventually fires remaining prefetches", async () => { + const prefetch = vi.fn(); + + // Schedule with timeout + const id = (globalThis as any).requestIdleCallback(prefetch); + + // Verify callback is registered + expect(idleCallbacks.has(id)).toBe(true); + + // Simulate idle firing + const cb = idleCallbacks.get(id); + cb?.(); + + expect(prefetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Componentes/question-card.tsx b/src/components/Componentes/question-card.tsx index 5229a13..8eee18f 100644 --- a/src/components/Componentes/question-card.tsx +++ b/src/components/Componentes/question-card.tsx @@ -12,6 +12,7 @@ type QuestionCardProps = { onInfoClick?: (item: QuestionListItem) => void; onNearViewport?: (item: QuestionListItem) => void; onPrefetch?: (item: QuestionListItem) => void; + onSelect?: (item: QuestionListItem) => void; }; const RADIUS = 8; @@ -34,6 +35,7 @@ export function QuestionCard({ onInfoClick, onNearViewport, onPrefetch, + onSelect, }: QuestionCardProps) { const { dictionary: t, locale } = useI18n(); const hasProgress = typeof progress === "number" && Number.isFinite(progress); @@ -69,10 +71,18 @@ export function QuestionCard({ return ( { + if (onSelect && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.button === 0) { + e.preventDefault(); + onSelect(item); + } + }} onFocus={() => onPrefetch?.(item)} onPointerEnter={() => onPrefetch?.(item)} + onPointerDown={() => onPrefetch?.(item)} >
void; }; export function QuestionExitNavigationButton({ exitHref, + onExit, ...props }: QuestionExitNavigationButtonProps) { const router = useRouter(); @@ -44,8 +46,12 @@ export function QuestionExitNavigationButton({ } catch { // ignore } finally { - const target = localizePath(exitHref || "/questions-list", locale); - router.replace(target); + if (onExit) { + onExit(); + } else { + const target = localizePath(exitHref || "/questions-list", locale); + router.replace(target); + } } }} /> diff --git a/src/components/Componentes/question-section-flow.tsx b/src/components/Componentes/question-section-flow.tsx index a73e1a1..9570803 100644 --- a/src/components/Componentes/question-section-flow.tsx +++ b/src/components/Componentes/question-section-flow.tsx @@ -22,6 +22,7 @@ type QuestionSectionFlowProps = { children: ReactNode; continueLabel: string; exitHref: string; + onExit?: () => void; total: number; optionalQuestionIndexes: readonly number[]; questions?: readonly QuestionField[]; @@ -31,12 +32,14 @@ function SectionFlowContent({ children, continueLabel, exitHref, + onExit, optionalQuestionIndexes, questions, }: { children: ReactNode; continueLabel: string; exitHref: string; + onExit?: () => void; optionalQuestionIndexes: readonly number[]; questions?: readonly QuestionField[]; }) { @@ -63,10 +66,14 @@ function SectionFlowContent({ } catch { // ignore } finally { - const target = localizePath(exitHref || "/questions-list", locale); - router.replace(target); + if (onExit) { + onExit(); + } else { + const target = localizePath(exitHref || "/questions-list", locale); + router.replace(target); + } } - }, [exitHref, flushAnswers, locale, router, isSubmitting]); + }, [exitHref, onExit, flushAnswers, locale, router, isSubmitting]); const markOptionalQuestionsPassed = useCallback( (currentIndex: number, nextIndex: number) => { @@ -143,6 +150,7 @@ export function QuestionSectionFlow({ children, continueLabel, exitHref, + onExit, total, optionalQuestionIndexes, questions, @@ -152,6 +160,7 @@ export function QuestionSectionFlow({ diff --git a/src/components/Componentes/section-overlay-host.test.tsx b/src/components/Componentes/section-overlay-host.test.tsx new file mode 100644 index 0000000..61c8510 --- /dev/null +++ b/src/components/Componentes/section-overlay-host.test.tsx @@ -0,0 +1,116 @@ +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/translations/provider"; +import SectionOverlayHost from "./section-overlay-host"; + +describe("SectionOverlayHost", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + return setTimeout(() => cb(Date.now()), 0); + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => { + clearTimeout(id); + }); + document.body.className = ""; + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.useRealTimers(); + document.body.className = ""; + }); + + it("returns null when no children or open is false", () => { + const { container } = render( + + {null} + , + ); + + expect(container.firstChild).toBeNull(); + expect(document.body.classList.contains("section-overlay-open")).toBe(false); + }); + + it("renders overlay and adds body class in LTR mode", () => { + render( + + +
Detail Page Content
+
+
, + ); + + // Initial paint frame + act(() => { + vi.advanceTimersByTime(16); + }); + + const overlay = screen.getByRole("dialog"); + expect(overlay).toBeInTheDocument(); + expect(overlay).toHaveClass("section-overlay"); + expect(overlay).toHaveAttribute("data-dir", "ltr"); + expect(overlay).toHaveAttribute("data-state", "open"); + expect(screen.getByTestId("detail-content")).toBeInTheDocument(); + expect(document.body.classList.contains("section-overlay-open")).toBe(true); + }); + + it("sets RTL direction when locale is fa", () => { + render( + + +
محتوای جزئیات
+
+
, + ); + + act(() => { + vi.advanceTimersByTime(16); + }); + + const overlay = screen.getByRole("dialog"); + expect(overlay).toHaveAttribute("data-dir", "rtl"); + expect(overlay).toHaveAttribute("dir", "rtl"); + }); + + it("retains children and animates to closing state when open becomes false", () => { + const { rerender } = render( + + +
Detail Page Content
+
+
, + ); + + act(() => { + vi.advanceTimersByTime(16); + }); + + expect(screen.getByTestId("detail-content")).toBeInTheDocument(); + expect(screen.getByRole("dialog")).toHaveAttribute("data-state", "open"); + + // Close overlay (simulate back / close action) + rerender( + + +
Detail Page Content
+
+
, + ); + + // Content is retained during closing transition + const overlay = screen.getByRole("dialog"); + expect(overlay).toHaveAttribute("data-state", "closing"); + expect(screen.getByTestId("detail-content")).toBeInTheDocument(); + expect(document.body.classList.contains("section-overlay-open")).toBe(false); + + // Advance past REVERSE_DURATION_MS (200ms) + act(() => { + vi.advanceTimersByTime(210); + }); + + expect(screen.queryByTestId("detail-content")).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Componentes/section-overlay-host.tsx b/src/components/Componentes/section-overlay-host.tsx new file mode 100644 index 0000000..074bea2 --- /dev/null +++ b/src/components/Componentes/section-overlay-host.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { + createContext, + type ReactNode, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { localeDirections } from "@/translations/config"; +import { useI18n } from "@/translations/provider"; + +type SectionOverlayContextValue = { + isOverlay: true; + onClose?: () => void; +}; + +const SectionOverlayContext = + createContext(null); + +export function useSectionOverlay() { + return useContext(SectionOverlayContext); +} + +type SectionOverlayHostProps = { + open?: boolean; + onClose?: () => void; + children?: ReactNode; +}; + +const REVERSE_DURATION_MS = 200; + +export function SectionOverlayHost({ + open = false, + onClose, + children, +}: SectionOverlayHostProps) { + const { locale } = useI18n(); + const dir = (locale && localeDirections[locale]) || "ltr"; + + // Retain last rendered children during closing animation (like hosseinieh-app PanelSlot) + const [activeChild, setActiveChild] = useState( + open ? children ?? null : null, + ); + const [mounted, setMounted] = useState(open); + const [state, setState] = useState<"closed" | "open" | "closing">( + open ? "open" : "closed", + ); + + const isClosingRef = useRef(false); + const closeTimerRef = useRef | null>(null); + + useEffect(() => { + if (open) { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + isClosingRef.current = false; + setMounted(true); + if (children) { + setActiveChild(children); + } + + const frame = requestAnimationFrame(() => { + setState("open"); + if (typeof document !== "undefined") { + document.body.classList.add("section-overlay-open"); + } + }); + return () => cancelAnimationFrame(frame); + } + + // When closing + if (mounted && !isClosingRef.current) { + isClosingRef.current = true; + setState("closing"); + if (typeof document !== "undefined") { + document.body.classList.remove("section-overlay-open"); + } + + closeTimerRef.current = setTimeout(() => { + setMounted(false); + setState("closed"); + setActiveChild(null); + isClosingRef.current = false; + closeTimerRef.current = null; + }, REVERSE_DURATION_MS); + } + }, [open, children, mounted]); + + useEffect(() => { + return () => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + } + if (typeof document !== "undefined") { + document.body.classList.remove("section-overlay-open"); + } + }; + }, []); + + // ESC key handler to close panel + useEffect(() => { + if (!open || !onClose) return; + const handleEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + window.addEventListener("keydown", handleEsc); + return () => window.removeEventListener("keydown", handleEsc); + }, [open, onClose]); + + const contextValue = useMemo( + () => ({ + isOverlay: true, + onClose, + }), + [onClose], + ); + + if (!mounted && !open && state === "closed") { + return null; + } + + const contentToRender = open ? children || activeChild : activeChild; + + return ( + + + + ); +} + +export default SectionOverlayHost; From f9a01cef31504dd2c251833cabda504c752e95e4 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Tue, 18 Aug 2026 12:37:24 +0330 Subject: [PATCH 14/35] feat: redesign country selection sheet with enhanced search, list items, and testing support --- ...ss_02d1b36a-d03c-400c-8d4c-a423d16e661f.md | 2 +- .../Componentes/question-phone.test.tsx | 82 ++++++++++ src/components/Componentes/question-phone.tsx | 140 ++++++++++++------ 3 files changed, 179 insertions(+), 45 deletions(-) create mode 100644 src/components/Componentes/question-phone.test.tsx diff --git a/.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md b/.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md index d34e5f4..ae63a2b 100644 --- a/.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md +++ b/.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md @@ -84,4 +84,4 @@ body.section-overlay-open .app-shell { overflow-y: hidden; } - `/en/questions-list` → کلیک سکشن: اسلاید از راست؛ `/fa/questions-list` → اسلاید از چپ. - بستن با دکمه close (flush)، back مرورگر، و شبیه‌سازی هاردور بک — همه با انیمیشن خروج. - refresh مستقیم روی `/en/questions-list/personal_identity` → صفحه کامل. - - حفظ اسکرول لیست پس از بستن؛ قفل اسکرول پشت پنل هنگام باز بودن. \ No newline at end of file + - حفظ اسکرول لیست پس از بستن؛ قفل اسکرول پشت پنل هنگام باز بودن.له \ No newline at end of file diff --git a/src/components/Componentes/question-phone.test.tsx b/src/components/Componentes/question-phone.test.tsx new file mode 100644 index 0000000..232770a --- /dev/null +++ b/src/components/Componentes/question-phone.test.tsx @@ -0,0 +1,82 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { QuestionField } from "@/lib/schema-adapter"; +import { QuestionPhone } from "./question-phone"; + +afterEach(() => { + cleanup(); +}); + +vi.mock("@/translations/provider", () => ({ + useI18n: () => ({ + dictionary: { + "Select country": "انتخاب کشور", + Close: "بستن", + Confirm: "تایید", + }, + locale: "fa", + }), +})); + +vi.mock("./question-answer-storage", () => ({ + useQuestionAnswers: () => ({ + getAnswerValue: () => null, + setAnswerValue: vi.fn(), + isLoading: false, + }), +})); + +describe("QuestionPhone Component", () => { + const mockQuestion: QuestionField = { + id: "personal_contact_number", + title: "شماره تماس شخصی", + type: "phone", + extras: { + placeHolder: "+98 9123456789", + }, + }; + + it("renders phone input and opens sheet on country code click", async () => { + render(); + + const countryButton = screen.getByRole("button", { name: /\+98/i }); + expect(countryButton).toBeInTheDocument(); + + fireEvent.click(countryButton); + + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeInTheDocument(); + expect(screen.getByText("انتخاب کشور")).toBeInTheDocument(); + + // Close button exists in header + const closeBtn = screen.getByLabelText("بستن"); + expect(closeBtn).toBeInTheDocument(); + + // Search input is present with translated placeholder + const searchInput = screen.getByPlaceholderText("جستجو..."); + expect(searchInput).toBeInTheDocument(); + }); + + it("filters countries when searching and selects a country", async () => { + const { container } = render( + , + ); + + const triggerButton = container.querySelector("button")!; + fireEvent.click(triggerButton); + + const searchInput = screen.getByPlaceholderText("جستجو..."); + fireEvent.change(searchInput, { target: { value: "ایران" } }); + + // Option should be rendered + const iranOption = screen.getByRole("button", { name: /ایران/i }); + expect(iranOption).toBeInTheDocument(); + + fireEvent.click(iranOption); + + // After selection, dialog closes + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/Componentes/question-phone.tsx b/src/components/Componentes/question-phone.tsx index b2a7bd8..7d41d4c 100644 --- a/src/components/Componentes/question-phone.tsx +++ b/src/components/Componentes/question-phone.tsx @@ -541,6 +541,22 @@ export function QuestionPhone({ const selectCountryTitle = t["Select country"] || question.title; + const searchPlaceholder = + locale === "fa" + ? "جستجو..." + : locale === "ar" + ? "بحث..." + : locale === "tr" + ? "Ara..." + : "Search..."; + + const noResultsText = + locale === "fa" + ? "موردی یافت نشد" + : locale === "ar" + ? "لم يتم العثور على نتائج" + : "No options found"; + return (
-
-

+ {/* Header with Title and Close Button */} +
+

{selectCountryTitle} -

-

+

+ + -
-
+ {/* Search Bar */} +
+
setSearchQuery(e.target.value)} - aria-label={selectCountryTitle} - placeholder={ - locale === "fa" - ? "جستجوی کشور یا پیش‌شماره..." - : "Search country or dial code..." - } + placeholder={searchPlaceholder} className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" /> {searchQuery ? ( @@ -700,63 +733,82 @@ export function QuestionPhone({
+ {/* Country Options List */}
event.stopPropagation()} onTouchMove={(event) => event.stopPropagation()} onTouchEnd={(event) => event.stopPropagation()} - className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain px-5" + className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-contain px-5 py-3" > {filteredCountries.length > 0 ? ( filteredCountries.map((c) => { + const cleanCode = c.code.replace(/[^\d]/g, ""); + const activeCleanCode = (codeValue || "").replace( + /[^\d]/g, + "", + ); + const isSelected = cleanCode === activeCleanCode; + return ( ); }) ) : ( - - {locale === "fa" ? "موردی یافت نشد" : "No options found"} - +
+ {noResultsText} +
)}
From 2bd91b89155c31e9055a904bf6a575d2742c73b1 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Tue, 18 Aug 2026 12:39:14 +0330 Subject: [PATCH 15/35] chore: disable browser autocomplete, spellcheck, and password manager interference on input fields --- src/components/Componentes/question-phone.tsx | 3 +++ src/components/Componentes/question-text.tsx | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/components/Componentes/question-phone.tsx b/src/components/Componentes/question-phone.tsx index 7d41d4c..fe22f63 100644 --- a/src/components/Componentes/question-phone.tsx +++ b/src/components/Componentes/question-phone.tsx @@ -611,6 +611,9 @@ export function QuestionPhone({ handleChange(e.target.value)} onBlur={handleBlur} @@ -140,6 +147,12 @@ export default function QuestionText({ type={isNumericQuestion ? "tel" : "text"} inputMode={isNumericQuestion ? "numeric" : undefined} pattern={isNumericQuestion ? "[0-9]*" : undefined} + autoComplete="off" + autoCorrect="off" + autoCapitalize="none" + spellCheck="false" + data-lpignore="true" + data-form-type="other" value={localValue} onChange={(e) => handleChange(e.target.value)} onBlur={handleBlur} From b107c224338004d87f07dd2ddf860ae997bf8fe6 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Tue, 18 Aug 2026 12:40:17 +0330 Subject: [PATCH 16/35] fix: update close button aria-label to use constant string instead of translation key --- src/components/Componentes/question-phone.test.tsx | 2 +- src/components/Componentes/question-phone.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Componentes/question-phone.test.tsx b/src/components/Componentes/question-phone.test.tsx index 232770a..08cacc0 100644 --- a/src/components/Componentes/question-phone.test.tsx +++ b/src/components/Componentes/question-phone.test.tsx @@ -49,7 +49,7 @@ describe("QuestionPhone Component", () => { expect(screen.getByText("انتخاب کشور")).toBeInTheDocument(); // Close button exists in header - const closeBtn = screen.getByLabelText("بستن"); + const closeBtn = screen.getByLabelText("Close"); expect(closeBtn).toBeInTheDocument(); // Search input is present with translated placeholder diff --git a/src/components/Componentes/question-phone.tsx b/src/components/Componentes/question-phone.tsx index fe22f63..5683705 100644 --- a/src/components/Componentes/question-phone.tsx +++ b/src/components/Componentes/question-phone.tsx @@ -671,7 +671,7 @@ export function QuestionPhone({ type="button" onClick={closeSheet} className="flex size-8 shrink-0 items-center justify-center rounded-full text-[#667085] hover:bg-[#F2F4F7] hover:text-[#181818] transition-colors cursor-pointer" - aria-label={t.Close || "Close"} + aria-label="Close" >
+ + +
+
+ +
+ , + ); + + const input = screen.getByRole("textbox", { name: "Full Name" }); + input.focus(); + expect(input).toHaveFocus(); + + // Finger touches input with 12px jitter/drift (typical coarse touch contact area shift) + fireEvent.touchStart(input, { + touches: [{ clientY: 300 }], + target: input, + }); + fireEvent.touchMove(input, { + touches: [{ clientY: 288 }], + target: input, + }); + fireEvent.touchEnd(input, { + changedTouches: [{ clientY: 288 }], + target: input, + }); + + // Input should remain focused, and active question must remain index 0 + expect(input).toHaveFocus(); + expect(onActiveIndexChange).not.toHaveBeenCalledWith(1); + }); + + it("does not engage drag or blur input when touched while snap animation is settling", () => { + const onActiveIndexChange = vi.fn(); + render( + +
+