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..ae63a2b --- /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/public/fonts/Amiri/Amiri-Bold.ttf b/public/fonts/Amiri/Amiri-Bold.ttf new file mode 100644 index 0000000..49600cb Binary files /dev/null and b/public/fonts/Amiri/Amiri-Bold.ttf differ diff --git a/public/fonts/Amiri/Amiri-BoldItalic.ttf b/public/fonts/Amiri/Amiri-BoldItalic.ttf new file mode 100644 index 0000000..1d89e4f Binary files /dev/null and b/public/fonts/Amiri/Amiri-BoldItalic.ttf differ diff --git a/public/fonts/Amiri/Amiri-Italic.ttf b/public/fonts/Amiri/Amiri-Italic.ttf new file mode 100644 index 0000000..13e3ec3 Binary files /dev/null and b/public/fonts/Amiri/Amiri-Italic.ttf differ diff --git a/public/fonts/Amiri/Amiri-Regular.ttf b/public/fonts/Amiri/Amiri-Regular.ttf new file mode 100644 index 0000000..df5e1df Binary files /dev/null and b/public/fonts/Amiri/Amiri-Regular.ttf differ diff --git a/public/fonts/Amiri/OFL.txt b/public/fonts/Amiri/OFL.txt new file mode 100644 index 0000000..51769ce --- /dev/null +++ b/public/fonts/Amiri/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2010-2022 The Amiri Project Authors (https://github.com/aliftype/amiri). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx index a0099b7..7465e02 100644 --- a/src/app/[lang]/page.tsx +++ b/src/app/[lang]/page.tsx @@ -11,6 +11,7 @@ import { getSubmitPath, hasCompletedMarriageProfileBasics, } from "@/lib/get-submit-path"; +import { fetchProfileSSR } from "@/lib/ssr-fetch"; import { localizePath } from "@/translations/config"; export const dynamic = "force-dynamic"; @@ -25,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, @@ -34,38 +42,16 @@ export default async function LocaleEntryPage({ redirect(localizePath(cachedEntryPath, lang)); } - // 1. If not authenticated, render instantly on the server (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) { + // 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)); } + // 4. Graceful Fallback if server fetch was unreachable return ( <> 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/candidate-contact/candidate-contact-client.tsx b/src/app/candidate-contact/candidate-contact-client.tsx new file mode 100644 index 0000000..6c3e667 --- /dev/null +++ b/src/app/candidate-contact/candidate-contact-client.tsx @@ -0,0 +1,411 @@ +"use client"; + +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"; +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 MarriageAdvisorsOverlay, { + useMarriageAdvisorsOverlay, +} from "@/components/Componentes/marriage-advisors-overlay"; +import MatchProfileOverlay, { + useMatchProfileOverlay, +} from "@/components/Componentes/match-profile-overlay"; +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 { isAdvisorOpen, openAdvisors, closeAdvisors } = + useMarriageAdvisorsOverlay(); + const { isProfileOpen, openProfile, closeProfile } = + useMatchProfileOverlay(); + + 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. + useHabibWebReady(!!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..f7f09ac --- /dev/null +++ b/src/app/finding-match/finding-match-client.tsx @@ -0,0 +1,191 @@ +"use client"; + +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"; +import MarriageAdvisorsOverlay, { + useMarriageAdvisorsOverlay, +} from "@/components/Componentes/marriage-advisors-overlay"; +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 { isAdvisorOpen, openAdvisors, closeAdvisors } = + useMarriageAdvisorsOverlay(); + 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. + useHabibWebReady(!!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/globals.css b/src/app/globals.css index c2b9939..7ec4c38 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -149,6 +149,34 @@ html { height: 100%; overflow: hidden; overscroll-behavior: none; + -webkit-tap-highlight-color: transparent; +} + +* { + -webkit-tap-highlight-color: transparent; +} + +button, +input, +select, +textarea, +a, +label, +[role="button"] { + touch-action: manipulation; +} + +button, +[role="button"] { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; +} + +input, +textarea { + -webkit-user-select: text; + user-select: text; } body { @@ -229,10 +257,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/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 2077197..39863b6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata, Viewport } from "next"; -import { Amiri } from "next/font/google"; import localFont from "next/font/local"; import { cookies, headers } from "next/headers"; import Providers from "./providers"; @@ -20,12 +19,29 @@ const faminela = localFont({ fallback: ["Arial", "sans-serif"], }); -// Amiri has no UI consumer above the fold (arabic fonts are mapped to Segoe -// UI in globals.css), so preloading it only competes with critical resources -// on a cold start. `display: swap` lets it load lazily if a consumer appears. -const amiri = Amiri({ - weight: ["400", "700"], - subsets: ["arabic"], +const amiri = localFont({ + src: [ + { + path: "../../public/fonts/Amiri/Amiri-Regular.ttf", + weight: "400", + style: "normal", + }, + { + path: "../../public/fonts/Amiri/Amiri-Italic.ttf", + weight: "400", + style: "italic", + }, + { + path: "../../public/fonts/Amiri/Amiri-Bold.ttf", + weight: "700", + style: "normal", + }, + { + path: "../../public/fonts/Amiri/Amiri-BoldItalic.ttf", + weight: "700", + style: "italic", + }, + ], variable: "--font-amiri", display: "swap", preload: false, @@ -236,34 +252,86 @@ export default async function RootLayout({ root.dataset.webBootstrap = 'pending'; } - // 4. Instant web_ready Announcement - function announce() { - if (window.__habibWebReadySent || !window.HabibApp || !window.HabibApp.postMessage) return false; + // 4. Queued web_ready Delivery Protocol + // + // 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; - } - 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'; } - }, 3000); + }, 2500); return true; } - if (!tryAnnounce()) { + 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() { + requestWebReady(); + }, 3000); + + // 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 (tryAnnounce() || attempts >= 40) clearInterval(timer); + if (window.HabibApp && window.HabibApp.postMessage) { + clearInterval(bridgePollTimer); + deliverReadyIfPossible(); + } else if (attempts >= 100) { + clearInterval(bridgePollTimer); + } }, 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/marriage-advisors/page.tsx b/src/app/marriage-advisors/page.tsx index 3ae339a..32823f8 100644 --- a/src/app/marriage-advisors/page.tsx +++ b/src/app/marriage-advisors/page.tsx @@ -17,7 +17,11 @@ import { postActionToFlutter } from "@/lib/webview-actions"; const FALLBACK_AVATAR = "/assets/images/Avatar Image.png"; -export default function MarriageAdvisorsPage() { +type MarriageAdvisorsPageProps = { + onClose?: () => void; +}; + +export default function MarriageAdvisorsPage({ onClose }: MarriageAdvisorsPageProps = {}) { const { dictionary: t } = useI18n(); const [isSupportOpen, setIsSupportOpen] = useState(false); @@ -42,15 +46,16 @@ export default function MarriageAdvisorsPage() { <>
- +

-
+
{isLoading && } {isError && ( 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..2aff24c --- /dev/null +++ b/src/app/new-match/new-match-client.tsx @@ -0,0 +1,916 @@ +"use client"; + +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 MarriageAdvisorsOverlay, { + useMarriageAdvisorsOverlay, +} from "@/components/Componentes/marriage-advisors-overlay"; +import MatchProfileOverlay, { + useMatchProfileOverlay, +} from "@/components/Componentes/match-profile-overlay"; +import PageHeader from "@/components/Componentes/page-header"; +import { PageBackground } from "@/components/Componentes/page-background"; +import InformationSheet from "@/components/Componentes/information-sheet"; +import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton"; +import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot"; +import { DiscountWidget } from "@/components/Componentes/discount-widget"; +import type { CheckDiscountResult } from "@/hooks/marriage/use-validate-discount"; +import { useHabcoinPaymentMutation } from "@/hooks/marriage/use-habcoin-payment"; +import { useHabcoinInventoryQuery } from "@/hooks/marriage/use-habcoin-inventory"; +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 fieldCandidateMatchers = { + name: [ + "name", + "full_name", + "fullname", + "first_name", + "last_name", + "display_name", + ], + occupation: [ + "job_title", + "occupation", + "job", + "profession", + "career", + "work", + "highest_level_of_education", + "field_of_study", + "employment_status", + ], + age: ["age", "date_of_birth", "birth_date", "dob"], + city: [ + "current_residence", + "city", + "current_city", + "residence_city", + "location", + "residence", + "birthplace", + "birth_city", + ], + maritalStatus: [ + "current_marital_status", + "marital_status", + "maritalstatus", + "relationship_status", + ], + cityPreference: [ + "willingness_to_relocate", + "city_preference", + "citypreference", + "preferred_city", + "preferred_location", + "future_residence", + "residence_preference_after_marriage", + ], +} 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 matchesCandidate( + field: MarriageField, + candidates: readonly string[], +): boolean { + const rawKey = (field.key || "").toLowerCase(); + const keyParts = rawKey.split("."); + const suffix = keyParts[keyParts.length - 1]; + const normalizedKey = rawKey.replace(/[^a-z0-9]/g, ""); + const normalizedSuffix = suffix.replace(/[^a-z0-9]/g, ""); + + for (const c of candidates) { + const normC = c.toLowerCase().replace(/[^a-z0-9]/g, ""); + if ( + normalizedSuffix === normC || + normalizedKey.endsWith(normC) || + rawKey === c.toLowerCase() || + suffix === c.toLowerCase() + ) { + return true; + } + } + return false; +} + +function calculateAgeFromDob(dobString: string): number | null { + if (!dobString) return null; + const match = dobString.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/); + if (!match) return null; + const year = parseInt(match[1], 10); + const month = parseInt(match[2], 10) - 1; + const day = parseInt(match[3], 10); + const birthDate = new Date(year, month, day); + if (isNaN(birthDate.getTime())) return null; + const today = new Date(); + let age = today.getFullYear() - birthDate.getFullYear(); + const m = today.getMonth() - birthDate.getMonth(); + if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) { + age--; + } + return age > 0 && age < 120 ? age : null; +} + +function formatOptionValue( + value: MarriageFieldValue, + dictionary?: Record, +): string | null { + const base = formatFieldValue(value); + if (!base) return null; + if (!dictionary) return base; + + if (dictionary[base]) return dictionary[base]; + + // Try replacing underscores with spaces: "single;_never_married" -> "single; never married" + const withSpaces = base.replace(/_/g, " ").trim(); + if (dictionary[withSpaces]) return dictionary[withSpaces]; + + // Try capitalized first letter: "Single; never married" + const capitalized = withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1); + if (dictionary[capitalized]) return dictionary[capitalized]; + + return withSpaces; +} + +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, + dictionary?: Record, +): DisplayField | null { + const value = formatOptionValue(field.value, dictionary); + + 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, + dictionary?: Record, +): DisplayField | null { + for (const [fieldIndex, field] of fields.entries()) { + if (usedIndexes.has(fieldIndex)) { + continue; + } + + if (matchesCandidate(field, candidates)) { + const displayField = toDisplayField(field, dictionary); + if (displayField) { + usedIndexes.add(fieldIndex); + return displayField; + } + } + } + + return null; +} + +function useMatchSummaryDisplay( + matchSummary: MarriageMatchSummary | null, + t?: Record, +) { + return useMemo(() => { + const fields = matchSummary?.public_info ?? []; + const usedIndexes = new Set(); + + // 1. Name: Combine first_name and last_name if available, or find general name + let displayName: string | null = null; + const firstNameIdx = fields.findIndex( + (f) => + f.key === "personal_identity.first_name" || + f.key?.endsWith(".first_name"), + ); + const lastNameIdx = fields.findIndex( + (f) => + f.key === "personal_identity.last_name" || + f.key?.endsWith(".last_name"), + ); + + if (firstNameIdx !== -1 && fields[firstNameIdx].value) { + usedIndexes.add(firstNameIdx); + const firstName = formatFieldValue(fields[firstNameIdx].value); + if (lastNameIdx !== -1 && fields[lastNameIdx].value) { + usedIndexes.add(lastNameIdx); + const lastName = formatFieldValue(fields[lastNameIdx].value); + displayName = `${firstName} ${lastName}`.trim(); + } else { + displayName = firstName; + } + } else { + const nameField = pickField( + fields, + fieldCandidateMatchers.name, + usedIndexes, + t, + ); + if (nameField) { + displayName = nameField.value; + } + } + + if (!displayName && matchSummary?.id) { + displayName = `Profile #${matchSummary.id}`; + } + + // 2. Occupation + const occupation = pickField( + fields, + fieldCandidateMatchers.occupation, + usedIndexes, + t, + ); + + // 3. Age (extract and calculate from date_of_birth if available) + const dobIdx = fields.findIndex( + (f) => + f.key === "personal_identity.date_of_birth" || + f.key?.endsWith(".date_of_birth") || + f.key?.toLowerCase().includes("date_of_birth") || + f.key?.toLowerCase().includes("birth_date"), + ); + let age: DisplayField | null = null; + if (dobIdx !== -1 && fields[dobIdx].value) { + usedIndexes.add(dobIdx); + const calculatedAge = calculateAgeFromDob(String(fields[dobIdx].value)); + if (calculatedAge) { + age = { + id: fields[dobIdx].key, + label: t ? t["Age"] || "Age" : "Age", + value: `${calculatedAge}`, + }; + } + } + if (!age) { + age = pickField(fields, fieldCandidateMatchers.age, usedIndexes, t); + } + + // 4. City / Current Residence + const city = pickField(fields, fieldCandidateMatchers.city, usedIndexes, t); + + // 5. Marital Status + const maritalStatus = pickField( + fields, + fieldCandidateMatchers.maritalStatus, + usedIndexes, + t, + ); + + // 6. City Preference / Relocation + const cityPreference = pickField( + fields, + fieldCandidateMatchers.cityPreference, + usedIndexes, + t, + ); + + const extraFields = fields + .filter((_, index) => !usedIndexes.has(index)) + .map((f) => toDisplayField(f, t)) + .filter((field): field is DisplayField => Boolean(field)) + .slice(0, 4); + + if (typeof window !== "undefined") { + console.log( + "🔍 [useMatchSummaryDisplay] input matchSummary:", + matchSummary, + ); + console.log("🔍 [useMatchSummaryDisplay] computed output:", { + displayName, + occupation, + age, + city, + maritalStatus, + cityPreference, + extraFieldsCount: extraFields.length, + }); + } + + return { + age, + city, + cityPreference, + extraFields, + maritalStatus, + name: displayName, + occupation, + }; + }, [matchSummary, t]); +} + +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 { isAdvisorOpen, openAdvisors, closeAdvisors } = + useMarriageAdvisorsOverlay(); + const { isProfileOpen, openProfile, closeProfile } = useMatchProfileOverlay(); + const { data: profile, isError, isLoading } = useMarriageProfileQuery(); + const [isPaymentSheetOpen, setIsPaymentSheetOpen] = useState(false); + const [paymentError, setPaymentError] = useState(null); + const [isInsufficientCoins, setIsInsufficientCoins] = useState(false); + + const [appliedDiscount, setAppliedDiscount] = + useState(null); + + const { data: inventory, isLoading: isInventoryLoading } = + useHabcoinInventoryQuery({ + enabled: isPaymentSheetOpen, + }); + + const planPrice = Number(profile?.recommended_plan?.price) || 50; + const finalPrice = appliedDiscount?.valid + ? appliedDiscount.discountedPrice + : planPrice; + const coinBalance = inventory?.coin_balance ?? 0; + const hasEnoughCoins = + !isInsufficientCoins && + (inventory === undefined ? true : coinBalance >= finalPrice); + + 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({ + objectId: recommendedPlanId, + discountCode: appliedDiscount?.valid ? appliedDiscount.code : undefined, + }); + setIsPaymentSheetOpen(false); + openProfile(); + } 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(() => { + console.log("🔍 [NewMatchClient] Current React Query Profile State:", { + isLoading, + isError, + hasProfile: Boolean(profile), + profileId: profile?.id, + status: profile?.status, + active_case: profile?.active_case, + hasMatchSummary: Boolean(profile?.match_summary), + matchSummaryId: profile?.match_summary?.id, + publicInfoCount: profile?.match_summary?.public_info?.length, + fullProfileObject: profile, + }); + + if (!profile) { + return; + } + const targetPath = getSubmitPath(profile); + if (targetPath !== "/new-match") { + router.replace(localizePath(targetPath, locale)); + } + }, [profile, locale, router, isLoading, isError]); + + // Signal Flutter to lift its loading cover once the profile is available. + useHabibWebReady(!!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, t); + + if (typeof window !== "undefined") { + if (!matchSummary && !isLoading && !isRedirecting) { + console.warn( + "⚠️ [NewMatchClient] match_summary is null on profile! 'No match summary is available yet.' will be displayed. Profile:", + profile, + ); + } else if (matchSummary) { + console.log( + "✅ [NewMatchClient] Rendering match summary card with:", + matchDisplay, + ); + } + } + + 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 && + profile.active_subscription.is_active !== false && + profile.active_subscription.is_valid !== false; + const isMatchAvailable = !!profile?.match_summary; + + return ( + <> + + +
+ + +
+
+ +

+ {matchHeadingTitle} +

+

+ {matchHeadingDescription} +

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

+ Unable to load match summary. +

+ ) : matchSummary ? ( + <> +

+ {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 && isInventoryLoading && ( + 0 ? `${14 + bottom}px` : undefined, + }} + onClose={() => { + setIsPaymentSheetOpen(false); + setPaymentError(null); + setIsInsufficientCoins(false); + }} + /> + )} + + {isPaymentSheetOpen && !isInventoryLoading && ( + 0 ? `${14 + bottom}px` : undefined, + }} + icon="coin" + title={ + !hasEnoughCoins ? ( + + {t["You do not have enough Habib Coins"] || + "You do not have enough Habib Coins"} + + ) : ( + t["Verification & Subscription Activation"] || + "Verification & Subscription Activation" + ) + } + description={ + !hasEnoughCoins ? ( +

+ {t[ + "Please add more Habib Coins to your balance (or use discount code), so that you can use them to access this content" + ] || + "Please add more Habib Coins to your balance (or use discount code), so that you can use them to access this content"} +

+ ) : ( +
+

+ {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"} + + + {finalPrice} {t["Habib Coins"] || "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."} +

+
+ ) + } + onClose={() => { + setIsPaymentSheetOpen(false); + setPaymentError(null); + setIsInsufficientCoins(false); + setAppliedDiscount(null); + }} + buttons={ +
+ {profile?.recommended_plan?.id ? ( + setAppliedDiscount(res)} + onDiscountCleared={() => setAppliedDiscount(null)} + /> + ) : null} + + {paymentError && ( +
+ {paymentError} +
+ )} + +
+ + + {!hasEnoughCoins ? ( + + ) : ( + + )} +
+
+ } + /> + )} + + + + + + ); +} 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/new-match/profile/page.tsx b/src/app/new-match/profile/page.tsx index 14e785a..5e1bc08 100644 --- a/src/app/new-match/profile/page.tsx +++ b/src/app/new-match/profile/page.tsx @@ -185,34 +185,40 @@ function MatchPublicProfileFields({ function NewMatchProfileSkeleton({ hideBackButton = false, + onClose, }: { hideBackButton?: boolean; + onClose?: () => void; }) { const { dictionary: t } = useI18n(); return ( <> -
- +
+
{!hideBackButton ? ( ) : (
)} -

- {t["New Match"]} +

+ {t["More detail"]}

-
+
@@ -246,14 +252,12 @@ function NewMatchProfileSkeleton({
-
-
- - -
+
+ +
@@ -276,7 +280,13 @@ function formatBoldText(text: string) { }); } -export default function NewMatchProfilePage() { +type NewMatchProfilePageProps = { + onClose?: () => void; +}; + +export default function NewMatchProfilePage({ + onClose, +}: NewMatchProfilePageProps = {}) { const { dictionary: t, locale } = useI18n(); const router = useRouter(); const [isRequestSheetOpen, setIsRequestSheetOpen] = useState(false); @@ -371,7 +381,12 @@ export default function NewMatchProfilePage() { const isSubmitting = respondMutation.isPending; if (isLoading || !profile || isRedirecting || isSubmitting) { - return ; + return ( + + ); } const isAcceptProfileEnabled = Boolean(caseId) && @@ -383,7 +398,7 @@ export default function NewMatchProfilePage() { const _firstName = nameParts[0] || ""; const _lastName = nameParts.slice(1).join(" ") || ""; - const mainClass = "-mx-[17px] flex min-h-screen flex-col pb-10"; + const mainClass = "-mx-[17px] flex h-dvh flex-col overflow-hidden"; const mainStyle = { backgroundImage: `url("/assets/images/islamic_pattern_2_2892_3864.svg")`, @@ -583,12 +598,13 @@ export default function NewMatchProfilePage() { ) : null}
- +

-
+
-
- {caseStatus === "payment_done" || - caseStatus === "contacted" || - caseStatus === "finalized" || - profile?.status === "matched" ? ( + {caseStatus === "payment_done" || + caseStatus === "contacted" || + caseStatus === "finalized" || + profile?.status === "matched" ? ( + + ) : ( +
- ) : ( -
- - -
- )} -
+ setIsRequestSheetOpen(true); + }} + className="inline-flex w-2/3 h-[52px] whitespace-nowrap items-center justify-center gap-1 rounded-[12px] bg-[#F0445B] px-4 text-[16px] font-semibold text-white shadow-[0_8px_16px_rgba(240,68,91,0.24)] disabled:cursor-not-allowed disabled:opacity-50" + > + {isSubmitting ? ( + + ) : ( + <> + + {t["Accept Profile"]} + + )} + +
+ )}

diff --git a/src/app/page.tsx b/src/app/page.tsx index 894b332..aae23df 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,8 +2,14 @@ import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; import { getAuthenticatedCachedEntryPath, + 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, @@ -36,14 +42,28 @@ 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)); + } + + const profile = await fetchProfileSSR(token); + if (profile) { + const targetPath = hasCompletedMarriageProfileBasics(profile) + ? getSubmitPath(profile) + : "/intro"; + redirect(localizePath(targetPath, targetLocale)); + } + + redirect(`/${targetLocale}`); } diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 72e5bcb..689d5b0 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -5,7 +5,9 @@ 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"; import { ViewPaddingsProvider } from "@/components/Componentes/view-paddings-provider"; @@ -42,9 +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 0390e6b..0584a18 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -1,7 +1,9 @@ "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 { 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"; @@ -46,6 +48,12 @@ import { isQuestionVisible, isQuestionRequired } from "@/lib/conditional-rules"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; 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; @@ -56,6 +64,7 @@ type QuestionDetailClientProps = { locale?: Locale; questionsListHref: string; title: string; + onClose?: () => void; }; type StoredQuestionField = { @@ -69,12 +78,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({ @@ -82,11 +93,13 @@ function QuestionFlowWrapper({ itemSlug, continueLabel, questionsListHref, + onExit, }: { questions: QuestionField[]; itemSlug: string; continueLabel: string; questionsListHref: string; + onExit?: () => void; }) { const { getAnswerValue, answers } = useQuestionAnswers(); const { data: profile } = useMarriageProfileQuery(); @@ -132,6 +145,7 @@ function QuestionFlowWrapper({ total={requiredCount} continueLabel={continueLabel} exitHref={questionsListHref} + onExit={onExit} optionalQuestionIndexes={dynamicQuestions.flatMap((question, index) => question.required ? [] : [index], )} @@ -186,34 +200,48 @@ export default function QuestionDetailClient({ locale = defaultLocale, questionsListHref, title, + onClose, }: QuestionDetailClientProps) { const router = useRouter(); const { dictionary: t } = useI18n(); const [isTestStarted, setIsTestStarted] = useState(false); const [hasTestProgress, setHasTestProgress] = useState(false); 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 () => { + handleExit(); + return true; // handled — keep WebView open + }, [handleExit]); + + 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"; @@ -349,9 +377,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) { @@ -371,7 +399,7 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} - onClick={() => router.push(questionsListHref)} + onClick={handleExit} />

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

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

{errorTitle} @@ -570,10 +598,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 +613,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 +632,7 @@ export default function QuestionDetailClient({ informationLabel={informationLabel} onClose={() => setIsTestStarted(false)} onFinish={handleTestFinish} - draftStorageKey={getTestDraftStorageKey(item.slug)} + draftStorageKey={getTestDraftStorageKey(item.slug, profileId)} /> ); } @@ -644,6 +678,7 @@ export default function QuestionDetailClient({ variant="transparent" icon="close" iconLabel={closeLabel} + onClick={handleExit} />

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

{item.title} @@ -834,6 +870,7 @@ export default function QuestionDetailClient({ itemSlug={item.slug} continueLabel={continueLabel} questionsListHref={questionsListHref} + onExit={handleExit} />

diff --git a/src/app/questions-list/page.tsx b/src/app/questions-list/page.tsx index b8c6509..b1bf27d 100644 --- a/src/app/questions-list/page.tsx +++ b/src/app/questions-list/page.tsx @@ -1,789 +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 [isTermsOpen, setIsTermsOpen] = 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), - version: overview.version, - }); - 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"]} -

- setIsTermsOpen(true)} - disableHelpModal - /> -
- setIsTermsOpen(false)} - /> - -
- - - {/* 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"]} -

- setIsTermsOpen(true)} - disableHelpModal - /> -
- setIsTermsOpen(false)} - /> - - { - 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} - setIsTermsOpen(false)} - /> - - -
-
-
- -
- { - 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"]} -

- setIsTermsOpen(true)} - disableHelpModal - /> -
- -
-
- -
- -
- {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..b838db4 --- /dev/null +++ b/src/app/questions-list/questions-list-client.tsx @@ -0,0 +1,899 @@ +"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 { 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"; +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 { getCattellQuestions } from "@/hooks/marriage/use-cattell"; +import { getGlasserQuestions } from "@/hooks/marriage/use-glasser"; +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 { 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, +} 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 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. + // 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 (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" }), + ); + } + return false; // Tell Flutter to close the WebView screen + }); + 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). + useHabibWebReady(!!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 [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()); + + useEffect(() => { + const next = new Map(); + const profileId = profile?.id; + for (const slug of ["personality_test", "glasser_5_needs_test"]) { + try { + 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 { + // Ignore malformed local drafts. + } + } + setLocalAssessmentProgress(next); + }, [overview, profile?.id]); + + 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 || !profile?.id || profile?.can_edit_profile === false) return; + if (syncPromiseRef.current) { + return syncPromiseRef.current; + } + + const profileId = profile.id; + + const task = (async () => { + const pendingSections: Array<{ + slug: string; + fields: MarriageField[]; + }> = []; + + for (const item of questionListItems) { + if ( + item.slug === "personality_test" || + item.slug === "glasser_5_needs_test" + ) { + 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 }); + } + } + } + + if (pendingSections.length === 0) return; + + 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); + } + })(); + + syncPromiseRef.current = task; + try { + await task; + setIsSyncError(false); + } finally { + syncPromiseRef.current = null; + } + }, [ + 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, router], + ); + + 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; + + // ── 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( + remaining, + (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(startRemainingPrefetch, { timeout: 3000 }) + : setTimeout(startRemainingPrefetch, 200); + return () => { + cancelled = true; + if (typeof cancelIdleCallback === "function" && typeof idle === "number") { + cancelIdleCallback(idle); + } + }; + }, [locale, overview, queryClient, questionListItems, router, 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} + + {process.env.NODE_ENV === "development" ? : 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)} + onPrefetch={prefetchSection} + onNearViewport={prefetchSection} + onSelect={(item) => handleOpenSection(item.slug)} + /> + ))} +
+
+ + + + +
+ + + {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/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..45684de --- /dev/null +++ b/src/app/request-accepted/request-accepted-client.tsx @@ -0,0 +1,773 @@ +"use client"; + +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 MarriageAdvisorsOverlay, { + useMarriageAdvisorsOverlay, +} from "@/components/Componentes/marriage-advisors-overlay"; +import MatchProfileOverlay, { + useMatchProfileOverlay, +} from "@/components/Componentes/match-profile-overlay"; +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 { isAdvisorOpen, openAdvisors, closeAdvisors } = + useMarriageAdvisorsOverlay(); + const { isProfileOpen, openProfile, closeProfile } = + useMatchProfileOverlay(); + 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. + useHabibWebReady(!!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..0396236 --- /dev/null +++ b/src/app/request-sent/request-sent-client.tsx @@ -0,0 +1,165 @@ +"use client"; + +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 MarriageAdvisorsOverlay, { + useMarriageAdvisorsOverlay, +} from "@/components/Componentes/marriage-advisors-overlay"; +import MatchProfileOverlay, { + useMatchProfileOverlay, +} from "@/components/Componentes/match-profile-overlay"; +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 { isAdvisorOpen, openAdvisors, closeAdvisors } = + useMarriageAdvisorsOverlay(); + const { isProfileOpen, openProfile, closeProfile } = + useMatchProfileOverlay(); + 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. + useHabibWebReady(!!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} +

+ + +
+ +
+ + +
+
+
+ lock +

+ {requestSentCopy.profileLocked} +

+
+
+
+
+
+
+ + + + + + ); +} 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/advisor-actions-card.tsx b/src/components/Componentes/advisor-actions-card.tsx index 7cb7585..f1722a6 100644 --- a/src/components/Componentes/advisor-actions-card.tsx +++ b/src/components/Componentes/advisor-actions-card.tsx @@ -18,7 +18,8 @@ type AdvisorActionsCardProps = { avatars: AdvisorAvatar[]; extraCount: number; getAdvisorLabel: string; - getAdvisorHref: string; + getAdvisorHref?: string; + onGetAdvisor?: () => void; className?: string; }; @@ -29,6 +30,7 @@ export function AdvisorActionsCard({ extraCount: fallbackExtraCount, getAdvisorLabel, getAdvisorHref, + onGetAdvisor, className, }: AdvisorActionsCardProps) { const { data, isLoading } = useMarriageAdvisorsQuery(); @@ -96,7 +98,8 @@ export function AdvisorActionsCard({ 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/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/data-error-state.tsx b/src/components/Componentes/data-error-state.tsx index dc217e5..1d0749e 100644 --- a/src/components/Componentes/data-error-state.tsx +++ b/src/components/Componentes/data-error-state.tsx @@ -11,6 +11,8 @@ type DataErrorStateProps = { onRetry?: () => void; /** Optional className for the outermost wrapper */ className?: string; + /** Render full-screen fixed overlay covering any headers/footers (default: true) */ + fullScreen?: boolean; }; /** @@ -30,6 +32,7 @@ export default function DataErrorState({ message, onRetry, className = "", + fullScreen = true, }: DataErrorStateProps) { const { dictionary: t } = useI18n(); @@ -43,20 +46,22 @@ export default function DataErrorState({ "Something went wrong. Please check your internet connection and try again."; const retryLabel = t["Try again"] ?? "Try again"; + const containerClasses = fullScreen + ? `fixed inset-0 z-[9999] flex h-[100dvh] w-full flex-col items-center justify-center bg-[#F5F5F5] px-6 text-center ${className}` + : `flex min-h-[60svh] w-full flex-col items-center justify-center gap-0 px-6 text-center ${className}`; + return ( -
+
{/* ── Disconnected-plug illustration (inline SVG — no network request) ── */} - {/* ── Title ── */} -

+ {/* ── Title (matching Flutter fontSize: 22, normal weight, #1B1B1B) ── */} +

{displayTitle}

- {/* ── Subtitle / message ── */} -

+ {/* ── Subtitle / message (matching Flutter fontSize: 14, fontWeight: 600, #8B8B8B) ── */} +

{displayMessage}

@@ -65,11 +70,11 @@ export default function DataErrorState({ )}
diff --git a/src/components/Componentes/dev-tap-instrumentation.tsx b/src/components/Componentes/dev-tap-instrumentation.tsx new file mode 100644 index 0000000..6bcbca2 --- /dev/null +++ b/src/components/Componentes/dev-tap-instrumentation.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Development-only capture-phase instrumentation for debugging section-card + * and question flow tap/focus responsiveness. Records pointerdown → pointerup → click, + * touchstart → touchmove → touchend, and focusin/focusout lifecycle events. + * + * Mount this inside the questions-list and question detail flows during development. + * Automatically inactive outside of development. + */ +export default function DevTapInstrumentation() { + useEffect(() => { + if (process.env.NODE_ENV !== "development") return; + + const events = [ + "pointerdown", + "pointerup", + "pointercancel", + "click", + "touchstart", + "touchmove", + "touchend", + "touchcancel", + "focusin", + "focusout", + ] as const; + + const startTime = performance.now(); + let sequenceId = 0; + let touchStartY: number | null = null; + + const handler = (event: Event) => { + const target = event.target as HTMLElement | null; + const anchor = target?.closest?.("a"); + const elapsed = (performance.now() - startTime).toFixed(1); + sequenceId += 1; + + let deltaY: number | null = null; + if (event.type === "touchstart") { + touchStartY = (event as TouchEvent).touches?.[0]?.clientY ?? null; + } else if (event.type === "touchmove" || event.type === "touchend") { + const currentY = + (event as TouchEvent).touches?.[0]?.clientY ?? + (event as TouchEvent).changedTouches?.[0]?.clientY ?? + null; + if (touchStartY !== null && currentY !== null) { + deltaY = Math.round(touchStartY - currentY); + } + } + + const isInteractive = Boolean( + target?.closest?.( + 'input, textarea, select, button, label, a, [role="button"], [role="option"], [role="checkbox"], [role="radio"], [role="switch"], [role="combobox"], [role="listbox"], [contenteditable="true"]', + ), + ); + + const color = event.type.startsWith("focus") + ? "#0EB13C" + : event.type.startsWith("touch") + ? "#3B82F6" + : "#E03950"; + + console.debug( + `[tap-debug #${sequenceId}] %c${event.type}%c @ ${elapsed}ms`, + `color: ${color}; font-weight: bold`, + "color: inherit", + { + type: event.type, + target: target?.tagName, + targetId: target?.id, + targetClass: target?.className, + activeElement: document.activeElement?.tagName, + activeElementId: document.activeElement?.id, + isInteractive, + deltaY, + closestAnchorHref: anchor?.getAttribute("href") || null, + defaultPrevented: event.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/discount-widget.tsx b/src/components/Componentes/discount-widget.tsx new file mode 100644 index 0000000..162c810 --- /dev/null +++ b/src/components/Componentes/discount-widget.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useState } from "react"; +import { useI18n } from "@/translations/provider"; +import { + type CheckDiscountResult, + useValidateDiscountMutation, +} from "@/hooks/marriage/use-validate-discount"; + +export interface DiscountWidgetProps { + objectId: number | string; + service?: string; + onDiscountApplied?: (result: CheckDiscountResult) => void; + onDiscountCleared?: () => void; + className?: string; +} + +export function DiscountWidget({ + objectId, + service = "marriagesubscriptionplan", + onDiscountApplied, + onDiscountCleared, + className = "", +}: DiscountWidgetProps) { + const { dictionary: t } = useI18n(); + const [isExpanded, setIsExpanded] = useState(false); + const [code, setCode] = useState(""); + const [discountStatus, setDiscountStatus] = useState< + "initial" | "empty" | "loading" | "success" | "failed" + >("initial"); + const [discountResult, setDiscountResult] = + useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + const validateMutation = useValidateDiscountMutation(); + + const handleApply = async () => { + const trimmed = code.trim(); + if (!trimmed) { + setDiscountStatus("empty"); + setErrorMessage(t["This field is required"] || "This field is required"); + return; + } + + setDiscountStatus("loading"); + setErrorMessage(null); + + try { + const result = await validateMutation.mutateAsync({ + code: trimmed, + objectId, + service, + }); + + if (result.valid) { + setDiscountStatus("success"); + setDiscountResult(result); + setErrorMessage(null); + onDiscountApplied?.(result); + } else { + setDiscountStatus("failed"); + setDiscountResult(null); + setErrorMessage(result.message || "Invalid discount code"); + } + } catch (err: any) { + setDiscountStatus("failed"); + setDiscountResult(null); + setErrorMessage( + err?.response?.data?.detail || err?.message || "Invalid discount code", + ); + } + }; + + const handleClear = () => { + setCode(""); + setDiscountStatus("initial"); + setDiscountResult(null); + setErrorMessage(null); + onDiscountCleared?.(); + }; + + const getBorderAndRing = () => { + switch (discountStatus) { + case "success": + return "border-[#00AC78] ring-1 ring-[#00AC78]"; + case "failed": + case "empty": + return "border-[#F0445B] ring-1 ring-[#F0445B]"; + default: + return "border-[#D0D5DD] hover:border-[#98A2B3] focus-within:border-[#F0445B] focus-within:ring-1 focus-within:ring-[#F0445B]"; + } + }; + + return ( +
+ {/* ── Collapsible Header ── */} + + + {/* ── Expanded Form Body ── */} + {isExpanded && ( +
+
+ { + setCode(e.target.value); + if (discountStatus !== "initial") { + setDiscountStatus("initial"); + setErrorMessage(null); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter" && discountResult === null) { + e.preventDefault(); + handleApply(); + } + }} + placeholder={ + t["Enter discount code..."] || "Enter discount code..." + } + className="h-full flex-1 bg-transparent text-[15px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3] disabled:opacity-70 min-w-0" + /> + + {discountStatus === "loading" ? ( +
+
+
+ ) : discountResult !== null ? ( + + ) : ( + + )} +
+ + {/* Status Message */} + {errorMessage && ( +

+ {errorMessage} +

+ )} + + {discountStatus === "success" && discountResult?.message && ( +

+ {discountResult.message} +

+ )} +
+ )} +
+ ); +} diff --git a/src/components/Componentes/entry-route-resolver.tsx b/src/components/Componentes/entry-route-resolver.tsx index dd95254..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) { 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/information-sheet.tsx b/src/components/Componentes/information-sheet.tsx index 7f9ba91..6d7c5a4 100644 --- a/src/components/Componentes/information-sheet.tsx +++ b/src/components/Componentes/information-sheet.tsx @@ -5,6 +5,7 @@ import type { HTMLAttributes, ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import Button from "./button"; import { LoadingSkeleton } from "./loading-skeleton"; +import { LoadingThreeDot } from "./loading-three-dot"; import { useI18n } from "@/translations/provider"; const EXIT_ANIMATION_MS = 220; @@ -75,14 +76,14 @@ const ICON_PRESETS: Record< coin: { src: "/assets/images/Inner Plugdsain Iframe.svg", alt: "Coin", - width: 50, - height: 50, + width: 56, + height: 56, }, "coin.svg": { src: "/assets/images/Inner Plugdsain Iframe.svg", alt: "Coin", - width: 50, - height: 50, + width: 56, + height: 56, }, check: { src: "/assets/images/Vectofdasr.svg", @@ -205,10 +206,8 @@ export function InformationSheet({ return (
{isLoading ? ( - <> - - -
- - -
-
- -
- +
+ +
) : ( <> {resolvedIcon ? ( diff --git a/src/components/Componentes/marriage-advisors-overlay.test.tsx b/src/components/Componentes/marriage-advisors-overlay.test.tsx new file mode 100644 index 0000000..4fa1659 --- /dev/null +++ b/src/components/Componentes/marriage-advisors-overlay.test.tsx @@ -0,0 +1,134 @@ +import { act, cleanup, render, renderHook, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { I18nProvider } from "@/translations/provider"; +import { + MarriageAdvisorsOverlay, + useMarriageAdvisorsOverlay, +} from "./marriage-advisors-overlay"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + back: vi.fn(), + }), +})); + +vi.mock("@/hooks/marriage/use-marriage-advisors", () => ({ + useMarriageAdvisorsQuery: () => ({ + data: { results: [] }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), +})); + +vi.mock("@/hooks/marriage/use-profile-main", () => ({ + useMarriageProfileQuery: () => ({ + data: null, + isLoading: false, + refetch: vi.fn(), + }), +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + }; +} + +describe("MarriageAdvisorsOverlay & useMarriageAdvisorsOverlay", () => { + 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("hook manages open and close state with history sync", () => { + const { result } = renderHook(() => useMarriageAdvisorsOverlay()); + + expect(result.current.isAdvisorOpen).toBe(false); + + act(() => { + result.current.openAdvisors(); + }); + + expect(result.current.isAdvisorOpen).toBe(true); + + act(() => { + result.current.closeAdvisors(); + }); + + expect(result.current.isAdvisorOpen).toBe(false); + }); + + it("renders overlay dialog and advisors page when open is true", () => { + const handleClose = vi.fn(); + const wrapper = createWrapper(); + + render( + , + { wrapper }, + ); + + act(() => { + vi.advanceTimersByTime(16); + }); + + const overlay = screen.getByRole("dialog"); + expect(overlay).toBeInTheDocument(); + expect(overlay).toHaveClass("section-overlay"); + expect(document.body.classList.contains("section-overlay-open")).toBe(true); + }); + + it("handles closing animation and removes body class when open becomes false", () => { + const handleClose = vi.fn(); + const wrapper = createWrapper(); + + const { rerender } = render( + , + { wrapper }, + ); + + act(() => { + vi.advanceTimersByTime(16); + }); + + expect(screen.getByRole("dialog")).toHaveAttribute("data-state", "open"); + expect(document.body.classList.contains("section-overlay-open")).toBe(true); + + rerender(); + + expect(screen.getByRole("dialog")).toHaveAttribute("data-state", "closing"); + expect(document.body.classList.contains("section-overlay-open")).toBe(false); + + act(() => { + vi.advanceTimersByTime(210); + }); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Componentes/marriage-advisors-overlay.tsx b/src/components/Componentes/marriage-advisors-overlay.tsx new file mode 100644 index 0000000..beb93e5 --- /dev/null +++ b/src/components/Componentes/marriage-advisors-overlay.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import MarriageAdvisorsPage from "@/app/marriage-advisors/page"; +import SectionOverlayHost from "@/components/Componentes/section-overlay-host"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; + +export type MarriageAdvisorsOverlayProps = { + open: boolean; + onClose: () => void; +}; + +/** + * Hook to manage Marriage Advisors slide-in overlay state. + * Syncs with browser history (?advisors=open) and intercepts hardware back in Flutter. + */ +export function useMarriageAdvisorsOverlay() { + const [isAdvisorOpen, setIsAdvisorOpen] = useState(false); + + useEffect(() => { + const readAdvisorsFromUrl = () => { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + setIsAdvisorOpen(params.get("advisors") === "open"); + }; + + readAdvisorsFromUrl(); + window.addEventListener("popstate", readAdvisorsFromUrl); + return () => window.removeEventListener("popstate", readAdvisorsFromUrl); + }, []); + + const openAdvisors = useCallback(() => { + setIsAdvisorOpen(true); + if (typeof window !== "undefined") { + const url = new URL(window.location.href); + url.searchParams.set("advisors", "open"); + window.history.pushState({ advisors: "open" }, "", url.toString()); + } + }, []); + + const closeAdvisors = useCallback(() => { + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + if (params.get("advisors") === "open") { + setIsAdvisorOpen(false); + window.history.back(); + return; + } + } + setIsAdvisorOpen(false); + }, []); + + // Intercept hardware back in Flutter WebView when advisor overlay is open + useHardwareBackHandler(() => { + closeAdvisors(); + return true; // handled: keep WebView screen open + }, isAdvisorOpen); + + return { + isAdvisorOpen, + openAdvisors, + closeAdvisors, + }; +} + +export function MarriageAdvisorsOverlay({ + open, + onClose, +}: MarriageAdvisorsOverlayProps) { + return ( + + + + ); +} + +export default MarriageAdvisorsOverlay; diff --git a/src/components/Componentes/match-profile-overlay.test.tsx b/src/components/Componentes/match-profile-overlay.test.tsx new file mode 100644 index 0000000..a7f92a9 --- /dev/null +++ b/src/components/Componentes/match-profile-overlay.test.tsx @@ -0,0 +1,140 @@ +import { act, cleanup, render, renderHook, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { I18nProvider } from "@/translations/provider"; +import { + MatchProfileOverlay, + useMatchProfileOverlay, +} from "./match-profile-overlay"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + back: vi.fn(), + }), +})); + +vi.mock("@/hooks/marriage/use-profile-main", () => ({ + useMarriageProfileQuery: () => ({ + data: { + status: "matched", + gender: "female", + active_case: { case_id: "test-case-1", status: "introduced" }, + match_summary: { + gender: "male", + public_info: [{ key: "full_name", label: "نام", value: "علی احمدی" }], + }, + }, + isLoading: false, + refetch: vi.fn(), + }), +})); + +vi.mock("@/hooks/marriage/use-case-respond", () => ({ + useRespondToMarriageCaseMutation: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + }; +} + +describe("MatchProfileOverlay & useMatchProfileOverlay", () => { + 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("hook manages open and close state with history sync", () => { + const { result } = renderHook(() => useMatchProfileOverlay()); + + expect(result.current.isProfileOpen).toBe(false); + + act(() => { + result.current.openProfile(); + }); + + expect(result.current.isProfileOpen).toBe(true); + + act(() => { + result.current.closeProfile(); + }); + + expect(result.current.isProfileOpen).toBe(false); + }); + + it("renders overlay dialog and profile page when open is true", () => { + const handleClose = vi.fn(); + const wrapper = createWrapper(); + + render( + , + { wrapper }, + ); + + act(() => { + vi.advanceTimersByTime(16); + }); + + const overlay = screen.getByRole("dialog"); + expect(overlay).toBeInTheDocument(); + expect(overlay).toHaveClass("section-overlay"); + expect(document.body.classList.contains("section-overlay-open")).toBe(true); + }); + + it("handles closing animation and removes body class when open becomes false", () => { + const handleClose = vi.fn(); + const wrapper = createWrapper(); + + const { rerender } = render( + , + { wrapper }, + ); + + act(() => { + vi.advanceTimersByTime(16); + }); + + expect(screen.getByRole("dialog")).toHaveAttribute("data-state", "open"); + expect(document.body.classList.contains("section-overlay-open")).toBe(true); + + rerender(); + + expect(screen.getByRole("dialog")).toHaveAttribute("data-state", "closing"); + expect(document.body.classList.contains("section-overlay-open")).toBe(false); + + act(() => { + vi.advanceTimersByTime(210); + }); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Componentes/match-profile-overlay.tsx b/src/components/Componentes/match-profile-overlay.tsx new file mode 100644 index 0000000..39c14cd --- /dev/null +++ b/src/components/Componentes/match-profile-overlay.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import NewMatchProfilePage from "@/app/new-match/profile/page"; +import SectionOverlayHost from "@/components/Componentes/section-overlay-host"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; + +export type MatchProfileOverlayProps = { + open: boolean; + onClose: () => void; +}; + +/** + * Hook to manage Match Profile ("More detail") slide-in overlay state. + * Syncs with browser history (?profile=open) and intercepts hardware back in Flutter. + */ +export function useMatchProfileOverlay() { + const [isProfileOpen, setIsProfileOpen] = useState(false); + + useEffect(() => { + const readProfileFromUrl = () => { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + setIsProfileOpen(params.get("profile") === "open"); + }; + + readProfileFromUrl(); + window.addEventListener("popstate", readProfileFromUrl); + return () => window.removeEventListener("popstate", readProfileFromUrl); + }, []); + + const openProfile = useCallback(() => { + setIsProfileOpen(true); + if (typeof window !== "undefined") { + const url = new URL(window.location.href); + url.searchParams.set("profile", "open"); + window.history.pushState({ profile: "open" }, "", url.toString()); + } + }, []); + + const closeProfile = useCallback(() => { + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + if (params.get("profile") === "open") { + setIsProfileOpen(false); + window.history.back(); + return; + } + } + setIsProfileOpen(false); + }, []); + + // Intercept hardware back in Flutter WebView when profile overlay is open + useHardwareBackHandler(() => { + closeProfile(); + return true; // handled: keep WebView screen open + }, isProfileOpen); + + return { + isProfileOpen, + openProfile, + closeProfile, + }; +} + +export function MatchProfileOverlay({ + open, + onClose, +}: MatchProfileOverlayProps) { + return ( + + + + ); +} + +export default MatchProfileOverlay; diff --git a/src/components/Componentes/navigation-button.tsx b/src/components/Componentes/navigation-button.tsx index 21065a8..ae4e5d7 100644 --- a/src/components/Componentes/navigation-button.tsx +++ b/src/components/Componentes/navigation-button.tsx @@ -1,24 +1,14 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; import Image from "next/image"; import { useRouter } from "next/navigation"; import type { ButtonHTMLAttributes, ReactNode } from "react"; import { useEffect, useRef, useState } from "react"; import { GoArrowLeft } from "react-icons/go"; import { HiEllipsisVertical } from "react-icons/hi2"; -import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; -import { - extractHabcoinPaymentUrl, - useHabcoinPaymentMutation, -} from "@/hooks/marriage/use-habcoin-payment"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; -import { - buyHabibCoinPackages, - isInFlutterWebView, -} from "@/lib/webview-actions"; +import { isInFlutterWebView } from "@/lib/webview-actions"; import { useI18n } from "@/translations/provider"; -import ErrorToast from "./error-toast"; import HelpModal from "./help-modal"; import InformationSheet from "./information-sheet"; import { hasSupportAccess } from "./support-access"; @@ -47,6 +37,8 @@ export type NavigationButtonProps = Omit< helpDescription?: ReactNode; helpButtonText?: ReactNode; disableHelpModal?: boolean; + badge?: ReactNode; + profile?: any; }; export function NavigationButton({ @@ -59,29 +51,52 @@ export function NavigationButton({ helpDescription, helpButtonText, disableHelpModal = false, + badge, + profile: profileProp, ...props }: NavigationButtonProps) { const router = useRouter(); - const { dictionary: t } = useI18n(); + const { dictionary: t, locale } = useI18n(); + const isRtl = locale === "fa" || locale === "ar" || locale === "ur"; const [isHelpOpen, setIsHelpOpen] = useState(false); const [isSupportOpen, setIsSupportOpen] = useState(false); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isSubscriptionInfoOpen, setIsSubscriptionInfoOpen] = useState(false); const [isSubscriptionLoading, setIsSubscriptionLoading] = useState(false); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); const dropdownRef = useRef(null); - const { data: profile, refetch } = useMarriageProfileQuery(); - const paymentMutation = useHabcoinPaymentMutation(); - const queryClient = useQueryClient(); - const [toastMessage, setToastMessage] = useState(null); - const [toastVariant, setToastVariant] = useState<"error" | "success">( - "error", - ); - const [isRenewing, setIsRenewing] = useState(false); - const [isInsufficientCoins, setIsInsufficientCoins] = useState(false); + + const needsProfile = + !profileProp && + (icon === "support" || + icon === "subscription" || + icon === "more" || + icon === "consultation"); + const { data: queriedProfile, refetch: refetchQuery } = + useMarriageProfileQuery({ + enabled: needsProfile, + }); + const profile = profileProp !== undefined ? profileProp : queriedProfile; + const refetch = refetchQuery; + + const isFemale = profile?.gender === "female"; + const hasActiveSubscription = + !!profile?.active_subscription && + profile.active_subscription.is_active !== false && + profile.active_subscription.is_valid !== false; + + const handleOpenSubscriptionModal = () => { + setIsSubscriptionInfoOpen(true); + if (!profile) { + setIsSubscriptionLoading(true); + refetch().finally(() => { + setIsSubscriptionLoading(false); + }); + } + }; const renderSubscriptionModal = () => { if (!isSubscriptionInfoOpen) return null; - if (isSubscriptionLoading) { + if (isSubscriptionLoading && !profile) { return ( ); } - return hasActiveSubscription ? ( - <> - setIsSubscriptionInfoOpen(false)} - buttons={({ close }) => ( -
- {isInsufficientCoins && isInFlutterWebView() && ( - - )} -
- + const sub = profile?.active_subscription; + const planType = sub?.plan?.plan_type || "time_based"; + const remainingDays = sub?.remaining_days ?? 0; + const usageLimit = sub?.plan?.usage_limit ?? 0; + const totalUsages = sub?.total_usages ?? 0; + const remainingUsages = Math.max(0, usageLimit - totalUsages); - -
-
- )} - /> - + return hasActiveSubscription ? ( + setIsSubscriptionInfoOpen(false)} + /> ) : ( setIsSubscriptionInfoOpen(false)} /> ); }; - const isFemale = profile?.gender === "female"; - const hasActiveSubscription = !!profile?.active_subscription; - useEffect(() => { if (!isDropdownOpen) return; const handleOutsideClick = (event: MouseEvent) => { @@ -279,9 +204,10 @@ export function NavigationButton({ src="/assets/images/diamond-color.png" alt="" aria-hidden="true" - className="size-6" + className="size-6 object-contain" width={24} height={24} + unoptimized /> ) : (
); } @@ -433,11 +347,7 @@ export function NavigationButton({ } else if (icon === "support") { setIsSupportOpen(true); } else if (icon === "subscription") { - setIsSubscriptionInfoOpen(true); - setIsSubscriptionLoading(true); - refetch().finally(() => { - setIsSubscriptionLoading(false); - }); + handleOpenSubscriptionModal(); } } } @@ -469,14 +379,6 @@ export function NavigationButton({ /> {renderSubscriptionModal()} - - {toastMessage && ( - setToastMessage(null)} - /> - )} ); } diff --git a/src/components/Componentes/page-header.tsx b/src/components/Componentes/page-header.tsx index 0585a59..6d9603a 100644 --- a/src/components/Componentes/page-header.tsx +++ b/src/components/Componentes/page-header.tsx @@ -14,29 +14,45 @@ 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; + profile?: any; }; export function PageHeader({ className, leftButton, rightButton, + enableProfileQuery = true, + profile: profileProp, }: 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 isDefaultOrSubscription = - iconFromProp === undefined || iconFromProp === "subscription"; + const isCustomRightButton = + !!rightButton?.onClick || + (iconFromProp !== undefined && + iconFromProp !== "subscription" && + iconFromProp !== "support"); - let finalIcon: NavigationButtonProps["icon"] = iconFromProp || "support"; - if (isDefaultOrSubscription) { - if (hasSupportAccess(profile)) { - finalIcon = "support"; - } else if (profile?.gender === "male") { - finalIcon = "subscription"; - } - } + const needsProfile = + enableProfileQuery && !rightButton?.onClick && !profileProp; + const { data: queriedProfile } = useMarriageProfileQuery({ + enabled: needsProfile, + }); + + const profile = profileProp !== undefined ? profileProp : queriedProfile; + const isMale = profile?.gender === "male"; const { icon: rightButtonIcon, ...rightButtonRest } = rightButton || {}; return ( @@ -48,18 +64,44 @@ export function PageHeader({ {leftButton?.className?.includes("hidden") ? (
) : ( - + )}

{t["Habib Marriage"]}

- + + {isCustomRightButton ? ( + + ) : isMale ? ( +
+ + +
+ ) : ( + + )} ); } export default PageHeader; + diff --git a/src/components/Componentes/question-answer-storage.tsx b/src/components/Componentes/question-answer-storage.tsx index 1d2a3b5..ca31df0 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; @@ -318,7 +325,6 @@ export function QuestionAnswersProvider({ const storageKeyRef = useRef(storageKey); const slugRef = useRef(slug); const backendFieldsRef = useRef([]); - const versionRef = useRef(undefined); const dirtyKeysRef = useRef(new Set()); const { data: profile } = useMarriageProfileQuery(); @@ -333,67 +339,117 @@ export function QuestionAnswersProvider({ slugRef.current = slug; questionsRef.current = questions; backendFieldsRef.current = serverSectionData?.data || []; - versionRef.current = serverSectionData?.version; }, [ answers, hasPendingSync, slug, questions, serverSectionData?.data, - serverSectionData?.version, ]); useEffect(() => { 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); @@ -430,15 +486,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); @@ -493,7 +560,6 @@ export function QuestionAnswersProvider({ const payload = { ...fullPayload, fields: pendingFields, - version: versionRef.current, }; const revision = answersRevisionRef.current; @@ -537,10 +603,6 @@ export function QuestionAnswersProvider({ } } - payload.fields.forEach((field) => { - dirtyKeysRef.current.delete(field.key); - }); - // Do not mark a newer edit as synced just because an older request // completed. A forced exit waits for and saves that newer revision too. if (revision !== answersRevisionRef.current) { @@ -550,6 +612,10 @@ export function QuestionAnswersProvider({ return; } + payload.fields.forEach((field) => { + dirtyKeysRef.current.delete(field.key); + }); + if (dirtyKeysRef.current.size > 0) { await flushAnswersRef.current(); return; @@ -557,17 +623,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); @@ -596,7 +658,7 @@ export function QuestionAnswersProvider({ const pendingFields = fullPayload.fields.filter((field) => dirtyKeysRef.current.has(field.key), ); - const payload = { ...fullPayload, fields: pendingFields, version: versionRef.current }; + const payload = { ...fullPayload, fields: pendingFields }; const revision = answersRevisionRef.current; if (payload.fields.length === 0) { @@ -621,7 +683,6 @@ export function QuestionAnswersProvider({ fetch(getKeepalivePatchUrl(slugRef.current), { body: JSON.stringify({ answers: answersPayload, - version: payload.version, }), credentials: "include", headers, @@ -644,15 +705,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-birthplace.tsx b/src/components/Componentes/question-birthplace.tsx index 5c1b34f..7265702 100644 --- a/src/components/Componentes/question-birthplace.tsx +++ b/src/components/Componentes/question-birthplace.tsx @@ -9,6 +9,7 @@ import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; import { LoadingThreeDot } from "./loading-three-dot"; import { useSheetScrollLock } from "./use-sheet-scroll-lock"; +import { Input } from "@/components/ui/input"; const EXIT_ANIMATION_MS = 220; @@ -479,7 +480,7 @@ export function QuestionBirthplace({ {/* City Text Input */}
-
diff --git a/src/components/Componentes/question-card.tsx b/src/components/Componentes/question-card.tsx index 81000e4..5955276 100644 --- a/src/components/Componentes/question-card.tsx +++ b/src/components/Componentes/question-card.tsx @@ -16,6 +16,7 @@ type QuestionCardProps = { onInfoClick?: (item: QuestionListItem) => void; onNearViewport?: (item: QuestionListItem) => void; onPrefetch?: (item: QuestionListItem) => void; + onSelect?: (item: QuestionListItem) => void; }; const RADIUS = 8; @@ -46,6 +47,7 @@ export function QuestionCard({ onInfoClick, onNearViewport, onPrefetch, + onSelect, }: QuestionCardProps) { const { dictionary: t, locale } = useI18n(); const hasProgress = typeof progress === "number" && Number.isFinite(progress); @@ -82,11 +84,18 @@ export function QuestionCard({ return ( { + if (onSelect && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.button === 0) { + e.preventDefault(); + onSelect(item); + } + }} onFocus={() => onPrefetch?.(item)} - onPointerDown={() => onPrefetch?.(item)} onPointerEnter={() => onPrefetch?.(item)} + onPointerDown={() => onPrefetch?.(item)} >
= 1 && Number.parseInt(rawDay, 10) <= 31 + ? rawDay + : "01", ); - const [selectedDay, setSelectedDay] = useState(parts[2] || "01"); const dirtyRef = useRef(false); const closeSheet = useCallback(() => { @@ -338,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(); + }); }); 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 = () => { diff --git a/src/components/Componentes/question-dropdown.tsx b/src/components/Componentes/question-dropdown.tsx index 7033bb6..a090d14 100644 --- a/src/components/Componentes/question-dropdown.tsx +++ b/src/components/Componentes/question-dropdown.tsx @@ -222,6 +222,16 @@ export function QuestionDropdown({ setSearchQuery(e.target.value)} placeholder="Search..." diff --git a/src/components/Componentes/question-exit-navigation-button.tsx b/src/components/Componentes/question-exit-navigation-button.tsx index 084b462..be99a3b 100644 --- a/src/components/Componentes/question-exit-navigation-button.tsx +++ b/src/components/Componentes/question-exit-navigation-button.tsx @@ -12,10 +12,12 @@ import { markFirstEntryCompleted } from "@/lib/first-entry-helper"; export type QuestionExitNavigationButtonProps = NavigationButtonProps & { exitHref?: string; + onExit?: () => 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.push(target); + if (onExit) { + onExit(); + } else { + const target = localizePath(exitHref || "/questions-list", locale); + router.replace(target); + } } }} /> diff --git a/src/components/Componentes/question-number.tsx b/src/components/Componentes/question-number.tsx index 7270c84..597a342 100644 --- a/src/components/Componentes/question-number.tsx +++ b/src/components/Componentes/question-number.tsx @@ -5,6 +5,7 @@ import type { QuestionField } from "@/lib/schema-adapter"; import { useI18n } from "@/translations/provider"; import { useQuestionAnswers } from "./question-answer-storage"; import QuestionTitle from "./question-title"; +import { Input } from "@/components/ui/input"; type QuestionNumberProps = { question: QuestionField; @@ -186,7 +187,7 @@ export default function QuestionNumber({
- setCurrencySearchQuery(e.target.value)} placeholder={locale === "fa" ? "جستجو..." : "Search..."} @@ -376,7 +387,7 @@ export default function QuestionNumber({ ].join(" ")} > -
-
-

+ {/* 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 ? ( @@ -816,63 +870,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} +
)}
diff --git a/src/components/Componentes/question-section-flow.tsx b/src/components/Componentes/question-section-flow.tsx index bb58def..69b5aa2 100644 --- a/src/components/Componentes/question-section-flow.tsx +++ b/src/components/Componentes/question-section-flow.tsx @@ -17,11 +17,13 @@ import AnswerPaceSheet from "@/app/questions-list/[slug]/answer-pace-sheet"; import { FixToTheEnd } from "./fix-to-the-end"; import Button from "./button"; import { markFirstEntryCompleted } from "@/lib/first-entry-helper"; +import DevTapInstrumentation from "./dev-tap-instrumentation"; type QuestionSectionFlowProps = { children: ReactNode; continueLabel: string; exitHref: string; + onExit?: () => void; total: number; optionalQuestionIndexes: readonly number[]; questions?: readonly QuestionField[]; @@ -31,12 +33,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 +67,14 @@ function SectionFlowContent({ } catch { // ignore } finally { - const target = localizePath(exitHref || "/questions-list", locale); - router.push(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) => { @@ -126,6 +134,9 @@ function SectionFlowContent({ {children} + {process.env.NODE_ENV === "development" ? ( + + ) : null}