116 changed files with 9001 additions and 4173 deletions
-
87.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md
-
BINpublic/fonts/Amiri/Amiri-Bold.ttf
-
BINpublic/fonts/Amiri/Amiri-BoldItalic.ttf
-
BINpublic/fonts/Amiri/Amiri-Italic.ttf
-
BINpublic/fonts/Amiri/Amiri-Regular.ttf
-
93public/fonts/Amiri/OFL.txt
-
44src/app/[lang]/page.tsx
-
1src/app/[lang]/questions-list/[slug]/loading.tsx
-
411src/app/candidate-contact/candidate-contact-client.tsx
-
405src/app/candidate-contact/page.tsx
-
191src/app/finding-match/finding-match-client.tsx
-
205src/app/finding-match/page.tsx
-
94src/app/globals.css
-
197src/app/intro/intro-client.tsx
-
222src/app/intro/page.tsx
-
104src/app/layout.tsx
-
13src/app/marriage-advisors/page.tsx
-
916src/app/new-match/new-match-client.tsx
-
689src/app/new-match/page.tsx
-
162src/app/new-match/profile/page.tsx
-
30src/app/page.tsx
-
11src/app/providers.tsx
-
21src/app/questions-list/[slug]/loading.tsx
-
109src/app/questions-list/[slug]/question-detail-client.tsx
-
818src/app/questions-list/page.tsx
-
899src/app/questions-list/questions-list-client.tsx
-
87src/app/questions-list/section-prefetch-race.test.ts
-
774src/app/request-accepted/page.tsx
-
773src/app/request-accepted/request-accepted-client.tsx
-
165src/app/request-sent/page.tsx
-
165src/app/request-sent/request-sent-client.tsx
-
5src/app/terms/page.tsx
-
7src/components/Componentes/advisor-actions-card.tsx
-
77src/components/Componentes/auth-data-boundary.test.tsx
-
99src/components/Componentes/auth-data-boundary.tsx
-
25src/components/Componentes/data-error-state.tsx
-
98src/components/Componentes/dev-tap-instrumentation.tsx
-
218src/components/Componentes/discount-widget.tsx
-
11src/components/Componentes/entry-route-resolver.tsx
-
28src/components/Componentes/hardware-back-bridge.tsx
-
31src/components/Componentes/information-sheet.tsx
-
134src/components/Componentes/marriage-advisors-overlay.test.tsx
-
77src/components/Componentes/marriage-advisors-overlay.tsx
-
140src/components/Componentes/match-profile-overlay.test.tsx
-
77src/components/Componentes/match-profile-overlay.tsx
-
260src/components/Componentes/navigation-button.tsx
-
76src/components/Componentes/page-header.tsx
-
232src/components/Componentes/question-answer-storage.tsx
-
4src/components/Componentes/question-birthplace.tsx
-
11src/components/Componentes/question-card.tsx
-
35src/components/Componentes/question-date-sheet.tsx
-
15src/components/Componentes/question-date.test.tsx
-
66src/components/Componentes/question-date.tsx
-
10src/components/Componentes/question-dropdown.tsx
-
10src/components/Componentes/question-exit-navigation-button.tsx
-
15src/components/Componentes/question-number.tsx
-
161src/components/Componentes/question-phone.tsx
-
19src/components/Componentes/question-section-flow.tsx
-
10src/components/Componentes/question-sheet.tsx
-
260src/components/Componentes/question-snap-list.test.tsx
-
357src/components/Componentes/question-snap-list.tsx
-
57src/components/Componentes/question-text.tsx
-
12src/components/Componentes/question-textarea.tsx
-
116src/components/Componentes/section-overlay-host.test.tsx
-
148src/components/Componentes/section-overlay-host.tsx
-
1src/components/Componentes/slider-page.test.tsx
-
4src/components/Componentes/sticky-header.tsx
-
198src/components/Componentes/subscription-required-sheet.tsx
-
19src/components/Componentes/support-access.ts
-
22src/components/Componentes/test-questions-flow.tsx
-
806src/components/Componentes/token-switcher.tsx
-
15src/components/Componentes/use-sheet-scroll-lock.ts
-
2src/components/ui/index.ts
-
37src/components/ui/input.tsx
-
36src/components/ui/textarea.tsx
-
1src/hooks/marriage/query-keys.ts
-
27src/hooks/marriage/use-habcoin-inventory.ts
-
33src/hooks/marriage/use-habcoin-payment.ts
-
3src/hooks/marriage/use-marriage-config.ts
-
36src/hooks/marriage/use-profile-main.ts
-
78src/hooks/marriage/use-validate-discount.ts
-
12src/hooks/use-current-profile-id.ts
-
25src/hooks/use-habib-web-ready.ts
-
84src/hooks/use-hardware-back-handler.ts
-
51src/hooks/useFlutterBridge.ts
-
89src/lib/ssr-fetch.ts
-
163src/lib/user-scoped-storage.test.ts
-
285src/lib/user-scoped-storage.ts
-
26src/lib/webview-actions.test.ts
-
14src/lib/webview-actions.ts
-
20src/translations/locales/ar.json
-
20src/translations/locales/az.json
-
20src/translations/locales/bn.json
-
20src/translations/locales/da.json
-
20src/translations/locales/de.json
-
19src/translations/locales/en.json
-
20src/translations/locales/es.json
-
19src/translations/locales/fa.json
-
20src/translations/locales/fr.json
-
20src/translations/locales/gu.json
@ -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` → صفحه کامل. |
|||
- حفظ اسکرول لیست پس از بستن؛ قفل اسکرول پشت پنل هنگام باز بودن.له |
|||
@ -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. |
|||
@ -0,0 +1 @@ |
|||
export { default } from "@/app/questions-list/[slug]/loading"; |
|||
@ -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 <PageLoadingSkeleton />; |
|||
} |
|||
|
|||
// If female, render the beautiful, customized layout matching the design
|
|||
if (isFemale) { |
|||
return ( |
|||
<> |
|||
<PageBackground /> |
|||
|
|||
{isOutcomeSheetOpen ? ( |
|||
<OutcomeSelectionSheet |
|||
onClose={() => setIsOutcomeSheetOpen(false)} |
|||
onSubmit={async (status) => { |
|||
if (status === "success") { |
|||
if (caseId) { |
|||
await outcomeMutation.mutateAsync({ |
|||
status: "success", |
|||
}); |
|||
} |
|||
} else { |
|||
setIsDismissReasonSheetOpen(true); |
|||
} |
|||
}} |
|||
/> |
|||
) : null} |
|||
|
|||
{isDismissReasonSheetOpen ? ( |
|||
<DismissReasonSheet |
|||
onClose={() => setIsDismissReasonSheetOpen(false)} |
|||
onSubmit={async (value) => { |
|||
if (caseId) { |
|||
await outcomeMutation.mutateAsync({ |
|||
status: "failure", |
|||
custom_note: value, |
|||
}); |
|||
} |
|||
}} |
|||
/> |
|||
) : null} |
|||
|
|||
<main className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-[calc(20px+var(--safe-bottom))]"> |
|||
<PageHeader |
|||
className="-mx-[6px]" |
|||
profile={profile} |
|||
leftButton={ |
|||
isFemale ? { icon: "back", className: "hidden" } : undefined |
|||
} |
|||
/> |
|||
|
|||
<div className="flex flex-1 flex-col justify-between gap-6 pt-4"> |
|||
<section className="flex flex-col items-center"> |
|||
{isFinalized ? ( |
|||
<div className="flex flex-col items-center max-w-[320px] text-center my-auto py-12"> |
|||
<div className="relative flex items-center justify-center text-[70px] animate-bounce"> |
|||
🎉 |
|||
</div> |
|||
<h1 className="mt-8 text-center text-[24px] leading-[1.25] font-bold text-[#E03950]"> |
|||
{t["Congratulations! 🎉"]} |
|||
</h1> |
|||
<p className="mt-6 text-[#4A4A4A] group-14 font-medium leading-relaxed"> |
|||
{ |
|||
t[ |
|||
"Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed." |
|||
] |
|||
} |
|||
</p> |
|||
</div> |
|||
) : ( |
|||
<> |
|||
{/* Illustration section */} |
|||
<div className="relative mt-4 flex items-center justify-center"> |
|||
<Image |
|||
src="/assets/images/Group 159788fd0467.svg" |
|||
alt={t["Selected candidate contact status"]} |
|||
width={160} |
|||
height={152} |
|||
priority |
|||
/> |
|||
</div> |
|||
|
|||
{/* Title / Status message */} |
|||
<h1 className="mt-8 text-center text-[20px] leading-[1.25] font-bold text-[#1A1A1A] max-w-[280px]"> |
|||
{ |
|||
t[ |
|||
"The selected candidate will contact your family shortly." |
|||
] |
|||
} |
|||
</h1> |
|||
|
|||
{caseStatus === "contacted" ? ( |
|||
<> |
|||
{/* Outcome instructions and button */} |
|||
<div className="w-full border border-[#ECECEC] bg-white rounded-[15px] mt-8 px-4 py-4 text-center shadow-sm"> |
|||
<p className="text-[#555555] group-14 font-medium leading-relaxed"> |
|||
{ |
|||
t[ |
|||
"Thank you for giving us feedback, we would be very happy if you also let us know the final result." |
|||
] |
|||
} |
|||
</p> |
|||
</div> |
|||
|
|||
<div className="flex mt-6 w-full gap-3"> |
|||
<button |
|||
type="button" |
|||
onClick={() => openProfile()} |
|||
className="flex-1 h-[52px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#F5F5F5]" |
|||
> |
|||
{t["View Profile"]} |
|||
</button> |
|||
|
|||
<button |
|||
type="button" |
|||
onClick={() => setIsOutcomeSheetOpen(true)} |
|||
disabled={outcomeMutation.isPending} |
|||
className="flex-1 h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50" |
|||
> |
|||
{outcomeMutation.isPending ? ( |
|||
<LoadingThreeDot className="text-white" /> |
|||
) : ( |
|||
t["Submit Final Outcome"] |
|||
)} |
|||
</button> |
|||
</div> |
|||
</> |
|||
) : ( |
|||
<> |
|||
{/* Action Buttons Row */} |
|||
<div className="flex mt-8 w-full gap-3"> |
|||
<button |
|||
type="button" |
|||
onClick={handleNoContactReport} |
|||
disabled={contactStatusMutation.isPending} |
|||
className="flex-1 h-[52px] rounded-[15px] bg-[#F5F5F7] text-[#8E8E93] font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#EAEAEF] disabled:opacity-50" |
|||
> |
|||
{contactStatusMutation.isPending ? ( |
|||
<LoadingThreeDot className="text-[#8E8E93]" /> |
|||
) : ( |
|||
t["Report No Contact"] |
|||
)} |
|||
</button> |
|||
|
|||
<button |
|||
type="button" |
|||
onClick={async () => { |
|||
if (caseId) { |
|||
await contactStatusMutation.mutateAsync({ |
|||
action: "contacted", |
|||
}); |
|||
} |
|||
}} |
|||
disabled={contactStatusMutation.isPending} |
|||
className="flex-1 h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50" |
|||
> |
|||
{contactStatusMutation.isPending ? ( |
|||
<LoadingThreeDot className="text-white" /> |
|||
) : ( |
|||
t["Confirm Contacted"] |
|||
)} |
|||
</button> |
|||
</div> |
|||
|
|||
{/* Red warning box */} |
|||
<div className="w-full border border-[#F0445B] bg-[#FFF5F6] rounded-[15px] mt-4 px-4 py-3 text-center"> |
|||
<p className="text-[#F0445B] group-12 font-semibold leading-normal"> |
|||
{ |
|||
t[ |
|||
"To keep the process moving smoothly, the other party has a 48-hour (2-day) window to make initial contact with you or your family. If no contact is established after 2 days, you have the option to decline his request or notify us that he hasn't reached out." |
|||
] |
|||
} |
|||
</p> |
|||
</div> |
|||
</> |
|||
)} |
|||
</> |
|||
)} |
|||
</section> |
|||
|
|||
<div className="space-y-4 pb-20"> |
|||
{/* Advisor section */} |
|||
<AdvisorActionsCard |
|||
title={t["Get an advisor"]} |
|||
description={ |
|||
t[ |
|||
"Not sure what to do next? Our psychology section is here to guide you at every step." |
|||
] |
|||
} |
|||
avatars={advisorAvatars} |
|||
extraCount={7} |
|||
getAdvisorLabel={t["Get Advisor"]} |
|||
onGetAdvisor={openAdvisors} |
|||
/> |
|||
|
|||
{/* Profile Locked banner */} |
|||
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> |
|||
<div |
|||
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }} |
|||
className="pointer-events-auto px-[17px] pt-3" |
|||
> |
|||
<div className="flex items-center justify-center w-full gap-1 rounded-[11px] bg-[#DBDBDB] py-3.5"> |
|||
<Image |
|||
src="/assets/images/material-symbols_lock.svg" |
|||
width={24} |
|||
height={24} |
|||
alt="lock" |
|||
/> |
|||
<h2 className="group-14 leading-none font-semibold text-[#747474]"> |
|||
{t["Profile is locked"]} |
|||
</h2> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
</div> |
|||
</div> |
|||
</main> |
|||
|
|||
<MarriageAdvisorsOverlay |
|||
open={isAdvisorOpen} |
|||
onClose={closeAdvisors} |
|||
/> |
|||
|
|||
<MatchProfileOverlay |
|||
open={isProfileOpen} |
|||
onClose={closeProfile} |
|||
/> |
|||
</> |
|||
); |
|||
} |
|||
|
|||
// Fallback UI for male/other users
|
|||
return ( |
|||
<> |
|||
<PageBackground /> |
|||
|
|||
{isCallResultSheetOpen ? ( |
|||
<CallResultSheet |
|||
onClose={() => setIsCallResultSheetOpen(false)} |
|||
onOtherReasonsClick={() => setIsDismissReasonSheetOpen(true)} |
|||
onSubmit={async (value) => { |
|||
if (caseId) { |
|||
await contactStatusMutation.mutateAsync({ |
|||
action: "contacted", |
|||
custom_note: value, |
|||
}); |
|||
} |
|||
}} |
|||
/> |
|||
) : null} |
|||
|
|||
{isDismissReasonSheetOpen ? ( |
|||
<DismissReasonSheet |
|||
onClose={() => setIsDismissReasonSheetOpen(false)} |
|||
onSubmit={async (value) => { |
|||
if (caseId) { |
|||
await contactStatusMutation.mutateAsync({ |
|||
action: "contacted", |
|||
custom_note: value, |
|||
}); |
|||
} |
|||
}} |
|||
/> |
|||
) : null} |
|||
|
|||
<main className="-mx-[17px] flex min-h-screen flex-col px-[17px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-36"> |
|||
<PageHeader profile={profile} /> |
|||
|
|||
<section className="flex flex-1 items-center justify-center"> |
|||
<div className="flex max-w-[300px] flex-col items-center"> |
|||
<Image |
|||
src="/assets/images/Group 1597880467.svg" |
|||
alt={t["Selected candidate contact status"]} |
|||
width={131} |
|||
height={125} |
|||
/> |
|||
|
|||
<h1 className="mt-10 text-center text-[18px] leading-[1.2] font-bold text-[#1A1A1A]"> |
|||
{t["The selected candidate will contact your family shortly."]} |
|||
</h1> |
|||
</div> |
|||
</section> |
|||
|
|||
<section |
|||
style={{ paddingBottom: `calc(2rem + var(--safe-bottom))` }} |
|||
className="fixed right-0 bottom-0 left-0 z-20 mx-auto flex w-full sm:max-w-[375px] gap-3 rounded-t-[30px] bg-white px-[17px] pt-6 shadow-[0_-18px_50px_rgba(15,23,42,0.08)]" |
|||
> |
|||
<button |
|||
type="button" |
|||
onClick={() => setIsCallResultSheetOpen(true)} |
|||
className="flex-1 h-[52px] rounded-[11px] bg-gradient-to-tl from-[#FE6F82] to-[#E03950] text-white font-semibold flex items-center justify-center cursor-pointer transition-opacity active:opacity-90" |
|||
> |
|||
{t["Confirm Contacted"]} |
|||
</button> |
|||
<button |
|||
type="button" |
|||
onClick={handleNoContactReport} |
|||
disabled={contactStatusMutation.isPending} |
|||
className="flex-1 h-[52px] rounded-[11px] border border-[#8B8B8B] bg-transparent text-[#8B8B8B] font-semibold flex items-center justify-center cursor-pointer transition-opacity active:opacity-90 disabled:opacity-50" |
|||
> |
|||
{contactStatusMutation.isPending ? ( |
|||
<LoadingThreeDot className="text-[#8B8B8B]" /> |
|||
) : ( |
|||
t["Report No Contact"] |
|||
)} |
|||
</button> |
|||
</section> |
|||
</main> |
|||
|
|||
<MarriageAdvisorsOverlay |
|||
open={isAdvisorOpen} |
|||
onClose={closeAdvisors} |
|||
/> |
|||
|
|||
<MatchProfileOverlay |
|||
open={isProfileOpen} |
|||
onClose={closeProfile} |
|||
/> |
|||
</> |
|||
); |
|||
} |
|||
@ -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 <PageLoadingSkeleton />; |
|||
} |
|||
|
|||
// If female, render the beautiful, customized layout matching the design
|
|||
if (isFemale) { |
|||
return ( |
|||
<> |
|||
<PageBackground /> |
|||
|
|||
{isOutcomeSheetOpen ? ( |
|||
<OutcomeSelectionSheet |
|||
onClose={() => setIsOutcomeSheetOpen(false)} |
|||
onSubmit={async (status) => { |
|||
if (status === "success") { |
|||
if (caseId) { |
|||
await outcomeMutation.mutateAsync({ |
|||
status: "success", |
|||
}); |
|||
} |
|||
} else { |
|||
setIsDismissReasonSheetOpen(true); |
|||
} |
|||
}} |
|||
/> |
|||
) : null} |
|||
|
|||
{isDismissReasonSheetOpen ? ( |
|||
<DismissReasonSheet |
|||
onClose={() => setIsDismissReasonSheetOpen(false)} |
|||
onSubmit={async (value) => { |
|||
if (caseId) { |
|||
await outcomeMutation.mutateAsync({ |
|||
status: "failure", |
|||
custom_note: value, |
|||
}); |
|||
} |
|||
}} |
|||
/> |
|||
) : null} |
|||
|
|||
<main className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-[calc(20px+var(--safe-bottom))]"> |
|||
<PageHeader |
|||
className="-mx-[6px]" |
|||
leftButton={ |
|||
isFemale ? { icon: "back", className: "hidden" } : undefined |
|||
} |
|||
/> |
|||
|
|||
<div className="flex flex-1 flex-col justify-between gap-6 pt-4"> |
|||
<section className="flex flex-col items-center"> |
|||
{isFinalized ? ( |
|||
<div className="flex flex-col items-center max-w-[320px] text-center my-auto py-12"> |
|||
<div className="relative flex items-center justify-center text-[70px] animate-bounce"> |
|||
🎉 |
|||
</div> |
|||
<h1 className="mt-8 text-center text-[24px] leading-[1.25] font-bold text-[#E03950]"> |
|||
{t["Congratulations! 🎉"]} |
|||
</h1> |
|||
<p className="mt-6 text-[#4A4A4A] group-14 font-medium leading-relaxed"> |
|||
{ |
|||
t[ |
|||
"Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed." |
|||
] |
|||
} |
|||
</p> |
|||
</div> |
|||
) : ( |
|||
<> |
|||
{/* Illustration section */} |
|||
<div className="relative mt-4 flex items-center justify-center"> |
|||
<Image |
|||
src="/assets/images/Group 159788fd0467.svg" |
|||
alt={t["Selected candidate contact status"]} |
|||
width={160} |
|||
height={152} |
|||
priority |
|||
/> |
|||
</div> |
|||
|
|||
{/* Title / Status message */} |
|||
<h1 className="mt-8 text-center text-[20px] leading-[1.25] font-bold text-[#1A1A1A] max-w-[280px]"> |
|||
{ |
|||
t[ |
|||
"The selected candidate will contact your family shortly." |
|||
] |
|||
} |
|||
</h1> |
|||
|
|||
{caseStatus === "contacted" ? ( |
|||
<> |
|||
{/* Outcome instructions and button */} |
|||
<div className="w-full border border-[#ECECEC] bg-white rounded-[15px] mt-8 px-4 py-4 text-center shadow-sm"> |
|||
<p className="text-[#555555] group-14 font-medium leading-relaxed"> |
|||
{ |
|||
t[ |
|||
"Thank you for giving us feedback, we would be very happy if you also let us know the final result." |
|||
] |
|||
} |
|||
</p> |
|||
</div> |
|||
|
|||
<div className="flex mt-6 w-full gap-3"> |
|||
<button |
|||
type="button" |
|||
onClick={() => |
|||
router.push( |
|||
localizePath("/new-match/profile", locale), |
|||
) |
|||
} |
|||
className="flex-1 h-[52px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#F5F5F5]" |
|||
> |
|||
{t["View Profile"]} |
|||
</button> |
|||
|
|||
<button |
|||
type="button" |
|||
onClick={() => setIsOutcomeSheetOpen(true)} |
|||
disabled={outcomeMutation.isPending} |
|||
className="flex-1 h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50" |
|||
> |
|||
{outcomeMutation.isPending ? ( |
|||
<LoadingThreeDot className="text-white" /> |
|||
) : ( |
|||
t["Submit Final Outcome"] |
|||
)} |
|||
</button> |
|||
</div> |
|||
</> |
|||
) : ( |
|||
<> |
|||
{/* Action Buttons Row */} |
|||
<div className="flex mt-8 w-full gap-3"> |
|||
<button |
|||
type="button" |
|||
onClick={handleNoContactReport} |
|||
disabled={contactStatusMutation.isPending} |
|||
className="flex-1 h-[52px] rounded-[15px] bg-[#F5F5F7] text-[#8E8E93] font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#EAEAEF] disabled:opacity-50" |
|||
> |
|||
{contactStatusMutation.isPending ? ( |
|||
<LoadingThreeDot className="text-[#8E8E93]" /> |
|||
) : ( |
|||
t["Report No Contact"] |
|||
)} |
|||
</button> |
|||
|
|||
<button |
|||
type="button" |
|||
onClick={async () => { |
|||
if (caseId) { |
|||
await contactStatusMutation.mutateAsync({ |
|||
action: "contacted", |
|||
}); |
|||
} |
|||
}} |
|||
disabled={contactStatusMutation.isPending} |
|||
className="flex-1 h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50" |
|||
> |
|||
{contactStatusMutation.isPending ? ( |
|||
<LoadingThreeDot className="text-white" /> |
|||
) : ( |
|||
t["Confirm Contacted"] |
|||
)} |
|||
</button> |
|||
</div> |
|||
|
|||
{/* Red warning box */} |
|||
<div className="w-full border border-[#F0445B] bg-[#FFF5F6] rounded-[15px] mt-4 px-4 py-3 text-center"> |
|||
<p className="text-[#F0445B] group-12 font-semibold leading-normal"> |
|||
{ |
|||
t[ |
|||
"To keep the process moving smoothly, the other party has a 48-hour (2-day) window to make initial contact with you or your family. If no contact is established after 2 days, you have the option to decline his request or notify us that he hasn't reached out." |
|||
] |
|||
} |
|||
</p> |
|||
</div> |
|||
</> |
|||
)} |
|||
</> |
|||
)} |
|||
</section> |
|||
|
|||
<div className="space-y-4 pb-20"> |
|||
{/* Advisor section */} |
|||
<AdvisorActionsCard |
|||
title={t["Get an advisor"]} |
|||
description={ |
|||
t[ |
|||
"Not sure what to do next? Our psychology section is here to guide you at every step." |
|||
] |
|||
} |
|||
avatars={advisorAvatars} |
|||
extraCount={7} |
|||
getAdvisorLabel={t["Get Advisor"]} |
|||
getAdvisorHref="/marriage-advisors" |
|||
/> |
|||
|
|||
{/* Profile Locked banner */} |
|||
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> |
|||
<div |
|||
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }} |
|||
className="pointer-events-auto px-[17px] pt-3" |
|||
> |
|||
<div className="flex items-center justify-center w-full gap-1 rounded-[11px] bg-[#DBDBDB] py-3.5"> |
|||
<Image |
|||
src="/assets/images/material-symbols_lock.svg" |
|||
width={24} |
|||
height={24} |
|||
alt="lock" |
|||
/> |
|||
<h2 className="group-14 leading-none font-semibold text-[#747474]"> |
|||
{t["Profile is locked"]} |
|||
</h2> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
</div> |
|||
</div> |
|||
</main> |
|||
</> |
|||
); |
|||
} |
|||
|
|||
// Fallback UI for male/other users
|
|||
return ( |
|||
<> |
|||
<PageBackground /> |
|||
|
|||
{isCallResultSheetOpen ? ( |
|||
<CallResultSheet |
|||
onClose={() => setIsCallResultSheetOpen(false)} |
|||
onOtherReasonsClick={() => setIsDismissReasonSheetOpen(true)} |
|||
onSubmit={async (value) => { |
|||
if (caseId) { |
|||
await contactStatusMutation.mutateAsync({ |
|||
action: "contacted", |
|||
custom_note: value, |
|||
}); |
|||
} |
|||
}} |
|||
/> |
|||
) : null} |
|||
|
|||
{isDismissReasonSheetOpen ? ( |
|||
<DismissReasonSheet |
|||
onClose={() => setIsDismissReasonSheetOpen(false)} |
|||
onSubmit={async (value) => { |
|||
if (caseId) { |
|||
await contactStatusMutation.mutateAsync({ |
|||
action: "contacted", |
|||
custom_note: value, |
|||
}); |
|||
} |
|||
}} |
|||
/> |
|||
) : null} |
|||
|
|||
<main className="-mx-[17px] flex min-h-screen flex-col px-[17px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-36"> |
|||
<PageHeader /> |
|||
|
|||
<section className="flex flex-1 items-center justify-center"> |
|||
<div className="flex max-w-[300px] flex-col items-center"> |
|||
<Image |
|||
src="/assets/images/Group 1597880467.svg" |
|||
alt={t["Selected candidate contact status"]} |
|||
width={131} |
|||
height={125} |
|||
/> |
|||
|
|||
<h1 className="mt-10 text-center text-[18px] leading-[1.2] font-bold text-[#1A1A1A]"> |
|||
{t["The selected candidate will contact your family shortly."]} |
|||
</h1> |
|||
</div> |
|||
</section> |
|||
|
|||
<section |
|||
style={{ paddingBottom: `calc(2rem + var(--safe-bottom))` }} |
|||
className="fixed right-0 bottom-0 left-0 z-20 mx-auto flex w-full sm:max-w-[375px] gap-3 rounded-t-[30px] bg-white px-[17px] pt-6 shadow-[0_-18px_50px_rgba(15,23,42,0.08)]" |
|||
> |
|||
<button |
|||
type="button" |
|||
onClick={() => setIsCallResultSheetOpen(true)} |
|||
className="flex-1 h-[52px] rounded-[11px] bg-gradient-to-tl from-[#FE6F82] to-[#E03950] text-white font-semibold flex items-center justify-center cursor-pointer transition-opacity active:opacity-90" |
|||
> |
|||
{t["Confirm Contacted"]} |
|||
</button> |
|||
<button |
|||
type="button" |
|||
onClick={handleNoContactReport} |
|||
disabled={contactStatusMutation.isPending} |
|||
className="flex-1 h-[52px] rounded-[11px] border border-[#8B8B8B] bg-transparent text-[#8B8B8B] font-semibold flex items-center justify-center cursor-pointer transition-opacity active:opacity-90 disabled:opacity-50" |
|||
> |
|||
{contactStatusMutation.isPending ? ( |
|||
<LoadingThreeDot className="text-[#8B8B8B]" /> |
|||
) : ( |
|||
t["Report No Contact"] |
|||
)} |
|||
</button> |
|||
</section> |
|||
</main> |
|||
</> |
|||
<HydrationBoundary state={dehydrate(queryClient)}> |
|||
<CandidateContactClient /> |
|||
</HydrationBoundary> |
|||
); |
|||
} |
|||
@ -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 <PageLoadingSkeleton />; |
|||
} |
|||
|
|||
const copy = { |
|||
title: t["SEARCH IN PROGRESS"], |
|||
description: |
|||
t[ |
|||
"Our system is actively looking for compatible partners based on your criteria. This process requires time and patience. We will notify you immediately once a profile is ready for your review." |
|||
], |
|||
advisorTitle: t["Get an advisor"], |
|||
advisorDescription: |
|||
t[ |
|||
"Not sure what to do next? Our psychology section is here to guide you at every step." |
|||
], |
|||
getAdvisor: t["Get Advisor"], |
|||
editProfile: t["Edit Profile"], |
|||
}; |
|||
const matchImageSrc = "/assets/images/Group 1597880466.svg"; |
|||
// This notice belongs exclusively to the gentleman whose accepted request
|
|||
// was later rejected by the lady. The API enforces the same rule.
|
|||
const unseenRejection = |
|||
profile?.gender === "male" ? profile.unseen_rejection : null; |
|||
|
|||
return ( |
|||
<> |
|||
<PageBackground /> |
|||
|
|||
<main |
|||
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }} |
|||
className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] text-center" |
|||
> |
|||
<PageHeader className="-mx-[6px]" /> |
|||
|
|||
<section className="mt-20 flex flex-1 flex-col items-center"> |
|||
<div className="relative h-[124px] w-[130px]" aria-hidden="true"> |
|||
<Image |
|||
src={matchImageSrc} |
|||
alt="" |
|||
fill |
|||
sizes="58px" |
|||
className="object-cover" |
|||
priority |
|||
/> |
|||
</div> |
|||
<h1 className="mt-5 group-16 font-bold leading-none tracking-[0.02em] text-[#171717] uppercase"> |
|||
{copy.title} |
|||
</h1> |
|||
<p className="mt-3 max-w-[320px] mx-auto text-center group-12 leading-[1.35] font-semibold text-[#747474]"> |
|||
{copy.description} |
|||
</p> |
|||
{unseenRejection && ( |
|||
<aside |
|||
className="mt-5 w-full rounded-[20px] border border-[#F2465F]/15 bg-white p-4 text-start shadow-[0_10px_28px_rgba(242,70,95,0.08)]" |
|||
aria-live="polite" |
|||
aria-labelledby="rejection-notice-title" |
|||
> |
|||
<div className="flex items-start gap-3"> |
|||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#FFF0F2] text-[#F2465F]"> |
|||
<IoAlertCircle className="h-6 w-6" aria-hidden="true" /> |
|||
</div> |
|||
|
|||
<div className="min-w-0 flex-1"> |
|||
<h2 |
|||
id="rejection-notice-title" |
|||
className="text-[16px] font-bold leading-[1.3] text-[#171717]" |
|||
> |
|||
{t["Your request was rejected"]} |
|||
</h2> |
|||
|
|||
<p className="mt-1.5 text-[13px] font-semibold leading-[1.5] text-[#747474]"> |
|||
{ |
|||
t[ |
|||
"Your request was rejected by the lady. You will be introduced to other candidates in the future." |
|||
] |
|||
} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="mt-4 w-full"> |
|||
<Button |
|||
isLoading={isMarkingSeen} |
|||
onClick={() => { |
|||
markRejectionSeen({ |
|||
rejection_id: unseenRejection.id, |
|||
}); |
|||
}} |
|||
> |
|||
{t["Got it"]} |
|||
</Button> |
|||
</div> |
|||
</aside> |
|||
)}{" "} |
|||
</section> |
|||
|
|||
<AdvisorActionsCard |
|||
title={copy.advisorTitle} |
|||
description={copy.advisorDescription} |
|||
avatars={advisorAvatars} |
|||
extraCount={7} |
|||
getAdvisorLabel={copy.getAdvisor} |
|||
onGetAdvisor={openAdvisors} |
|||
/> |
|||
|
|||
<FixToTheEnd> |
|||
{profile?.can_edit_profile === false ? ( |
|||
<div |
|||
className="inline-flex w-full items-center justify-center gap-2 rounded-[9px] border-none bg-[#D1D1D6] dark:bg-[#3D3E42] px-4 py-[17px] text-center text-[#747474] dark:text-[#A1A1A1] shadow-none" |
|||
aria-live="polite" |
|||
> |
|||
<FaLock aria-hidden="true" className="h-3.5 w-3.5 shrink-0" /> |
|||
<span className="group-16 leading-none font-semibold"> |
|||
{t["Profile is locked"]} |
|||
</span> |
|||
</div> |
|||
) : ( |
|||
<Button variant="dark" href="/questions-list"> |
|||
<FaPen aria-hidden="true" className="h-3.5 w-3.5 shrink-0" /> |
|||
<span>{copy.editProfile}</span> |
|||
</Button> |
|||
)} |
|||
</FixToTheEnd> |
|||
</main> |
|||
|
|||
<MarriageAdvisorsOverlay |
|||
open={isAdvisorOpen} |
|||
onClose={closeAdvisors} |
|||
/> |
|||
</> |
|||
); |
|||
} |
|||
@ -1,180 +1,39 @@ |
|||
"use client"; |
|||
|
|||
import Image from "next/image"; |
|||
import { useRouter } from "next/navigation"; |
|||
import { useEffect, useMemo } from "react"; |
|||
import { FaLock, FaPen } from "react-icons/fa6"; |
|||
import { IoAlertCircle } from "react-icons/io5"; |
|||
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; |
|||
import Button from "@/components/Componentes/button"; |
|||
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; |
|||
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; |
|||
import { PageBackground } from "@/components/Componentes/page-background"; |
|||
import PageHeader from "@/components/Componentes/page-header"; |
|||
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
|||
import { useRejectionSeenMutation } from "@/hooks/marriage/use-rejection-seen"; |
|||
import { getSubmitPath } from "@/lib/get-submit-path"; |
|||
import { localizePath } from "@/translations/config"; |
|||
import { useI18n } from "@/translations/provider"; |
|||
|
|||
const advisorAvatars = [ |
|||
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" }, |
|||
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" }, |
|||
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" }, |
|||
]; |
|||
|
|||
export default function FindingMatchPage() { |
|||
const router = useRouter(); |
|||
const { dictionary: t, locale } = useI18n(); |
|||
const { data: profile, isLoading } = useMarriageProfileQuery({ |
|||
refetchInterval: 3000, |
|||
import { cookies } from "next/headers"; |
|||
import { |
|||
dehydrate, |
|||
HydrationBoundary, |
|||
QueryClient, |
|||
} from "@tanstack/react-query"; |
|||
import { isAuthenticatedToken } from "@/lib/entry-route-cache"; |
|||
import { fetchProfileSSR } from "@/lib/ssr-fetch"; |
|||
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; |
|||
import FindingMatchClient from "./finding-match-client"; |
|||
|
|||
export const dynamic = "force-dynamic"; |
|||
|
|||
export default async function FindingMatchPage() { |
|||
const cookieStore = await cookies(); |
|||
|
|||
const token = |
|||
cookieStore.get("HABIB_TOKEN")?.value ?? |
|||
cookieStore.get("habib_token")?.value; |
|||
|
|||
const queryClient = new QueryClient({ |
|||
defaultOptions: { |
|||
queries: { staleTime: 10 * 1000 }, |
|||
}, |
|||
}); |
|||
|
|||
const { mutate: markRejectionSeen, isPending: isMarkingSeen } = |
|||
useRejectionSeenMutation(); |
|||
|
|||
useEffect(() => { |
|||
if (!profile) { |
|||
return; |
|||
} |
|||
const targetPath = getSubmitPath(profile); |
|||
if (targetPath !== "/finding-match") { |
|||
router.replace(localizePath(targetPath, locale)); |
|||
} |
|||
}, [profile, locale, router]); |
|||
|
|||
const isRedirecting = useMemo(() => { |
|||
if (!profile) return false; |
|||
return getSubmitPath(profile) !== "/finding-match"; |
|||
}, [profile]); |
|||
|
|||
if (isLoading || isRedirecting) { |
|||
return <PageLoadingSkeleton />; |
|||
if (isAuthenticatedToken(token)) { |
|||
await queryClient.prefetchQuery({ |
|||
queryKey: marriageQueryKeys.profile(), |
|||
queryFn: () => fetchProfileSSR(token!), |
|||
}); |
|||
} |
|||
|
|||
const copy = { |
|||
title: t["SEARCH IN PROGRESS"], |
|||
description: |
|||
t[ |
|||
"Our system is actively looking for compatible partners based on your criteria. This process requires time and patience. We will notify you immediately once a profile is ready for your review." |
|||
], |
|||
advisorTitle: t["Get an advisor"], |
|||
advisorDescription: |
|||
t[ |
|||
"Not sure what to do next? Our psychology section is here to guide you at every step." |
|||
], |
|||
getAdvisor: t["Get Advisor"], |
|||
editProfile: t["Edit Profile"], |
|||
}; |
|||
const matchImageSrc = "/assets/images/Group 1597880466.svg"; |
|||
// This notice belongs exclusively to the gentleman whose accepted request
|
|||
// was later rejected by the lady. The API enforces the same rule.
|
|||
const unseenRejection = |
|||
profile?.gender === "male" ? profile.unseen_rejection : null; |
|||
|
|||
return ( |
|||
<> |
|||
<PageBackground /> |
|||
|
|||
<main |
|||
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }} |
|||
className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] text-center" |
|||
> |
|||
<PageHeader |
|||
className="-mx-[6px]" |
|||
rightButton={{ icon: "subscription", iconLabel: "Subscribe" }} |
|||
/> |
|||
|
|||
<section className="mt-20 flex flex-1 flex-col items-center"> |
|||
<div className="relative h-[124px] w-[130px]" aria-hidden="true"> |
|||
<Image |
|||
src={matchImageSrc} |
|||
alt="" |
|||
fill |
|||
sizes="58px" |
|||
className="object-cover" |
|||
priority |
|||
/> |
|||
</div> |
|||
<h1 className="mt-5 group-16 font-bold leading-none tracking-[0.02em] text-[#171717] uppercase"> |
|||
{copy.title} |
|||
</h1> |
|||
<p className="mt-3 max-w-[320px] mx-auto text-center group-12 leading-[1.35] font-semibold text-[#747474]"> |
|||
{copy.description} |
|||
</p> |
|||
{unseenRejection && ( |
|||
<aside |
|||
className="mt-5 w-full rounded-[20px] border border-[#F2465F]/15 bg-white p-4 text-start shadow-[0_10px_28px_rgba(242,70,95,0.08)]" |
|||
aria-live="polite" |
|||
aria-labelledby="rejection-notice-title" |
|||
> |
|||
<div className="flex items-start gap-3"> |
|||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#FFF0F2] text-[#F2465F]"> |
|||
<IoAlertCircle className="h-6 w-6" aria-hidden="true" /> |
|||
</div> |
|||
|
|||
<div className="min-w-0 flex-1"> |
|||
<h2 |
|||
id="rejection-notice-title" |
|||
className="text-[16px] font-bold leading-[1.3] text-[#171717]" |
|||
> |
|||
{t["Your request was rejected"]} |
|||
</h2> |
|||
|
|||
<p className="mt-1.5 text-[13px] font-semibold leading-[1.5] text-[#747474]"> |
|||
{ |
|||
t[ |
|||
"Your request was rejected by the lady. You will be introduced to other candidates in the future." |
|||
] |
|||
} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="mt-4 w-full"> |
|||
<Button |
|||
isLoading={isMarkingSeen} |
|||
onClick={() => { |
|||
markRejectionSeen({ |
|||
rejection_id: unseenRejection.id, |
|||
}); |
|||
}} |
|||
> |
|||
{t["Got it"]} |
|||
</Button> |
|||
</div> |
|||
</aside> |
|||
)}{" "} |
|||
</section> |
|||
|
|||
<AdvisorActionsCard |
|||
title={copy.advisorTitle} |
|||
description={copy.advisorDescription} |
|||
avatars={advisorAvatars} |
|||
extraCount={7} |
|||
getAdvisorLabel={copy.getAdvisor} |
|||
getAdvisorHref="/marriage-advisors" |
|||
/> |
|||
|
|||
<FixToTheEnd> |
|||
{profile?.can_edit_profile === false ? ( |
|||
<div |
|||
className="inline-flex w-full items-center justify-center gap-2 rounded-[9px] border-none bg-[#D1D1D6] dark:bg-[#3D3E42] px-4 py-[17px] text-center text-[#747474] dark:text-[#A1A1A1] shadow-none" |
|||
aria-live="polite" |
|||
> |
|||
<FaLock aria-hidden="true" className="h-3.5 w-3.5 shrink-0" /> |
|||
<span className="group-16 leading-none font-semibold"> |
|||
{t["Profile is locked"]} |
|||
</span> |
|||
</div> |
|||
) : ( |
|||
<Button variant="dark" href="/questions-list"> |
|||
<FaPen aria-hidden="true" className="h-3.5 w-3.5 shrink-0" /> |
|||
<span>{copy.editProfile}</span> |
|||
</Button> |
|||
)} |
|||
</FixToTheEnd> |
|||
</main> |
|||
</> |
|||
<HydrationBoundary state={dehydrate(queryClient)}> |
|||
<FindingMatchClient /> |
|||
</HydrationBoundary> |
|||
); |
|||
} |
|||
@ -0,0 +1,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 ( |
|||
<div className="pt-[max(12px,calc(var(--safe-top)+4px))]"> |
|||
{isReportSheetOpen && ( |
|||
<ReportActionsSheet onClose={() => setIsReportSheetOpen(false)} /> |
|||
)} |
|||
<PageHeader |
|||
rightButton={{ |
|||
icon: "support", |
|||
onClick: () => setIsReportSheetOpen(true), |
|||
}} |
|||
/> |
|||
<main className="pb-[calc(90px+var(--safe-bottom))]"> |
|||
<div className="flex flex-col items-center mt-16"> |
|||
<Image |
|||
src={"/assets/images/Group 1597880466.svg"} |
|||
alt={t["heavenly marriage"]} |
|||
width={168} |
|||
height={155} |
|||
/> |
|||
<h2 className="group-16 text-[#475569] font-bold mt-5 text-center"> |
|||
{t["A Path to Heavenly Marriage"]} |
|||
</h2> |
|||
<p className="text-center mt-2 group-12 text-[#4D4D4D]"> |
|||
{ |
|||
t[ |
|||
'We have come together with the goal of creating a secure and confidential path for "permanent marriage" among Muslims' |
|||
] |
|||
} |
|||
</p> |
|||
</div> |
|||
<div className="flex items-center justify-between mt-4"> |
|||
<div className="flex items-center gap-0.5 p-2 border border-[#FD6ABB]/10 rounded-2xl"> |
|||
<Image |
|||
src={"/assets/images/tabler_user-filled.svg"} |
|||
alt={t["user profiles"]} |
|||
width={37} |
|||
height={37} |
|||
/> |
|||
<div className="flex flex-col items-start justify-start -mb-2"> |
|||
<p className="text-[#36363C] group-16 font-bold leading-3">120</p> |
|||
<p className="text-[#36363C] group-10 font-semibold capitalize"> |
|||
{t["user profiles"]} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
<div className="flex items-center gap-0.5 p-2 border border-[#FD6ABB]/10 rounded-2xl"> |
|||
<Image |
|||
src={"/assets/images/icon-park-solid_success.svg"} |
|||
alt={t["matches"]} |
|||
width={37} |
|||
height={37} |
|||
priority |
|||
/> |
|||
<div className="flex flex-col items-start justify-start -mb-2"> |
|||
<p className="text-[#36363C] group-16 font-bold leading-3">14</p> |
|||
<p className="text-[#36363C] group-10 font-semibold capitalize"> |
|||
{t["matches"]} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
<div className="flex items-center gap-0.5 p-2 border border-[#FD6ABB]/10 rounded-2xl"> |
|||
<Image |
|||
src={"/assets/images/typcn_heart-full-outline.svg"} |
|||
alt={t["marriages"]} |
|||
width={37} |
|||
height={37} |
|||
priority |
|||
/> |
|||
<div className="flex flex-col items-start justify-start -mb-2"> |
|||
<p className="text-[#36363C] group-16 font-bold leading-3">14</p> |
|||
<p className="text-[#36363C] group-10 font-semibold capitalize"> |
|||
{t["marriages"]} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div |
|||
className="mt-14 relative cursor-pointer group rounded-2xl overflow-hidden aspect-[344/221] max-w-[344px] w-full mx-auto" |
|||
onClick={() => setIsPlayerOpen(true)} |
|||
> |
|||
<NetworkImage |
|||
src={config?.intro_video_thumbnail_url} |
|||
fallbackSrc="/assets/images/Frame 2095586523.png" |
|||
alt={t["video"]} |
|||
fill |
|||
className="object-cover transition-transform duration-300 group-hover:scale-105" |
|||
priority |
|||
/> |
|||
<div className="absolute inset-0 bg-black/10 transition-colors duration-300 group-hover:bg-black/20" /> |
|||
<Image |
|||
src={"/assets/images/Frame 1116607280.svg"} |
|||
alt={t["play"]} |
|||
width={68} |
|||
height={68} |
|||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transition-transform duration-300 group-hover:scale-110 active:scale-95" |
|||
/> |
|||
</div> |
|||
|
|||
<VideoPlayer |
|||
isOpen={isPlayerOpen} |
|||
onClose={() => setIsPlayerOpen(false)} |
|||
videoUrl={config?.intro_video_url} |
|||
/> |
|||
<div className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> |
|||
<div |
|||
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }} |
|||
className="pointer-events-auto px-[17px] pt-3" |
|||
> |
|||
<Button |
|||
onClick={handleSubmit} |
|||
disabled={isSubmitting} |
|||
isLoading={isSubmitting} |
|||
> |
|||
{t["Submit"]} |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
</main> |
|||
</div> |
|||
); |
|||
} |
|||
@ -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 ( |
|||
<div className="pt-[max(12px,calc(var(--safe-top)+4px))]"> |
|||
{isReportSheetOpen && ( |
|||
<ReportActionsSheet onClose={() => setIsReportSheetOpen(false)} /> |
|||
)} |
|||
<PageHeader |
|||
rightButton={{ |
|||
icon: "support", |
|||
onClick: () => setIsReportSheetOpen(true), |
|||
}} |
|||
/> |
|||
<main className="pb-[calc(90px+var(--safe-bottom))]"> |
|||
<div className="flex flex-col items-center mt-16"> |
|||
<Image |
|||
src={"/assets/images/Group 1597880466.svg"} |
|||
alt={t["heavenly marriage"]} |
|||
width={168} |
|||
height={155} |
|||
/> |
|||
<h2 className="group-16 text-[#475569] font-bold mt-5 text-center"> |
|||
{t["A Path to Heavenly Marriage"]} |
|||
</h2> |
|||
<p className="text-center mt-2 group-12 text-[#4D4D4D]"> |
|||
{ |
|||
t[ |
|||
'We have come together with the goal of creating a secure and confidential path for "permanent marriage" among Muslims' |
|||
] |
|||
} |
|||
</p> |
|||
</div> |
|||
<div className="flex items-center justify-between mt-4"> |
|||
<div className="flex items-center gap-0.5 p-2 border border-[#FD6ABB]/10 rounded-2xl"> |
|||
<Image |
|||
src={"/assets/images/tabler_user-filled.svg"} |
|||
alt={t["user profiles"]} |
|||
width={37} |
|||
height={37} |
|||
/> |
|||
<div className="flex flex-col items-start justify-start -mb-2"> |
|||
<p className="text-[#36363C] group-16 font-bold leading-3">120</p> |
|||
<p className="text-[#36363C] group-10 font-semibold capitalize"> |
|||
{t["user profiles"]} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
<div className="flex items-center gap-0.5 p-2 border border-[#FD6ABB]/10 rounded-2xl"> |
|||
<Image |
|||
src={"/assets/images/icon-park-solid_success.svg"} |
|||
alt={t["matches"]} |
|||
width={37} |
|||
height={37} |
|||
priority |
|||
/> |
|||
<div className="flex flex-col items-start justify-start -mb-2"> |
|||
<p className="text-[#36363C] group-16 font-bold leading-3">14</p> |
|||
<p className="text-[#36363C] group-10 font-semibold capitalize"> |
|||
{t["matches"]} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
<div className="flex items-center gap-0.5 p-2 border border-[#FD6ABB]/10 rounded-2xl"> |
|||
<Image |
|||
src={"/assets/images/typcn_heart-full-outline.svg"} |
|||
alt={t["marriages"]} |
|||
width={37} |
|||
height={37} |
|||
priority |
|||
/> |
|||
<div className="flex flex-col items-start justify-start -mb-2"> |
|||
<p className="text-[#36363C] group-16 font-bold leading-3">14</p> |
|||
<p className="text-[#36363C] group-10 font-semibold capitalize"> |
|||
{t["marriages"]} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div |
|||
className="mt-14 relative cursor-pointer group rounded-2xl overflow-hidden aspect-[344/221] max-w-[344px] w-full mx-auto" |
|||
onClick={() => setIsPlayerOpen(true)} |
|||
> |
|||
<NetworkImage |
|||
src={config?.intro_video_thumbnail_url} |
|||
fallbackSrc="/assets/images/Frame 2095586523.png" |
|||
alt={t["video"]} |
|||
fill |
|||
className="object-cover transition-transform duration-300 group-hover:scale-105" |
|||
priority |
|||
/> |
|||
<div className="absolute inset-0 bg-black/10 transition-colors duration-300 group-hover:bg-black/20" /> |
|||
<Image |
|||
src={"/assets/images/Frame 1116607280.svg"} |
|||
alt={t["play"]} |
|||
width={68} |
|||
height={68} |
|||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transition-transform duration-300 group-hover:scale-110 active:scale-95" |
|||
/> |
|||
</div> |
|||
|
|||
<VideoPlayer |
|||
isOpen={isPlayerOpen} |
|||
onClose={() => setIsPlayerOpen(false)} |
|||
videoUrl={config?.intro_video_url} |
|||
/> |
|||
<div className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> |
|||
<div |
|||
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }} |
|||
className="pointer-events-auto px-[17px] pt-3" |
|||
> |
|||
<Button |
|||
onClick={handleSubmit} |
|||
disabled={isSubmitting} |
|||
isLoading={isSubmitting} |
|||
> |
|||
{t["Submit"]} |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
</main> |
|||
</div> |
|||
<HydrationBoundary state={dehydrate(queryClient)}> |
|||
<IntroClient /> |
|||
</HydrationBoundary> |
|||
); |
|||
} |
|||
@ -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, string>, |
|||
): 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<MarriagePhoneFieldValue>; |
|||
|
|||
return ( |
|||
typeof phoneValue.countryCode === "string" && |
|||
typeof phoneValue.phoneNumber === "string" |
|||
); |
|||
} |
|||
|
|||
function titleFromKey(key: string) { |
|||
return key |
|||
.replace(/^q\d+[_-]?/i, "") |
|||
.replace(/[_-]+/g, " ") |
|||
.replace(/\s+/g, " ") |
|||
.trim() |
|||
.replace(/\b\w/g, (letter) => letter.toUpperCase()); |
|||
} |
|||
|
|||
function toDisplayField( |
|||
field: MarriageField, |
|||
dictionary?: Record<string, string>, |
|||
): 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<number>, |
|||
dictionary?: Record<string, string>, |
|||
): 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<string, string>, |
|||
) { |
|||
return useMemo(() => { |
|||
const fields = matchSummary?.public_info ?? []; |
|||
const usedIndexes = new Set<number>(); |
|||
|
|||
// 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 ( |
|||
<p className="break-words text-[10px] leading-[1.85] font-medium text-white"> |
|||
<span>{field.label}: </span> |
|||
<span>{field.value}</span> |
|||
</p> |
|||
); |
|||
} |
|||
|
|||
export default function NewMatchClient() { |
|||
const router = useRouter(); |
|||
const { dictionary: t, locale } = useI18n(); |
|||
const { top, bottom } = useViewPaddings(); |
|||
const { isAdvisorOpen, openAdvisors, closeAdvisors } = |
|||
useMarriageAdvisorsOverlay(); |
|||
const { isProfileOpen, openProfile, closeProfile } = useMatchProfileOverlay(); |
|||
const { data: profile, isError, isLoading } = useMarriageProfileQuery(); |
|||
const [isPaymentSheetOpen, setIsPaymentSheetOpen] = useState(false); |
|||
const [paymentError, setPaymentError] = useState<string | null>(null); |
|||
const [isInsufficientCoins, setIsInsufficientCoins] = useState(false); |
|||
|
|||
const [appliedDiscount, setAppliedDiscount] = |
|||
useState<CheckDiscountResult | null>(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 ( |
|||
<> |
|||
<PageBackground /> |
|||
<main |
|||
style={{ |
|||
paddingBottom: `${bottom + 20}px`, |
|||
paddingTop: `${Math.max(12, top + 4)}px`, |
|||
}} |
|||
className="-mx-[17px] min-h-screen h-full text-center px-4" |
|||
> |
|||
<PageHeader profile={profile} /> |
|||
|
|||
<div> |
|||
{/* Header Section Skeleton */} |
|||
<section className="flex flex-col items-center mt-9"> |
|||
<LoadingSkeleton className="h-[60px] w-[60px] rounded-full" /> |
|||
<LoadingSkeleton className="h-6 w-[220px] mt-4" /> |
|||
<LoadingSkeleton className="h-4 w-[280px] mt-3" /> |
|||
<LoadingSkeleton className="h-4 w-[240px] mt-1.5" /> |
|||
</section> |
|||
|
|||
<div className="flex h-full flex-col justify-between"> |
|||
{/* Match Card Skeleton */} |
|||
<section className="mt-[36px] rounded-[15px] border border-white/80 bg-white px-[17px] pt-[18px] pb-[17px] shadow-[0_18px_45px_rgba(15,23,42,0.06)] flex flex-col items-center"> |
|||
{/* Name line */} |
|||
<LoadingSkeleton className="h-5 w-[140px]" /> |
|||
|
|||
{/* Subtitle / Details lines */} |
|||
<div className="mt-3 w-full flex flex-col items-center gap-2 min-h-[48px]"> |
|||
<LoadingSkeleton className="h-3 w-[180px]" /> |
|||
<LoadingSkeleton className="h-3 w-[120px]" /> |
|||
<LoadingSkeleton className="h-3 w-[150px]" /> |
|||
</div> |
|||
|
|||
{/* Button skeleton */} |
|||
<LoadingSkeleton className="mt-[15px] h-[40px] w-full rounded-[10px]" /> |
|||
</section> |
|||
|
|||
{/* Advisor Card Skeleton */} |
|||
<section className="mt-[36px]"> |
|||
<div className="rounded-[13px] border border-white/80 bg-white px-3 py-3.5 text-left shadow-[0_18px_45px_rgba(15,23,42,0.06)] backdrop-blur-sm"> |
|||
<LoadingSkeleton className="h-[16px] w-[140px]" /> |
|||
<div className="mt-2 space-y-1"> |
|||
<LoadingSkeleton className="h-[10px] w-[260px]" /> |
|||
<LoadingSkeleton className="h-[10px] w-[180px]" /> |
|||
</div> |
|||
|
|||
<div className="mt-4 flex items-center justify-between gap-3"> |
|||
<div className="flex items-center pl-1"> |
|||
<LoadingSkeleton className="h-[30px] w-[30px] rounded-full border-2 border-white" /> |
|||
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" /> |
|||
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" /> |
|||
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" /> |
|||
</div> |
|||
|
|||
<LoadingSkeleton className="h-[38px] w-[120px] rounded-[9px]" /> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
</div> |
|||
</div> |
|||
</main> |
|||
</> |
|||
); |
|||
} |
|||
const pairedFields = [matchDisplay.age, matchDisplay.city].filter( |
|||
(field): field is DisplayField => Boolean(field), |
|||
); |
|||
|
|||
const isFemaleProfile = profile?.gender === "female"; |
|||
const matchHeadingTitle = isFemaleProfile |
|||
? t["New Marriage Proposal"] |
|||
: t["YOU HAVE A NEW MATCH!"]; |
|||
const matchHeadingDescription = isFemaleProfile |
|||
? t[ |
|||
"A suitable match has been found for you. If approved, your profile will be evaluated to proceed with the introduction process." |
|||
] |
|||
: t[ |
|||
"If you proceed, we will notify the other party, and upon their approval, you can view each other's contact information." |
|||
]; |
|||
|
|||
const isMale = profile?.gender === "male"; |
|||
const hasActiveSub = |
|||
!!profile?.active_subscription && |
|||
profile.active_subscription.is_active !== false && |
|||
profile.active_subscription.is_valid !== false; |
|||
const isMatchAvailable = !!profile?.match_summary; |
|||
|
|||
return ( |
|||
<> |
|||
<PageBackground /> |
|||
|
|||
<main |
|||
style={{ |
|||
paddingBottom: `${profile?.can_edit_profile === false ? 100 + bottom : 20 + bottom}px`, |
|||
paddingTop: `${Math.max(12, top + 4)}px`, |
|||
}} |
|||
className="-mx-[17px] min-h-screen h-full text-center px-4" |
|||
> |
|||
<PageHeader profile={profile} /> |
|||
|
|||
<div className=""> |
|||
<section className="flex flex-col items-center mt-9"> |
|||
<div |
|||
aria-hidden="true" |
|||
className="relative flex h-[60px] w-[60px] items-center justify-center rounded-full bg-[#FF4E67] shadow-[0_12px_28px_rgba(240,68,91,0.22)]" |
|||
> |
|||
<Image |
|||
src={"/assets/images/Ellipse 1210.svg"} |
|||
width={70} |
|||
height={70} |
|||
alt="notification" |
|||
/> |
|||
</div> |
|||
<h2 className="text-[22px] font-bold mt-3.5"> |
|||
{matchHeadingTitle} |
|||
</h2> |
|||
<p className="mt-[11px] max-w-[322px] text-[12px] leading-[1.35] font-semibold text-[#7C7C7C]"> |
|||
{matchHeadingDescription} |
|||
</p> |
|||
</section> |
|||
<div className="flex h-full flex-col justify-between"> |
|||
{isLoading ? ( |
|||
<section className="mt-[36px] rounded-[15px] border border-white/80 bg-white px-[17px] pt-[18px] pb-[17px] shadow-[0_18px_45px_rgba(15,23,42,0.06)]"> |
|||
<div className="flex flex-col items-center py-2"> |
|||
{/* Name line */} |
|||
<LoadingSkeleton className="h-5 w-[140px]" /> |
|||
|
|||
{/* Subtitle / Details lines */} |
|||
<div className="mt-3 w-full flex flex-col items-center gap-2 min-h-[48px]"> |
|||
<LoadingSkeleton className="h-3 w-[180px]" /> |
|||
<LoadingSkeleton className="h-3 w-[120px]" /> |
|||
<LoadingSkeleton className="h-3 w-[150px]" /> |
|||
</div> |
|||
|
|||
{/* Button skeleton */} |
|||
<LoadingSkeleton className="mt-[15px] h-[40px] w-full rounded-[10px]" /> |
|||
</div> |
|||
</section> |
|||
) : ( |
|||
<section className="mt-[36px] rounded-[15px] bg-[linear-gradient(180deg,#F0445B_0%,#F4556E_100%)] px-[17px] pt-[18px] pb-[17px] text-white shadow-[0_18px_38px_rgba(240,68,91,0.25)]"> |
|||
{isError ? ( |
|||
<p className="py-8 text-[13px] font-semibold"> |
|||
Unable to load match summary. |
|||
</p> |
|||
) : matchSummary ? ( |
|||
<> |
|||
<h2 className="break-words text-[13px] leading-[1.4] font-bold"> |
|||
<span>{matchDisplay.name}</span> |
|||
</h2> |
|||
|
|||
<div className="mt-[3px] min-h-[48px]"> |
|||
{matchDisplay.occupation ? ( |
|||
<FieldLine field={matchDisplay.occupation} /> |
|||
) : null} |
|||
|
|||
{pairedFields.length ? ( |
|||
<p className="break-words text-[10px] leading-[1.85] font-medium text-white"> |
|||
{pairedFields.map((field, index) => ( |
|||
<span key={field.id}> |
|||
{index > 0 ? <span> | </span> : null} |
|||
<span> |
|||
{field.label}: {field.value} |
|||
</span> |
|||
</span> |
|||
))} |
|||
</p> |
|||
) : null} |
|||
|
|||
{matchDisplay.maritalStatus ? ( |
|||
<FieldLine field={matchDisplay.maritalStatus} /> |
|||
) : null} |
|||
{matchDisplay.cityPreference ? ( |
|||
<FieldLine field={matchDisplay.cityPreference} /> |
|||
) : null} |
|||
</div> |
|||
|
|||
<button |
|||
type="button" |
|||
onClick={() => { |
|||
if (isMale && !hasActiveSub) { |
|||
setIsPaymentSheetOpen(true); |
|||
} else { |
|||
openProfile(); |
|||
} |
|||
}} |
|||
className="mt-[15px] inline-flex w-full items-center justify-center rounded-[10px] border-none bg-white h-[40px] text-[16px] font-semibold text-[#F0445B] no-underline shadow-none hover:bg-white/95 transition-colors cursor-pointer" |
|||
> |
|||
{t["View more details"]} |
|||
</button> |
|||
</> |
|||
) : ( |
|||
<p className="py-8 text-[13px] font-semibold"> |
|||
No match summary is available yet. |
|||
</p> |
|||
)} |
|||
</section> |
|||
)} |
|||
<div className="w-full space-y-3"> |
|||
<AdvisorActionsCard |
|||
className="mt-[36px]" |
|||
title={t["Get an advisor"]} |
|||
description={ |
|||
t[ |
|||
"Not sure what to do next? Our psychology section is here to guide you at every step." |
|||
] |
|||
} |
|||
avatars={advisorAvatars} |
|||
extraCount={7} |
|||
getAdvisorLabel={t["Get Advisor"]} |
|||
onGetAdvisor={openAdvisors} |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</main> |
|||
|
|||
{profile?.can_edit_profile === false && ( |
|||
<div |
|||
style={{ paddingBottom: `${16 + bottom}px` }} |
|||
className="fixed inset-x-0 bottom-0 z-20 mx-auto w-full sm:max-w-[375px] bg-background/95 px-[17px] pt-3 pb-[16px] backdrop-blur-md" |
|||
> |
|||
<div |
|||
className="inline-flex w-full items-center justify-center gap-2 rounded-[9px] border-none bg-[#D1D1D6] px-4 h-[52px] text-center text-[#747474] shadow-none" |
|||
role="status" |
|||
> |
|||
<FaLock aria-hidden="true" className="h-3.5 w-3.5 shrink-0" /> |
|||
<span className="group-16 leading-none font-semibold"> |
|||
{t["Profile is locked"]} |
|||
</span> |
|||
</div> |
|||
</div> |
|||
)} |
|||
|
|||
{isPaymentSheetOpen && isInventoryLoading && ( |
|||
<InformationSheet |
|||
isLoading={true} |
|||
style={{ |
|||
paddingBottom: bottom > 0 ? `${14 + bottom}px` : undefined, |
|||
}} |
|||
onClose={() => { |
|||
setIsPaymentSheetOpen(false); |
|||
setPaymentError(null); |
|||
setIsInsufficientCoins(false); |
|||
}} |
|||
/> |
|||
)} |
|||
|
|||
{isPaymentSheetOpen && !isInventoryLoading && ( |
|||
<InformationSheet |
|||
style={{ |
|||
paddingBottom: bottom > 0 ? `${14 + bottom}px` : undefined, |
|||
}} |
|||
icon="coin" |
|||
title={ |
|||
!hasEnoughCoins ? ( |
|||
<span className="text-[#BD3F3F]"> |
|||
{t["You do not have enough Habib Coins"] || |
|||
"You do not have enough Habib Coins"} |
|||
</span> |
|||
) : ( |
|||
t["Verification & Subscription Activation"] || |
|||
"Verification & Subscription Activation" |
|||
) |
|||
} |
|||
description={ |
|||
!hasEnoughCoins ? ( |
|||
<p className="text-[14px] text-[#4D4D4D] leading-[1.3] font-normal text-center"> |
|||
{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"} |
|||
</p> |
|||
) : ( |
|||
<div className="space-y-3.5 text-center w-full"> |
|||
<p className="text-[14px] text-[#4D4D4D] leading-[1.3] font-normal"> |
|||
{t[ |
|||
"This payment acts as a verification process for your account and activates a 3-month subscription to receive new case introductions. The cost of this subscription is 50 Habib Coins." |
|||
] || |
|||
"This payment acts as a verification process for your account and activates a 3-month subscription to receive new case introductions. The cost of this subscription is 50 Habib Coins."} |
|||
</p> |
|||
|
|||
<div className="w-full bg-white rounded-[11px] p-3 border border-gray-200/60 shadow-2xs"> |
|||
<span className="text-[11px] text-[#8B8B8B] block mb-0.5 font-medium"> |
|||
{t["Valid for 3 months"] || "Valid for 3 months"} |
|||
</span> |
|||
<span className="text-[16px] font-bold text-[#FF4E67]"> |
|||
{finalPrice} {t["Habib Coins"] || "Habib Coins"} |
|||
</span> |
|||
</div> |
|||
|
|||
<p className="text-[11px] text-[#8B8B8B] leading-normal"> |
|||
{t[ |
|||
"Please note that there is no guarantee for a specific number of cases, and the volume of incoming cases is solely subject to profile compatibility with other users." |
|||
] || |
|||
"Please note that there is no guarantee for a specific number of cases, and the volume of incoming cases is solely subject to profile compatibility with other users."} |
|||
</p> |
|||
</div> |
|||
) |
|||
} |
|||
onClose={() => { |
|||
setIsPaymentSheetOpen(false); |
|||
setPaymentError(null); |
|||
setIsInsufficientCoins(false); |
|||
setAppliedDiscount(null); |
|||
}} |
|||
buttons={ |
|||
<div className="space-y-3 w-full"> |
|||
{profile?.recommended_plan?.id ? ( |
|||
<DiscountWidget |
|||
objectId={profile.recommended_plan.id} |
|||
onDiscountApplied={(res) => setAppliedDiscount(res)} |
|||
onDiscountCleared={() => setAppliedDiscount(null)} |
|||
/> |
|||
) : null} |
|||
|
|||
{paymentError && ( |
|||
<div className="w-full bg-[#FEF2F2] text-[#B91C1C] text-xs p-3 rounded-[11px] text-center font-medium"> |
|||
{paymentError} |
|||
</div> |
|||
)} |
|||
|
|||
<div className="grid w-full grid-cols-[33fr_67fr] gap-3"> |
|||
<button |
|||
type="button" |
|||
disabled={ |
|||
paymentMutation.isPending || respondMutation.isPending |
|||
} |
|||
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0" |
|||
onClick={handleDecline} |
|||
> |
|||
<div className="inline-flex w-full items-center justify-center rounded-[11px] border border-[#747474] bg-transparent px-2 h-[52px] text-[16px] font-semibold text-[#747474] transition-opacity active:opacity-90 min-w-0"> |
|||
{respondMutation.isPending ? ( |
|||
<LoadingThreeDot /> |
|||
) : ( |
|||
<span className="truncate"> |
|||
{t["Decline"] || "Decline"} |
|||
</span> |
|||
)} |
|||
</div> |
|||
</button> |
|||
|
|||
{!hasEnoughCoins ? ( |
|||
<button |
|||
type="button" |
|||
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0" |
|||
onClick={() => buyHabibCoinPackages()} |
|||
> |
|||
<div className="inline-flex w-full items-center justify-center gap-2 rounded-[11px] bg-[#F0445B] px-4 h-[52px] text-[16px] font-semibold text-white shadow-[0_10px_18px_rgba(240,68,91,0.28)] transition-opacity active:opacity-90 min-w-0"> |
|||
<span className="truncate"> |
|||
{t["Buy Habib Coins"] || "Buy Habib Coins"} |
|||
</span> |
|||
</div> |
|||
</button> |
|||
) : ( |
|||
<button |
|||
type="button" |
|||
disabled={ |
|||
paymentMutation.isPending || respondMutation.isPending |
|||
} |
|||
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0" |
|||
onClick={handlePayment} |
|||
> |
|||
<div className="inline-flex w-full items-center justify-center gap-2 rounded-[11px] bg-[#F0445B] px-4 h-[52px] text-[16px] font-semibold text-white shadow-[0_10px_18px_rgba(240,68,91,0.28)] transition-opacity active:opacity-90 min-w-0"> |
|||
{paymentMutation.isPending ? ( |
|||
<LoadingThreeDot /> |
|||
) : ( |
|||
<> |
|||
<span className="truncate min-w-0"> |
|||
{t["Payment"] || "Payment"} |
|||
</span> |
|||
<span className="inline-flex items-center gap-1 rounded-[12px] bg-black/15 px-[9px] py-[4px] text-xs font-semibold leading-none text-white shrink-0 min-w-0 whitespace-nowrap"> |
|||
<Image |
|||
src="/assets/images/Inner Plugdsain Iframe.svg" |
|||
alt="" |
|||
aria-hidden="true" |
|||
width={16} |
|||
height={16} |
|||
className="shrink-0" |
|||
/> |
|||
<span className="truncate">{finalPrice}</span> |
|||
</span> |
|||
</> |
|||
)} |
|||
</div> |
|||
</button> |
|||
)} |
|||
</div> |
|||
</div> |
|||
} |
|||
/> |
|||
)} |
|||
|
|||
<MarriageAdvisorsOverlay open={isAdvisorOpen} onClose={closeAdvisors} /> |
|||
|
|||
<MatchProfileOverlay open={isProfileOpen} onClose={closeProfile} /> |
|||
</> |
|||
); |
|||
} |
|||
@ -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<MarriagePhoneFieldValue>; |
|||
|
|||
return ( |
|||
typeof phoneValue.countryCode === "string" && |
|||
typeof phoneValue.phoneNumber === "string" |
|||
); |
|||
} |
|||
|
|||
function titleFromKey(key: string) { |
|||
return key |
|||
.replace(/^q\d+[_-]?/i, "") |
|||
.replace(/[_-]+/g, " ") |
|||
.replace(/\s+/g, " ") |
|||
.trim() |
|||
.replace(/\b\w/g, (letter) => letter.toUpperCase()); |
|||
} |
|||
|
|||
function toDisplayField(field: MarriageField): DisplayField | null { |
|||
const value = formatFieldValue(field.value); |
|||
|
|||
if (!value) { |
|||
return null; |
|||
} |
|||
|
|||
return { |
|||
id: field.key || field.label || value, |
|||
label: field.label || titleFromKey(field.key), |
|||
value, |
|||
}; |
|||
} |
|||
|
|||
function pickField( |
|||
fields: MarriageField[], |
|||
candidates: readonly string[], |
|||
usedIndexes: Set<number>, |
|||
) { |
|||
const candidateSet = new Set(candidates.map(normalizeFieldName)); |
|||
for (const [fieldIndex, field] of fields.entries()) { |
|||
if (usedIndexes.has(fieldIndex)) { |
|||
continue; |
|||
} |
|||
|
|||
const displayField = toDisplayField(field); |
|||
|
|||
if ( |
|||
displayField && |
|||
[field.key, field.label].some((value) => |
|||
candidateSet.has(normalizeFieldName(value)), |
|||
) |
|||
) { |
|||
usedIndexes.add(fieldIndex); |
|||
return displayField; |
|||
} |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
function useMatchSummaryDisplay(matchSummary: MarriageMatchSummary | null) { |
|||
return useMemo(() => { |
|||
const fields = matchSummary?.public_info ?? []; |
|||
const usedIndexes = new Set<number>(); |
|||
const name = pickField(fields, fieldCandidates.name, usedIndexes); |
|||
const occupation = pickField( |
|||
fields, |
|||
fieldCandidates.occupation, |
|||
usedIndexes, |
|||
); |
|||
const age = pickField(fields, fieldCandidates.age, usedIndexes); |
|||
const city = pickField(fields, fieldCandidates.city, usedIndexes); |
|||
const maritalStatus = pickField( |
|||
fields, |
|||
fieldCandidates.maritalStatus, |
|||
usedIndexes, |
|||
); |
|||
const cityPreference = pickField( |
|||
fields, |
|||
fieldCandidates.cityPreference, |
|||
usedIndexes, |
|||
); |
|||
const extraFields = fields |
|||
.filter((_, index) => !usedIndexes.has(index)) |
|||
.map(toDisplayField) |
|||
.filter((field): field is DisplayField => Boolean(field)) |
|||
.slice(0, 4); |
|||
|
|||
return { |
|||
age, |
|||
city, |
|||
cityPreference, |
|||
extraFields, |
|||
maritalStatus, |
|||
name: |
|||
name?.value ?? |
|||
(matchSummary?.id ? `Profile #${matchSummary.id}` : null), |
|||
occupation, |
|||
}; |
|||
}, [matchSummary]); |
|||
} |
|||
|
|||
function FieldLine({ field }: { field: DisplayField }) { |
|||
return ( |
|||
<p className="break-words text-[10px] leading-[1.85] font-medium text-white"> |
|||
<span>{field.label}: </span> |
|||
<span>{field.value}</span> |
|||
</p> |
|||
); |
|||
} |
|||
|
|||
export default function NewMatchPage() { |
|||
const router = useRouter(); |
|||
const { dictionary: t, locale } = useI18n(); |
|||
const { top, bottom } = useViewPaddings(); |
|||
const { data: profile, isError, isLoading } = useMarriageProfileQuery(); |
|||
|
|||
const [isPaymentSheetOpen, setIsPaymentSheetOpen] = useState(false); |
|||
const [paymentError, setPaymentError] = useState<string | null>(null); |
|||
const [isInsufficientCoins, setIsInsufficientCoins] = useState(false); |
|||
|
|||
const paymentMutation = useHabcoinPaymentMutation(); |
|||
const caseId = profile?.active_case?.case_id; |
|||
const respondMutation = useRespondToMarriageCaseMutation(caseId ?? "", { |
|||
onSuccess: () => { |
|||
router.replace(localizePath("/finding-match", locale)); |
|||
dehydrate, |
|||
HydrationBoundary, |
|||
QueryClient, |
|||
} from "@tanstack/react-query"; |
|||
import { isAuthenticatedToken } from "@/lib/entry-route-cache"; |
|||
import { fetchProfileSSR } from "@/lib/ssr-fetch"; |
|||
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; |
|||
import NewMatchClient from "./new-match-client"; |
|||
|
|||
export const dynamic = "force-dynamic"; |
|||
|
|||
export default async function NewMatchPage() { |
|||
const cookieStore = await cookies(); |
|||
|
|||
const token = |
|||
cookieStore.get("HABIB_TOKEN")?.value ?? |
|||
cookieStore.get("habib_token")?.value; |
|||
|
|||
const queryClient = new QueryClient({ |
|||
defaultOptions: { |
|||
queries: { staleTime: 10 * 1000 }, |
|||
}, |
|||
}); |
|||
|
|||
const handlePayment = async () => { |
|||
const recommendedPlanId = profile?.recommended_plan?.id; |
|||
if (!recommendedPlanId) return; |
|||
|
|||
try { |
|||
setPaymentError(null); |
|||
setIsInsufficientCoins(false); |
|||
await paymentMutation.mutateAsync(recommendedPlanId); |
|||
setIsPaymentSheetOpen(false); |
|||
router.push(localizePath("/new-match/profile", locale)); |
|||
} catch (err: any) { |
|||
console.error("Payment failed", err); |
|||
const msg = |
|||
err?.response?.data?.error || err?.message || "Payment failed"; |
|||
const modalT = (t as any).paymentModal || {}; |
|||
if (msg === "Not enough coins") { |
|||
setIsInsufficientCoins(true); |
|||
setPaymentError( |
|||
modalT.insufficientCoins || |
|||
"Insufficient coin balance. Please recharge your account.", |
|||
); |
|||
} else { |
|||
setPaymentError(msg); |
|||
} |
|||
} |
|||
}; |
|||
|
|||
const handleDecline = async () => { |
|||
if (!caseId) return; |
|||
try { |
|||
await respondMutation.mutateAsync({ action: "reject" }); |
|||
} catch (err) { |
|||
console.error("Decline failed", err); |
|||
} |
|||
}; |
|||
|
|||
useEffect(() => { |
|||
if (!profile) { |
|||
return; |
|||
} |
|||
const targetPath = getSubmitPath(profile); |
|||
if (targetPath !== "/new-match") { |
|||
router.replace(localizePath(targetPath, locale)); |
|||
} |
|||
}, [profile, locale, router]); |
|||
|
|||
const isRedirecting = useMemo(() => { |
|||
if (!profile) return false; |
|||
return getSubmitPath(profile) !== "/new-match"; |
|||
}, [profile]); |
|||
|
|||
const matchSummary = profile?.match_summary ?? null; |
|||
const matchDisplay = useMatchSummaryDisplay(matchSummary); |
|||
|
|||
if (isLoading || isRedirecting) { |
|||
return ( |
|||
<> |
|||
<PageBackground /> |
|||
<main |
|||
style={{ |
|||
paddingBottom: `${bottom + 20}px`, |
|||
paddingTop: `${Math.max(12, top + 4)}px`, |
|||
}} |
|||
className="-mx-[17px] min-h-screen h-full text-center px-4" |
|||
> |
|||
<PageHeader /> |
|||
|
|||
<div> |
|||
{/* Header Section Skeleton */} |
|||
<section className="flex flex-col items-center mt-9"> |
|||
<LoadingSkeleton className="h-[60px] w-[60px] rounded-full" /> |
|||
<LoadingSkeleton className="h-6 w-[220px] mt-4" /> |
|||
<LoadingSkeleton className="h-4 w-[280px] mt-3" /> |
|||
<LoadingSkeleton className="h-4 w-[240px] mt-1.5" /> |
|||
</section> |
|||
|
|||
<div className="flex h-full flex-col justify-between"> |
|||
{/* Match Card Skeleton */} |
|||
<section className="mt-[36px] rounded-[15px] border border-white/80 bg-white px-[17px] pt-[18px] pb-[17px] shadow-[0_18px_45px_rgba(15,23,42,0.06)] flex flex-col items-center"> |
|||
{/* Name line */} |
|||
<LoadingSkeleton className="h-5 w-[140px]" /> |
|||
|
|||
{/* Subtitle / Details lines */} |
|||
<div className="mt-3 w-full flex flex-col items-center gap-2 min-h-[48px]"> |
|||
<LoadingSkeleton className="h-3 w-[180px]" /> |
|||
<LoadingSkeleton className="h-3 w-[120px]" /> |
|||
<LoadingSkeleton className="h-3 w-[150px]" /> |
|||
</div> |
|||
|
|||
{/* Button skeleton */} |
|||
<LoadingSkeleton className="mt-[15px] h-[40px] w-full rounded-[10px]" /> |
|||
</section> |
|||
|
|||
{/* Advisor Card Skeleton */} |
|||
<section className="mt-[36px]"> |
|||
<div className="rounded-[13px] border border-white/80 bg-white px-3 py-3.5 text-left shadow-[0_18px_45px_rgba(15,23,42,0.06)] backdrop-blur-sm"> |
|||
<LoadingSkeleton className="h-[16px] w-[140px]" /> |
|||
<div className="mt-2 space-y-1"> |
|||
<LoadingSkeleton className="h-[10px] w-[260px]" /> |
|||
<LoadingSkeleton className="h-[10px] w-[180px]" /> |
|||
</div> |
|||
|
|||
<div className="mt-4 flex items-center justify-between gap-3"> |
|||
<div className="flex items-center pl-1"> |
|||
<LoadingSkeleton className="h-[30px] w-[30px] rounded-full border-2 border-white" /> |
|||
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" /> |
|||
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" /> |
|||
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" /> |
|||
</div> |
|||
|
|||
<LoadingSkeleton className="h-[38px] w-[120px] rounded-[9px]" /> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
</div> |
|||
</div> |
|||
</main> |
|||
</> |
|||
); |
|||
if (isAuthenticatedToken(token)) { |
|||
await queryClient.prefetchQuery({ |
|||
queryKey: marriageQueryKeys.profile(), |
|||
queryFn: () => fetchProfileSSR(token!), |
|||
}); |
|||
} |
|||
const pairedFields = [matchDisplay.age, matchDisplay.city].filter( |
|||
(field): field is DisplayField => Boolean(field), |
|||
); |
|||
|
|||
const isFemaleProfile = profile?.gender === "female"; |
|||
const matchHeadingTitle = isFemaleProfile |
|||
? t["New Marriage Proposal"] |
|||
: t["YOU HAVE A NEW MATCH!"]; |
|||
const matchHeadingDescription = isFemaleProfile |
|||
? t[ |
|||
"A suitable match has been found for you. If approved, your profile will be evaluated to proceed with the introduction process." |
|||
] |
|||
: t[ |
|||
"If you proceed, we will notify the other party, and upon their approval, you can view each other's contact information." |
|||
]; |
|||
|
|||
const isMale = profile?.gender === "male"; |
|||
const hasActiveSub = !!profile?.active_subscription; |
|||
const isMatchAvailable = !!profile?.match_summary; |
|||
|
|||
return ( |
|||
<> |
|||
<PageBackground /> |
|||
|
|||
<main |
|||
style={{ |
|||
paddingBottom: `${profile?.can_edit_profile === false ? 100 + bottom : 20 + bottom}px`, |
|||
paddingTop: `${Math.max(12, top + 4)}px`, |
|||
}} |
|||
className="-mx-[17px] min-h-screen h-full text-center px-4" |
|||
> |
|||
<PageHeader /> |
|||
|
|||
<div className=""> |
|||
<section className="flex flex-col items-center mt-9"> |
|||
<div |
|||
aria-hidden="true" |
|||
className="relative flex h-[60px] w-[60px] items-center justify-center rounded-full bg-[#FF4E67] shadow-[0_12px_28px_rgba(240,68,91,0.22)]" |
|||
> |
|||
<Image |
|||
src={"/assets/images/Ellipse 1210.svg"} |
|||
width={70} |
|||
height={70} |
|||
alt="notification" |
|||
/> |
|||
</div> |
|||
<h2 className="text-[22px] font-bold mt-3.5"> |
|||
{matchHeadingTitle} |
|||
</h2> |
|||
<p className="mt-[11px] max-w-[322px] text-[12px] leading-[1.35] font-semibold text-[#7C7C7C]"> |
|||
{matchHeadingDescription} |
|||
</p> |
|||
</section> |
|||
<div className="flex h-full flex-col justify-between"> |
|||
{isLoading ? ( |
|||
<section className="mt-[36px] rounded-[15px] border border-white/80 bg-white px-[17px] pt-[18px] pb-[17px] shadow-[0_18px_45px_rgba(15,23,42,0.06)]"> |
|||
<div className="flex flex-col items-center py-2"> |
|||
{/* Name line */} |
|||
<LoadingSkeleton className="h-5 w-[140px]" /> |
|||
|
|||
{/* Subtitle / Details lines */} |
|||
<div className="mt-3 w-full flex flex-col items-center gap-2 min-h-[48px]"> |
|||
<LoadingSkeleton className="h-3 w-[180px]" /> |
|||
<LoadingSkeleton className="h-3 w-[120px]" /> |
|||
<LoadingSkeleton className="h-3 w-[150px]" /> |
|||
</div> |
|||
|
|||
{/* Button skeleton */} |
|||
<LoadingSkeleton className="mt-[15px] h-[40px] w-full rounded-[10px]" /> |
|||
</div> |
|||
</section> |
|||
) : ( |
|||
<section className="mt-[36px] rounded-[15px] bg-[linear-gradient(180deg,#F0445B_0%,#F4556E_100%)] px-[17px] pt-[18px] pb-[17px] text-white shadow-[0_18px_38px_rgba(240,68,91,0.25)]"> |
|||
{isError ? ( |
|||
<p className="py-8 text-[13px] font-semibold"> |
|||
Unable to load match summary. |
|||
</p> |
|||
) : matchSummary ? ( |
|||
<> |
|||
<h2 className="break-words text-[13px] leading-[1.4] font-bold"> |
|||
<span>Name: </span> |
|||
<span>{matchDisplay.name}</span> |
|||
</h2> |
|||
|
|||
<div className="mt-[3px] min-h-[48px]"> |
|||
{matchDisplay.occupation ? ( |
|||
<FieldLine field={matchDisplay.occupation} /> |
|||
) : null} |
|||
|
|||
{pairedFields.length ? ( |
|||
<p className="break-words text-[10px] leading-[1.85] font-medium text-white"> |
|||
{pairedFields.map((field, index) => ( |
|||
<span key={field.id}> |
|||
{index > 0 ? <span> | </span> : null} |
|||
<span> |
|||
{field.label}: {field.value} |
|||
</span> |
|||
</span> |
|||
))} |
|||
</p> |
|||
) : null} |
|||
|
|||
{matchDisplay.maritalStatus ? ( |
|||
<FieldLine field={matchDisplay.maritalStatus} /> |
|||
) : null} |
|||
{matchDisplay.cityPreference ? ( |
|||
<FieldLine field={matchDisplay.cityPreference} /> |
|||
) : null} |
|||
</div> |
|||
|
|||
<button |
|||
type="button" |
|||
onClick={() => { |
|||
if (isMale && !hasActiveSub) { |
|||
setIsPaymentSheetOpen(true); |
|||
} else { |
|||
router.push( |
|||
localizePath("/new-match/profile", locale), |
|||
); |
|||
} |
|||
}} |
|||
className="mt-[15px] inline-flex w-full items-center justify-center rounded-[10px] border-none bg-white h-[40px] text-[16px] font-semibold text-[#F0445B] no-underline shadow-none hover:bg-white/95 transition-colors cursor-pointer" |
|||
> |
|||
{t["View more details"]} |
|||
</button> |
|||
</> |
|||
) : ( |
|||
<p className="py-8 text-[13px] font-semibold"> |
|||
No match summary is available yet. |
|||
</p> |
|||
)} |
|||
</section> |
|||
)} |
|||
<div className="w-full space-y-3"> |
|||
<AdvisorActionsCard |
|||
className="mt-[36px]" |
|||
title={t["Get an advisor"]} |
|||
description={ |
|||
t[ |
|||
"Not sure what to do next? Our psychology section is here to guide you at every step." |
|||
] |
|||
} |
|||
avatars={advisorAvatars} |
|||
extraCount={7} |
|||
getAdvisorLabel={t["Get Advisor"]} |
|||
getAdvisorHref="/marriage-advisors" |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</main> |
|||
|
|||
{profile?.can_edit_profile === false && ( |
|||
<div |
|||
style={{ paddingBottom: `${16 + bottom}px` }} |
|||
className="fixed inset-x-0 bottom-0 z-20 mx-auto w-full sm:max-w-[375px] bg-background/95 px-[17px] pt-3 pb-[16px] backdrop-blur-md" |
|||
> |
|||
<div |
|||
className="inline-flex w-full items-center justify-center gap-2 rounded-[9px] border-none bg-[#D1D1D6] px-4 h-[52px] text-center text-[#747474] shadow-none" |
|||
role="status" |
|||
> |
|||
<FaLock aria-hidden="true" className="h-3.5 w-3.5 shrink-0" /> |
|||
<span className="group-16 leading-none font-semibold"> |
|||
{t["Profile is locked"]} |
|||
</span> |
|||
</div> |
|||
</div> |
|||
)} |
|||
|
|||
{isPaymentSheetOpen && ( |
|||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 backdrop-blur-xs p-4 animate-in fade-in duration-200"> |
|||
<div |
|||
style={{ paddingBottom: `calc(20px + ${bottom}px)` }} |
|||
className="relative w-full max-w-[375px] rounded-[24px] bg-white px-5 pt-6 shadow-[0_-8px_30px_rgba(0,0,0,0.12)] border border-slate-100 animate-in slide-in-from-bottom duration-300 text-center" |
|||
> |
|||
<button |
|||
type="button" |
|||
className="absolute top-4 right-4 text-[#8F8F8F] hover:text-[#5F5F5F] transition-colors" |
|||
onClick={() => { |
|||
setIsPaymentSheetOpen(false); |
|||
setPaymentError(null); |
|||
setIsInsufficientCoins(false); |
|||
}} |
|||
> |
|||
<IoClose className="text-[22px]" /> |
|||
</button> |
|||
|
|||
<div className="flex flex-col items-center"> |
|||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#F0445B] text-white mb-4"> |
|||
<Image |
|||
src="/assets/images/noun-wedding-rings-6540466 1.svg" |
|||
alt="" |
|||
width={28} |
|||
height={28} |
|||
className="text-white" |
|||
/> |
|||
</div> |
|||
|
|||
<h3 className="group-16 font-bold text-gray-900 mb-2"> |
|||
{t["Verification & Subscription Activation"] || |
|||
"Verification & Subscription Activation"} |
|||
</h3> |
|||
|
|||
<p className="text-xs text-gray-500 leading-relaxed mb-4 text-center"> |
|||
{t[ |
|||
"This payment acts as a verification process for your account and activates a 3-month subscription to receive new case introductions. The cost of this subscription is 50 Habib Coins." |
|||
] || |
|||
"This payment acts as a verification process for your account and activates a 3-month subscription to receive new case introductions. The cost of this subscription is 50 Habib Coins."} |
|||
</p> |
|||
|
|||
<div className="w-full bg-gray-50 rounded-xl p-3 mb-4 text-center"> |
|||
<span className="text-[10px] text-gray-400 block mb-0.5"> |
|||
{t["Valid for 3 months"] || "Valid for 3 months"} |
|||
</span> |
|||
<span className="text-base font-bold text-[#FF4E67]"> |
|||
{t["50 Coins"] || "50 Habib Coins"} |
|||
</span> |
|||
</div> |
|||
|
|||
<p className="text-[10px] text-gray-400 leading-normal mb-6 text-center"> |
|||
{t[ |
|||
"Please note that there is no guarantee for a specific number of cases, and the volume of incoming cases is solely subject to profile compatibility with other users." |
|||
] || |
|||
"Please note that there is no guarantee for a specific number of cases, and the volume of incoming cases is solely subject to profile compatibility with other users."} |
|||
</p> |
|||
|
|||
{paymentError && ( |
|||
<div className="w-full bg-[#FEF2F2] text-[#B91C1C] text-xs p-3 rounded-xl mb-4 text-center font-medium"> |
|||
{paymentError} |
|||
</div> |
|||
)} |
|||
|
|||
{isInsufficientCoins && isInFlutterWebView() && ( |
|||
<button |
|||
type="button" |
|||
className="appearance-none border-0 bg-transparent p-0 text-left w-full mb-4" |
|||
onClick={() => buyHabibCoinPackages()} |
|||
> |
|||
<div className="inline-flex w-full items-center justify-center gap-2 rounded-[18px] bg-[#00AC78] px-4 h-[52px] text-[16px] font-semibold text-white shadow-[0_10px_18px_rgba(0,172,120,0.28)] transition-opacity active:opacity-90"> |
|||
<Image |
|||
src="/assets/images/Inner Plugdsain Iframe.svg" |
|||
alt="" |
|||
aria-hidden="true" |
|||
width={18} |
|||
height={18} |
|||
className="shrink-0" |
|||
/> |
|||
<span className="truncate"> |
|||
{t["Buy Habib Coins"] || "Buy Habib Coins"} |
|||
</span> |
|||
</div> |
|||
</button> |
|||
)} |
|||
|
|||
<div className="grid w-full grid-cols-[33fr_67fr] gap-3"> |
|||
<button |
|||
type="button" |
|||
disabled={ |
|||
paymentMutation.isPending || respondMutation.isPending |
|||
} |
|||
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0" |
|||
onClick={handleDecline} |
|||
> |
|||
<div className="inline-flex w-full items-center justify-center rounded-[18px] border border-[#9A9A9A] bg-[#F7F7F7] px-2 h-[52px] text-[16px] font-bold text-[#8B8B8B] shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] transition-opacity active:opacity-90 min-w-0"> |
|||
{respondMutation.isPending ? ( |
|||
<LoadingThreeDot /> |
|||
) : ( |
|||
<span className="truncate"> |
|||
{t["Decline"] || "Decline"} |
|||
</span> |
|||
)} |
|||
</div> |
|||
</button> |
|||
|
|||
<button |
|||
type="button" |
|||
disabled={ |
|||
paymentMutation.isPending || respondMutation.isPending |
|||
} |
|||
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0" |
|||
onClick={handlePayment} |
|||
> |
|||
<div className="inline-flex w-full items-center justify-center gap-3 rounded-[18px] bg-[#F0445B] px-4 h-[52px] text-[16px] font-semibold text-white shadow-[0_10px_18px_rgba(240,68,91,0.28)] transition-opacity active:opacity-90 min-w-0"> |
|||
{paymentMutation.isPending ? ( |
|||
<LoadingThreeDot /> |
|||
) : ( |
|||
<> |
|||
<span className="truncate min-w-0"> |
|||
{t["Pay"] || "Pay"} |
|||
</span> |
|||
<span className="inline-flex items-center gap-1 rounded-full bg-[#E43B51] p-1.5 text-xs font-semibold leading-none text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.12)] shrink-0 min-w-0 whitespace-nowrap"> |
|||
<Image |
|||
src="/assets/images/Inner Plugdsain Iframe.svg" |
|||
alt="" |
|||
aria-hidden="true" |
|||
width={18} |
|||
height={18} |
|||
className="shrink-0" |
|||
/> |
|||
<span className="truncate">50</span> |
|||
</span> |
|||
</> |
|||
)} |
|||
</div> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
)} |
|||
</> |
|||
<HydrationBoundary state={dehydrate(queryClient)}> |
|||
<NewMatchClient /> |
|||
</HydrationBoundary> |
|||
); |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
export default function Loading() { |
|||
return ( |
|||
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]"> |
|||
{/* Header placeholder */} |
|||
<div |
|||
style={{ paddingTop: "max(12px, calc(var(--safe-top) + 4px))" }} |
|||
className="shrink-0 px-[17px] pb-3" |
|||
> |
|||
<div className="h-10" /> |
|||
</div> |
|||
{/* Center spinner */} |
|||
<div className="flex min-h-0 flex-1 items-center justify-center"> |
|||
<span |
|||
role="status" |
|||
aria-label="Loading section" |
|||
className="size-5 animate-spin rounded-full border-2 border-[#E03950]/25 border-t-[#E03950] motion-reduce:animate-none" |
|||
/> |
|||
</div> |
|||
</main> |
|||
); |
|||
} |
|||
@ -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<QuestionListItem | null>(null); |
|||
const questionListItems = useMemo( |
|||
() => convertOverviewToFrontendItems(overview), |
|||
[overview], |
|||
); |
|||
const [localAssessmentProgress, setLocalAssessmentProgress] = useState< |
|||
Map<string, number> |
|||
>(new Map()); |
|||
|
|||
useEffect(() => { |
|||
const next = new Map<string, number>(); |
|||
for (const slug of ["personality_test", "glasser_5_needs_test"]) { |
|||
try { |
|||
const raw = window.localStorage.getItem(`marriage:tests:${slug}:draft`); |
|||
const draft = raw ? JSON.parse(raw) : null; |
|||
const progress = getAssessmentLocalProgress(draft, false); |
|||
if (progress > 0) next.set(slug, progress); |
|||
} catch { |
|||
// Ignore malformed local drafts.
|
|||
} |
|||
} |
|||
setLocalAssessmentProgress(next); |
|||
}, [overview]); |
|||
|
|||
const sectionProgressBySlug = useMemo(() => { |
|||
const progressBySlug = new Map<string, number>(); |
|||
|
|||
if (overview?.progress?.sections_progress) { |
|||
Object.entries(overview.progress.sections_progress).forEach( |
|||
([slug, prog]) => { |
|||
progressBySlug.set( |
|||
slug, |
|||
Math.max(0, Math.min(100, Math.round(prog.completion_percent))), |
|||
); |
|||
}, |
|||
); |
|||
} |
|||
localAssessmentProgress.forEach((progress, slug) => { |
|||
if ((progressBySlug.get(slug) ?? 0) < 100) |
|||
progressBySlug.set(slug, progress); |
|||
}); |
|||
|
|||
// Add fallback for combined section from the schema adapter which attaches it to questionListItems directly
|
|||
questionListItems.forEach((item) => { |
|||
if (!progressBySlug.has(item.slug)) { |
|||
progressBySlug.set(item.slug, item.progress); |
|||
} |
|||
}); |
|||
|
|||
return progressBySlug; |
|||
}, [overview, questionListItems, localAssessmentProgress]); |
|||
|
|||
const requiredQuestionListItems = useMemo( |
|||
() => questionListItems.filter((item) => Boolean(item.required)), |
|||
[questionListItems], |
|||
); |
|||
|
|||
const completedRequiredSections = useMemo( |
|||
() => |
|||
requiredQuestionListItems.filter( |
|||
(item) => (sectionProgressBySlug.get(item.slug) ?? 0) >= 100, |
|||
).length, |
|||
[requiredQuestionListItems, sectionProgressBySlug], |
|||
); |
|||
const [displayedRequiredSections, setDisplayedRequiredSections] = useState( |
|||
() => completedRequiredSections, |
|||
); |
|||
|
|||
useEffect(() => { |
|||
if (displayedRequiredSections === completedRequiredSections) return; |
|||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { |
|||
setDisplayedRequiredSections(completedRequiredSections); |
|||
return; |
|||
} |
|||
const direction = |
|||
completedRequiredSections > displayedRequiredSections ? 1 : -1; |
|||
const distance = Math.abs( |
|||
completedRequiredSections - displayedRequiredSections, |
|||
); |
|||
const interval = window.setInterval( |
|||
() => { |
|||
setDisplayedRequiredSections((current) => { |
|||
const next = current + direction; |
|||
if (next === completedRequiredSections) |
|||
window.clearInterval(interval); |
|||
return next; |
|||
}); |
|||
}, |
|||
Math.max(90, Math.floor(500 / distance)), |
|||
); |
|||
return () => window.clearInterval(interval); |
|||
}, [completedRequiredSections, displayedRequiredSections]); |
|||
|
|||
const hasValidRequiredContract = |
|||
requiredQuestionListItems.length === REQUIRED_PROFILE_SECTION_COUNT; |
|||
|
|||
useEffect(() => { |
|||
if (overview && !hasValidRequiredContract) { |
|||
console.error("Required section contract mismatch", { |
|||
expected: REQUIRED_PROFILE_SECTION_COUNT, |
|||
actual: requiredQuestionListItems.length, |
|||
}); |
|||
} |
|||
}, [hasValidRequiredContract, overview, requiredQuestionListItems.length]); |
|||
|
|||
const allRequiredSectionsCompleted = useMemo(() => { |
|||
if (!hasValidRequiredContract) { |
|||
return false; |
|||
} |
|||
|
|||
return requiredQuestionListItems.every((item) => { |
|||
const progress = sectionProgressBySlug.get(item.slug) ?? 0; |
|||
return progress >= 100; |
|||
if (isAuthenticatedToken(token)) { |
|||
await queryClient.prefetchQuery({ |
|||
queryKey: marriageQueryKeys.profile(), |
|||
queryFn: () => fetchProfileSSR(token!), |
|||
}); |
|||
}, [ |
|||
hasValidRequiredContract, |
|||
requiredQuestionListItems, |
|||
sectionProgressBySlug, |
|||
]); |
|||
|
|||
const profileStatus = profile?.status; |
|||
const isProfileSuspended = profileStatus === "suspended"; |
|||
const isProfileBlocked = |
|||
profileStatus === "in_case" || profileStatus === "matched"; |
|||
const canStartMatch = |
|||
!isProfileSuspended && !isProfileBlocked && allRequiredSectionsCompleted; |
|||
const [isSyncError, setIsSyncError] = useState(false); |
|||
const [isSyncing, setIsSyncing] = useState(false); |
|||
const [toastMessage, setToastMessage] = useState<string | null>(null); |
|||
|
|||
const syncPromiseRef = useRef<Promise<void> | null>(null); |
|||
|
|||
const syncPendingAnswers = useCallback(async () => { |
|||
if (!overview) return; |
|||
if (syncPromiseRef.current) { |
|||
return syncPromiseRef.current; |
|||
} |
|||
|
|||
const task = (async () => { |
|||
const pendingSections: Array<{ |
|||
storageKey: string; |
|||
storedValue: { |
|||
current_step: number; |
|||
fields: MarriageField[]; |
|||
pending_keys: string[]; |
|||
pending_sync: boolean; |
|||
}; |
|||
fields: MarriageField[]; |
|||
slug: string; |
|||
}> = []; |
|||
for (const item of questionListItems) { |
|||
if ( |
|||
item.slug === "personality_test" || |
|||
item.slug === "glasser_5_needs_test" |
|||
) { |
|||
continue; |
|||
} |
|||
const storageKey = getQuestionAnswersStorageKey(item.slug); |
|||
const rawValue = window.localStorage.getItem(storageKey); |
|||
if (!rawValue) continue; |
|||
|
|||
let storedValue: any; |
|||
try { |
|||
storedValue = JSON.parse(rawValue); |
|||
} catch { |
|||
continue; |
|||
} |
|||
|
|||
if ( |
|||
!storedValue || |
|||
!storedValue.pending_sync || |
|||
!Array.isArray(storedValue.fields) |
|||
) { |
|||
continue; |
|||
} |
|||
const pendingKeys = new Set<string>( |
|||
Array.isArray(storedValue.pending_keys) |
|||
? storedValue.pending_keys |
|||
: storedValue.fields.map((field: { key: string }) => field.key), |
|||
); |
|||
const pendingFields = storedValue.fields.filter( |
|||
(field: { key: string }) => pendingKeys.has(field.key), |
|||
); |
|||
if (pendingFields.length === 0) continue; |
|||
pendingSections.push({ |
|||
storageKey, |
|||
storedValue, |
|||
fields: pendingFields, |
|||
slug: item.slug, |
|||
}); |
|||
} |
|||
|
|||
if (pendingSections.length === 0) return; |
|||
const result = await updateMarriageSectionData(pendingSections[0].slug, { |
|||
current_step: pendingSections[0].storedValue.current_step, |
|||
fields: pendingSections.flatMap((section) => section.fields), |
|||
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<string>()); |
|||
const enqueueViewportPrefetch = useCallback( |
|||
(item: QuestionListItem) => { |
|||
if ( |
|||
item.slug === "personality_test" || |
|||
item.slug === "glasser_5_needs_test" || |
|||
viewportPrefetchSlugs.current.has(item.slug) |
|||
) { |
|||
return; |
|||
} |
|||
viewportPrefetchSlugs.current.add(item.slug); |
|||
viewportPrefetchChain.current = viewportPrefetchChain.current |
|||
.catch(() => undefined) |
|||
.then(() => |
|||
queryClient.fetchQuery({ |
|||
queryKey: marriageQueryKeys.formSection( |
|||
"profile", |
|||
item.slug, |
|||
locale, |
|||
), |
|||
queryFn: () => getFormSection("profile", item.slug, locale), |
|||
staleTime: 30 * 1000, |
|||
}), |
|||
) |
|||
.then(() => undefined); |
|||
}, |
|||
[locale, queryClient], |
|||
); |
|||
|
|||
const prefetchQueueStarted = useRef(false); |
|||
useEffect(() => { |
|||
if (prefetchQueueStarted.current || !overview) return; |
|||
const profileSections = questionListItems |
|||
.filter( |
|||
(item) => |
|||
item.slug !== "personality_test" && |
|||
item.slug !== "glasser_5_needs_test", |
|||
) |
|||
.sort((first, second) => { |
|||
const firstPriority = |
|||
first.required && |
|||
(sectionProgressBySlug.get(first.slug) ?? first.progress) < 100 |
|||
? 0 |
|||
: 1; |
|||
const secondPriority = |
|||
second.required && |
|||
(sectionProgressBySlug.get(second.slug) ?? second.progress) < 100 |
|||
? 0 |
|||
: 1; |
|||
return firstPriority - secondPriority; |
|||
}); |
|||
if (profileSections.length === 0) return; |
|||
prefetchQueueStarted.current = true; |
|||
let cancelled = false; |
|||
void prefetchSectionsWithBoundedConcurrency( |
|||
profileSections, |
|||
(item) => |
|||
queryClient.fetchQuery({ |
|||
queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), |
|||
queryFn: () => getFormSection("profile", item.slug, locale), |
|||
staleTime: 30 * 1000, |
|||
}), |
|||
() => cancelled, |
|||
); |
|||
return () => { |
|||
cancelled = true; |
|||
}; |
|||
}, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]); |
|||
|
|||
useEffect(() => { |
|||
void syncPendingAnswers().catch((err) => { |
|||
console.warn("Background draft sync:", err); |
|||
}); |
|||
const handleOnline = () => { |
|||
void syncPendingAnswers().catch((err) => { |
|||
console.warn("Background draft sync on online:", err); |
|||
}); |
|||
}; |
|||
window.addEventListener("online", handleOnline); |
|||
return () => window.removeEventListener("online", handleOnline); |
|||
}, [syncPendingAnswers]); |
|||
|
|||
useEffect(() => { |
|||
if (startMatchMutation.isError) { |
|||
setToastMessage( |
|||
t[ |
|||
"Sending the match request failed. Please check your connection and try again." |
|||
], |
|||
); |
|||
} |
|||
}, [startMatchMutation.isError, t]); |
|||
|
|||
const handleCloseToast = () => { |
|||
setToastMessage(null); |
|||
setIsSyncError(false); |
|||
startMatchMutation.reset(); |
|||
}; |
|||
|
|||
const isStartMatchDisabled = |
|||
startMatchMutation.isPending || isSyncing || !canStartMatch; |
|||
|
|||
const hasIncompleteOptionalSections = useMemo(() => { |
|||
return questionListItems.some( |
|||
(item) => |
|||
!item.required && (sectionProgressBySlug.get(item.slug) ?? 0) < 100, |
|||
); |
|||
}, [questionListItems, sectionProgressBySlug]); |
|||
|
|||
const handleStartMatch = async () => { |
|||
if (isStartMatchDisabled || isSyncing) { |
|||
return; |
|||
} |
|||
|
|||
setIsSyncing(true); |
|||
setIsSyncError(false); |
|||
|
|||
try { |
|||
await syncPendingAnswers(); |
|||
startMatchMutation.mutate(); |
|||
} catch (err) { |
|||
console.error("Failed to sync pending sections:", err); |
|||
setIsSyncError(true); |
|||
setToastMessage( |
|||
t[ |
|||
"Sending the match request failed. Please check your connection and try again." |
|||
] ?? "Sending the match request failed. Please check your connection and try again." |
|||
); |
|||
} finally { |
|||
setIsSyncing(false); |
|||
} |
|||
}; |
|||
|
|||
if ( |
|||
isProfileLoading || |
|||
isSectionsLoading || |
|||
isSchemaLoading || |
|||
isProfileRedirecting |
|||
) { |
|||
return ( |
|||
<> |
|||
<PageBackground disabled /> |
|||
<main |
|||
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }} |
|||
className="-mx-[17px] relative min-h-screen overflow-x-clip bg-[#F5F5F5] px-[17px] pt-0" |
|||
> |
|||
<div className="pointer-events-none absolute inset-x-0 top-0 h-[240px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.7)_42%,rgba(255,255,255,0)_100%)]" /> |
|||
<div className="pointer-events-none absolute -top-16 left-1/2 h-[220px] w-[220px] -translate-x-1/2 rounded-full bg-white/70 blur-3xl" /> |
|||
|
|||
<header |
|||
style={{ |
|||
paddingTop: "max(12px, calc(var(--safe-top) + 4px))", |
|||
}} |
|||
className="sticky top-0 z-20 -mx-[17px] flex items-center justify-between bg-[#F5F5F5]/90 px-[17px] pb-3 backdrop-blur-md" |
|||
> |
|||
<NavigationButton |
|||
icon="back" |
|||
iconLabel={t["Close questions list"]} |
|||
onClick={(e) => { |
|||
e.preventDefault(); |
|||
if (typeof window !== "undefined" && (window as any).HabibApp) { |
|||
(window as any).HabibApp.postMessage( |
|||
JSON.stringify({ action: "close_service" }), |
|||
); |
|||
} else { |
|||
router.push("/"); |
|||
} |
|||
}} |
|||
/> |
|||
<h1 className="group-16 font-semibold text-[#151515]"> |
|||
{t["Profile registration"]} |
|||
</h1> |
|||
<NavigationButton |
|||
icon="document" |
|||
iconLabel={t["Support"]} |
|||
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]" |
|||
onClick={() => setIsTermsOpen(true)} |
|||
disableHelpModal |
|||
/> |
|||
</header> |
|||
<SectionsRequest |
|||
sections={overview?.sections} |
|||
isOpen={isTermsOpen || undefined} |
|||
onClose={() => setIsTermsOpen(false)} |
|||
/> |
|||
|
|||
<div className="relative mt-4 space-y-5"> |
|||
<RequiredStepsCard |
|||
completed={displayedRequiredSections} |
|||
total={REQUIRED_PROFILE_SECTION_COUNT} |
|||
/> |
|||
|
|||
{/* Section Card Skeletons (solid blocks like the Meet/checkup |
|||
AppShimmer loading — one sweep band runs across each card) */} |
|||
<div className="space-y-3"> |
|||
{Array.from({ length: 6 }).map((_, idx) => ( |
|||
<div key={idx} className="shimmer-bg h-[84px] rounded-[20px]" /> |
|||
))} |
|||
</div> |
|||
</div> |
|||
|
|||
<FixToTheEnd> |
|||
<Button aria-label={t["Find Matches"]} disabled> |
|||
<span className="flex items-center justify-center gap-2.5"> |
|||
<Image |
|||
src="/assets/images/noun-wedding-rings-6540466 1.svg" |
|||
alt="" |
|||
aria-hidden="true" |
|||
width={28} |
|||
height={28} |
|||
className="shrink-0" |
|||
loading="eager" |
|||
fetchPriority="high" |
|||
/> |
|||
<span className="leading-none font-semibold"> |
|||
{t["Submit"]} |
|||
</span> |
|||
</span> |
|||
</Button> |
|||
</FixToTheEnd> |
|||
</main> |
|||
</> |
|||
); |
|||
} |
|||
|
|||
if (isProfileError || isSchemaError) { |
|||
return ( |
|||
<> |
|||
<PageBackground disabled /> |
|||
<main |
|||
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }} |
|||
className="-mx-[17px] relative min-h-screen overflow-x-clip bg-[#F5F5F5] px-[17px] pt-0" |
|||
> |
|||
<div className="pointer-events-none absolute inset-x-0 top-0 h-[240px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.7)_42%,rgba(255,255,255,0)_100%)]" /> |
|||
<div className="pointer-events-none absolute -top-16 left-1/2 h-[220px] w-[220px] -translate-x-1/2 rounded-full bg-white/70 blur-3xl" /> |
|||
|
|||
<header |
|||
style={{ |
|||
paddingTop: "max(12px, calc(var(--safe-top) + 4px))", |
|||
}} |
|||
className="sticky top-0 z-20 -mx-[17px] flex items-center justify-between bg-[#F5F5F5]/90 px-[17px] pb-3 backdrop-blur-md" |
|||
> |
|||
<NavigationButton |
|||
icon="back" |
|||
iconLabel={t["Close questions list"]} |
|||
onClick={(e) => { |
|||
e.preventDefault(); |
|||
if (typeof window !== "undefined" && (window as any).HabibApp) { |
|||
(window as any).HabibApp.postMessage( |
|||
JSON.stringify({ action: "close_service" }), |
|||
); |
|||
} else { |
|||
router.push("/"); |
|||
} |
|||
}} |
|||
/> |
|||
<h1 className="group-16 font-semibold text-[#151515]"> |
|||
{t["Profile registration"]} |
|||
</h1> |
|||
<NavigationButton |
|||
icon="document" |
|||
iconLabel={t["Support"]} |
|||
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]" |
|||
onClick={() => setIsTermsOpen(true)} |
|||
disableHelpModal |
|||
/> |
|||
</header> |
|||
<SectionsRequest |
|||
sections={overview?.sections} |
|||
isOpen={isTermsOpen || undefined} |
|||
onClose={() => setIsTermsOpen(false)} |
|||
/> |
|||
|
|||
<DataErrorState |
|||
onRetry={() => { |
|||
if (isProfileError) refetchProfile(); |
|||
if (isSchemaError) refetchOverview(); |
|||
}} |
|||
/> |
|||
</main> |
|||
</> |
|||
); |
|||
} |
|||
|
|||
return ( |
|||
<> |
|||
{toastMessage && ( |
|||
<ErrorToast message={toastMessage} onClose={handleCloseToast} /> |
|||
)} |
|||
{isOptionalInfoSheetOpen ? ( |
|||
<InformationSheet |
|||
icon="warning" |
|||
title={t["Important Note"]} |
|||
description={ |
|||
<p className="px-0.5 text-sm leading-[1.45] text-[#2D2D2D]"> |
|||
{ |
|||
t[ |
|||
"You've completed all required fields. However, filling in all sections will help us find better matches for you" |
|||
] |
|||
} |
|||
</p> |
|||
} |
|||
onClose={() => setIsOptionalInfoSheetOpen(false)} |
|||
buttons={({ close }) => ( |
|||
<div className="grid w-full grid-cols-[33fr_67fr] gap-3"> |
|||
<Button |
|||
className="rounded-[11px] border border-[#9B9B9B] bg-transparent text-[#8B8B8B] min-w-0" |
|||
onClick={close} |
|||
variant="outlined" |
|||
> |
|||
<span className="truncate">{t["Cancel"]}</span> |
|||
</Button> |
|||
<Button |
|||
className="rounded-[11px] min-w-0" |
|||
disabled={isStartMatchDisabled} |
|||
onClick={() => { |
|||
close(); |
|||
handleStartMatch(); |
|||
}} |
|||
> |
|||
<span className="truncate">{t["Submit"]}</span> |
|||
</Button> |
|||
</div> |
|||
)} |
|||
/> |
|||
) : null} |
|||
{selectedSection ? ( |
|||
<InformationSheet |
|||
icon={null} |
|||
title={({ close }) => ( |
|||
<span className="flex w-full items-start justify-between gap-3 text-left"> |
|||
<span className="group-14 leading-5 font-bold tracking-normal text-[#8B8B8B]"> |
|||
{selectedSection.title} |
|||
</span> |
|||
<button |
|||
type="button" |
|||
aria-label={`Close ${selectedSection.title} explanation`} |
|||
className="-mt-0.5 flex size-6 shrink-0 items-center justify-center text-[#8F8F8F]" |
|||
onClick={close} |
|||
> |
|||
<IoClose aria-hidden="true" className="text-[22px]" /> |
|||
</button> |
|||
</span> |
|||
)} |
|||
description={ |
|||
<p className="px-0.5 group-14 leading-[1.45] text-[#2D2D2D]"> |
|||
{selectedSection.summary} |
|||
</p> |
|||
} |
|||
onClose={() => setSelectedSection(null)} |
|||
className="text-left" |
|||
/> |
|||
) : null} |
|||
<SectionsRequest |
|||
sections={overview?.sections} |
|||
isOpen={isTermsOpen || undefined} |
|||
onClose={() => setIsTermsOpen(false)} |
|||
/> |
|||
<PageBackground disabled /> |
|||
|
|||
<main |
|||
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }} |
|||
className="-mx-[17px] relative min-h-screen overflow-x-clip bg-[#F5F5F5] px-[17px] pt-0" |
|||
> |
|||
<div className="pointer-events-none absolute inset-x-0 top-0 h-[240px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.7)_42%,rgba(255,255,255,0)_100%)]" /> |
|||
<div className="pointer-events-none absolute -top-16 left-1/2 h-[220px] w-[220px] -translate-x-1/2 rounded-full bg-white/70 blur-3xl" /> |
|||
|
|||
<header |
|||
style={{ |
|||
paddingTop: "max(12px, calc(var(--safe-top) + 4px))", |
|||
}} |
|||
className="sticky top-0 z-20 -mx-[17px] flex items-center justify-between bg-[#F5F5F5]/90 px-[17px] pb-3 backdrop-blur-md" |
|||
> |
|||
<NavigationButton |
|||
icon="back" |
|||
iconLabel={t["Close questions list"]} |
|||
onClick={(e) => { |
|||
e.preventDefault(); |
|||
if (typeof window !== "undefined" && (window as any).HabibApp) { |
|||
(window as any).HabibApp.postMessage( |
|||
JSON.stringify({ action: "close_service" }), |
|||
); |
|||
} else { |
|||
router.push("/"); |
|||
} |
|||
}} |
|||
/> |
|||
<h1 className="group-16 font-semibold text-[#151515]"> |
|||
{t["Profile registration"]} |
|||
</h1> |
|||
<NavigationButton |
|||
icon="document" |
|||
iconLabel={t["Support"]} |
|||
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]" |
|||
onClick={() => setIsTermsOpen(true)} |
|||
disableHelpModal |
|||
/> |
|||
</header> |
|||
|
|||
<div className="relative"> |
|||
<div className="mt-4"> |
|||
<RequiredStepsCard |
|||
completed={displayedRequiredSections} |
|||
total={REQUIRED_PROFILE_SECTION_COUNT} |
|||
/> |
|||
</div> |
|||
|
|||
<section className="mt-5 space-y-3"> |
|||
{questionListItems.map((item) => ( |
|||
<QuestionCard |
|||
key={item.slug} |
|||
item={item} |
|||
progress={sectionProgressBySlug.get(item.slug) ?? null} |
|||
onInfoClick={(section) => setSelectedSection(section)} |
|||
onNearViewport={enqueueViewportPrefetch} |
|||
onPrefetch={prefetchSection} |
|||
/> |
|||
))} |
|||
</section> |
|||
</div> |
|||
|
|||
<FixToTheEnd> |
|||
<Button |
|||
aria-label={t["Find Matches"]} |
|||
disabled={isStartMatchDisabled} |
|||
isLoading={startMatchMutation.isPending || isSyncing} |
|||
onClick={() => { |
|||
if (hasIncompleteOptionalSections) { |
|||
setIsOptionalInfoSheetOpen(true); |
|||
return; |
|||
} |
|||
|
|||
handleStartMatch(); |
|||
}} |
|||
> |
|||
<span className="flex items-center justify-center gap-2.5"> |
|||
<Image |
|||
src="/assets/images/noun-wedding-rings-6540466 1.svg" |
|||
alt="" |
|||
aria-hidden="true" |
|||
width={28} |
|||
height={28} |
|||
className="shrink-0" |
|||
// Above-the-fold CTA asset: fetch it with the initial
|
|||
// document instead of letting next/image lazy-load it.
|
|||
loading="eager" |
|||
fetchPriority="high" |
|||
/> |
|||
<span className="leading-none font-semibold">{t["Submit"]}</span> |
|||
</span> |
|||
</Button> |
|||
</FixToTheEnd> |
|||
</main> |
|||
</> |
|||
<HydrationBoundary state={dehydrate(queryClient)}> |
|||
<QuestionsListClient /> |
|||
</HydrationBoundary> |
|||
); |
|||
} |
|||
@ -0,0 +1,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<QuestionListItem | null>(null); |
|||
const [activeSectionSlug, setActiveSectionSlug] = useState<string | null>( |
|||
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<string, number> |
|||
>(new Map()); |
|||
|
|||
useEffect(() => { |
|||
const next = new Map<string, number>(); |
|||
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<string, number>(); |
|||
|
|||
if (overview?.progress?.sections_progress) { |
|||
Object.entries(overview.progress.sections_progress).forEach( |
|||
([slug, prog]) => { |
|||
progressBySlug.set( |
|||
slug, |
|||
Math.max(0, Math.min(100, Math.round(prog.completion_percent))), |
|||
); |
|||
}, |
|||
); |
|||
} |
|||
localAssessmentProgress.forEach((progress, slug) => { |
|||
if ((progressBySlug.get(slug) ?? 0) < 100) |
|||
progressBySlug.set(slug, progress); |
|||
}); |
|||
|
|||
// Add fallback for combined section from the schema adapter which attaches it to questionListItems directly
|
|||
questionListItems.forEach((item) => { |
|||
if (!progressBySlug.has(item.slug)) { |
|||
progressBySlug.set(item.slug, item.progress); |
|||
} |
|||
}); |
|||
|
|||
return progressBySlug; |
|||
}, [overview, questionListItems, localAssessmentProgress]); |
|||
|
|||
const requiredQuestionListItems = useMemo( |
|||
() => questionListItems.filter((item) => Boolean(item.required)), |
|||
[questionListItems], |
|||
); |
|||
|
|||
const completedRequiredSections = useMemo( |
|||
() => |
|||
requiredQuestionListItems.filter( |
|||
(item) => (sectionProgressBySlug.get(item.slug) ?? 0) >= 100, |
|||
).length, |
|||
[requiredQuestionListItems, sectionProgressBySlug], |
|||
); |
|||
const [displayedRequiredSections, setDisplayedRequiredSections] = useState( |
|||
() => completedRequiredSections, |
|||
); |
|||
|
|||
useEffect(() => { |
|||
if (displayedRequiredSections === completedRequiredSections) return; |
|||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { |
|||
setDisplayedRequiredSections(completedRequiredSections); |
|||
return; |
|||
} |
|||
const direction = |
|||
completedRequiredSections > displayedRequiredSections ? 1 : -1; |
|||
const distance = Math.abs( |
|||
completedRequiredSections - displayedRequiredSections, |
|||
); |
|||
const interval = window.setInterval( |
|||
() => { |
|||
setDisplayedRequiredSections((current) => { |
|||
const next = current + direction; |
|||
if (next === completedRequiredSections) |
|||
window.clearInterval(interval); |
|||
return next; |
|||
}); |
|||
}, |
|||
Math.max(90, Math.floor(500 / distance)), |
|||
); |
|||
return () => window.clearInterval(interval); |
|||
}, [completedRequiredSections, displayedRequiredSections]); |
|||
|
|||
const hasValidRequiredContract = |
|||
requiredQuestionListItems.length === REQUIRED_PROFILE_SECTION_COUNT; |
|||
|
|||
useEffect(() => { |
|||
if (overview && !hasValidRequiredContract) { |
|||
console.error("Required section contract mismatch", { |
|||
expected: REQUIRED_PROFILE_SECTION_COUNT, |
|||
actual: requiredQuestionListItems.length, |
|||
}); |
|||
} |
|||
}, [hasValidRequiredContract, overview, requiredQuestionListItems.length]); |
|||
|
|||
const allRequiredSectionsCompleted = useMemo(() => { |
|||
if (!hasValidRequiredContract) { |
|||
return false; |
|||
} |
|||
|
|||
return requiredQuestionListItems.every((item) => { |
|||
const progress = sectionProgressBySlug.get(item.slug) ?? 0; |
|||
return progress >= 100; |
|||
}); |
|||
}, [ |
|||
hasValidRequiredContract, |
|||
requiredQuestionListItems, |
|||
sectionProgressBySlug, |
|||
]); |
|||
|
|||
const profileStatus = profile?.status; |
|||
const isProfileSuspended = profileStatus === "suspended"; |
|||
const isProfileBlocked = |
|||
profileStatus === "in_case" || profileStatus === "matched"; |
|||
const canStartMatch = |
|||
!isProfileSuspended && !isProfileBlocked && allRequiredSectionsCompleted; |
|||
const [isSyncError, setIsSyncError] = useState(false); |
|||
const [isSyncing, setIsSyncing] = useState(false); |
|||
const [toastMessage, setToastMessage] = useState<string | null>(null); |
|||
|
|||
const syncPromiseRef = useRef<Promise<void> | null>(null); |
|||
|
|||
const syncPendingAnswers = useCallback(async () => { |
|||
if (!overview || !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 ( |
|||
<> |
|||
<PageBackground disabled /> |
|||
<main |
|||
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }} |
|||
className="-mx-[17px] relative min-h-screen overflow-x-clip bg-[#F5F5F5] px-[17px] pt-0" |
|||
> |
|||
<div className="pointer-events-none absolute inset-x-0 top-0 h-[240px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.7)_42%,rgba(255,255,255,0)_100%)]" /> |
|||
<div className="pointer-events-none absolute -top-16 left-1/2 h-[220px] w-[220px] -translate-x-1/2 rounded-full bg-white/70 blur-3xl" /> |
|||
|
|||
<header |
|||
style={{ |
|||
paddingTop: "max(12px, calc(var(--safe-top) + 4px))", |
|||
}} |
|||
className="sticky top-0 z-20 -mx-[17px] flex items-center justify-between bg-[#F5F5F5]/90 px-[17px] pb-3 backdrop-blur-md" |
|||
> |
|||
<NavigationButton |
|||
icon="back" |
|||
iconLabel={t["Close questions list"]} |
|||
onClick={(e) => { |
|||
e.preventDefault(); |
|||
if (typeof window !== "undefined" && (window as any).HabibApp) { |
|||
(window as any).HabibApp.postMessage( |
|||
JSON.stringify({ action: "close_service" }), |
|||
); |
|||
} else { |
|||
router.push("/"); |
|||
} |
|||
}} |
|||
/> |
|||
<h1 className="group-16 font-semibold text-[#151515]"> |
|||
{t["Profile registration"]} |
|||
</h1> |
|||
<NavigationButton |
|||
icon="document" |
|||
iconLabel={t["Support"]} |
|||
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]" |
|||
/> |
|||
</header> |
|||
|
|||
<div className="relative mt-4 space-y-5"> |
|||
<RequiredStepsCard |
|||
completed={displayedRequiredSections} |
|||
total={REQUIRED_PROFILE_SECTION_COUNT} |
|||
/> |
|||
|
|||
{/* Section Card Skeletons (solid blocks like the Meet/checkup |
|||
AppShimmer loading — one sweep band runs across each card) */} |
|||
<div className="space-y-3"> |
|||
{Array.from({ length: 6 }).map((_, idx) => ( |
|||
<div key={idx} className="shimmer-bg h-[84px] rounded-[20px]" /> |
|||
))} |
|||
</div> |
|||
</div> |
|||
|
|||
<FixToTheEnd> |
|||
<Button aria-label={t["Find Matches"]} disabled> |
|||
<span className="flex items-center justify-center gap-2.5"> |
|||
<Image |
|||
src="/assets/images/noun-wedding-rings-6540466 1.svg" |
|||
alt="" |
|||
aria-hidden="true" |
|||
width={28} |
|||
height={28} |
|||
className="shrink-0" |
|||
loading="eager" |
|||
fetchPriority="high" |
|||
/> |
|||
<span className="leading-none font-semibold"> |
|||
{t["Submit"]} |
|||
</span> |
|||
</span> |
|||
</Button> |
|||
</FixToTheEnd> |
|||
</main> |
|||
</> |
|||
); |
|||
} |
|||
|
|||
if (isProfileError || isSchemaError) { |
|||
return ( |
|||
<> |
|||
<PageBackground disabled /> |
|||
<main |
|||
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }} |
|||
className="-mx-[17px] relative min-h-screen overflow-x-clip bg-[#F5F5F5] px-[17px] pt-0" |
|||
> |
|||
<div className="pointer-events-none absolute inset-x-0 top-0 h-[240px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.7)_42%,rgba(255,255,255,0)_100%)]" /> |
|||
<div className="pointer-events-none absolute -top-16 left-1/2 h-[220px] w-[220px] -translate-x-1/2 rounded-full bg-white/70 blur-3xl" /> |
|||
|
|||
<header |
|||
style={{ |
|||
paddingTop: "max(12px, calc(var(--safe-top) + 4px))", |
|||
}} |
|||
className="sticky top-0 z-20 -mx-[17px] flex items-center justify-between bg-[#F5F5F5]/90 px-[17px] pb-3 backdrop-blur-md" |
|||
> |
|||
<NavigationButton |
|||
icon="back" |
|||
iconLabel={t["Close questions list"]} |
|||
onClick={(e) => { |
|||
e.preventDefault(); |
|||
if (typeof window !== "undefined" && (window as any).HabibApp) { |
|||
(window as any).HabibApp.postMessage( |
|||
JSON.stringify({ action: "close_service" }), |
|||
); |
|||
} else { |
|||
router.push("/"); |
|||
} |
|||
}} |
|||
/> |
|||
<h1 className="group-16 font-semibold text-[#151515]"> |
|||
{t["Profile registration"]} |
|||
</h1> |
|||
<NavigationButton |
|||
icon="document" |
|||
iconLabel={t["Support"]} |
|||
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]" |
|||
/> |
|||
</header> |
|||
|
|||
<DataErrorState |
|||
onRetry={() => { |
|||
if (isProfileError) refetchProfile(); |
|||
if (isSchemaError) refetchOverview(); |
|||
}} |
|||
/> |
|||
</main> |
|||
</> |
|||
); |
|||
} |
|||
|
|||
return ( |
|||
<> |
|||
{toastMessage && ( |
|||
<ErrorToast message={toastMessage} onClose={handleCloseToast} /> |
|||
)} |
|||
{isOptionalInfoSheetOpen ? ( |
|||
<InformationSheet |
|||
icon="warning" |
|||
title={t["Important Note"]} |
|||
description={ |
|||
<p className="px-0.5 text-sm leading-[1.45] text-[#2D2D2D]"> |
|||
{ |
|||
t[ |
|||
"You've completed all required fields. However, filling in all sections will help us find better matches for you" |
|||
] |
|||
} |
|||
</p> |
|||
} |
|||
onClose={() => setIsOptionalInfoSheetOpen(false)} |
|||
buttons={({ close }) => ( |
|||
<div className="grid w-full grid-cols-[33fr_67fr] gap-3"> |
|||
<Button |
|||
className="rounded-[11px] border border-[#9B9B9B] bg-transparent text-[#8B8B8B] min-w-0" |
|||
onClick={close} |
|||
variant="outlined" |
|||
> |
|||
<span className="truncate">{t["Cancel"]}</span> |
|||
</Button> |
|||
<Button |
|||
className="rounded-[11px] min-w-0" |
|||
disabled={isStartMatchDisabled} |
|||
onClick={() => { |
|||
close(); |
|||
handleStartMatch(); |
|||
}} |
|||
> |
|||
<span className="truncate">{t["Submit"]}</span> |
|||
</Button> |
|||
</div> |
|||
)} |
|||
/> |
|||
) : null} |
|||
{selectedSection ? ( |
|||
<InformationSheet |
|||
icon={null} |
|||
title={({ close }) => ( |
|||
<span className="flex w-full items-start justify-between gap-3 text-left"> |
|||
<span className="group-14 leading-5 font-bold tracking-normal text-[#8B8B8B]"> |
|||
{selectedSection.title} |
|||
</span> |
|||
<button |
|||
type="button" |
|||
aria-label={`Close ${selectedSection.title} explanation`} |
|||
className="-mt-0.5 flex size-6 shrink-0 items-center justify-center text-[#8F8F8F]" |
|||
onClick={close} |
|||
> |
|||
<IoClose aria-hidden="true" className="text-[22px]" /> |
|||
</button> |
|||
</span> |
|||
)} |
|||
description={ |
|||
<p className="px-0.5 group-14 leading-[1.45] text-[#2D2D2D]"> |
|||
{selectedSection.summary} |
|||
</p> |
|||
} |
|||
onClose={() => setSelectedSection(null)} |
|||
className="text-left" |
|||
/> |
|||
) : null} |
|||
<SectionsRequest sections={overview?.sections} /> |
|||
{process.env.NODE_ENV === "development" ? <DevTapInstrumentation /> : null} |
|||
<PageBackground disabled /> |
|||
|
|||
<main |
|||
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }} |
|||
className="-mx-[17px] relative min-h-screen overflow-x-clip bg-[#F5F5F5] px-[17px] pt-0" |
|||
> |
|||
<div className="pointer-events-none absolute inset-x-0 top-0 h-[240px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.7)_42%,rgba(255,255,255,0)_100%)]" /> |
|||
<div className="pointer-events-none absolute -top-16 left-1/2 h-[220px] w-[220px] -translate-x-1/2 rounded-full bg-white/70 blur-3xl" /> |
|||
|
|||
<header |
|||
style={{ |
|||
paddingTop: "max(12px, calc(var(--safe-top) + 4px))", |
|||
}} |
|||
className="sticky top-0 z-20 -mx-[17px] flex items-center justify-between bg-[#F5F5F5]/90 px-[17px] pb-3 backdrop-blur-md" |
|||
> |
|||
<NavigationButton |
|||
icon="back" |
|||
iconLabel={t["Close questions list"]} |
|||
onClick={(e) => { |
|||
e.preventDefault(); |
|||
if (typeof window !== "undefined" && (window as any).HabibApp) { |
|||
(window as any).HabibApp.postMessage( |
|||
JSON.stringify({ action: "close_service" }), |
|||
); |
|||
} else { |
|||
router.push("/"); |
|||
} |
|||
}} |
|||
/> |
|||
<h1 className="group-16 font-semibold text-[#151515]"> |
|||
{t["Profile registration"]} |
|||
</h1> |
|||
<NavigationButton |
|||
icon="document" |
|||
iconLabel={t["Support"]} |
|||
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]" |
|||
/> |
|||
</header> |
|||
|
|||
<div className="relative"> |
|||
<div className="mt-4"> |
|||
<RequiredStepsCard |
|||
completed={displayedRequiredSections} |
|||
total={REQUIRED_PROFILE_SECTION_COUNT} |
|||
/> |
|||
</div> |
|||
|
|||
<section className="mt-5 space-y-3"> |
|||
{questionListItems.map((item) => ( |
|||
<QuestionCard |
|||
key={item.slug} |
|||
item={item} |
|||
progress={sectionProgressBySlug.get(item.slug) ?? null} |
|||
onInfoClick={(section) => setSelectedSection(section)} |
|||
onPrefetch={prefetchSection} |
|||
onNearViewport={prefetchSection} |
|||
onSelect={(item) => handleOpenSection(item.slug)} |
|||
/> |
|||
))} |
|||
</section> |
|||
</div> |
|||
|
|||
<FixToTheEnd> |
|||
<Button |
|||
aria-label={t["Find Matches"]} |
|||
disabled={isStartMatchDisabled} |
|||
isLoading={startMatchMutation.isPending || isSyncing} |
|||
onClick={() => { |
|||
if (hasIncompleteOptionalSections) { |
|||
setIsOptionalInfoSheetOpen(true); |
|||
return; |
|||
} |
|||
|
|||
handleStartMatch(); |
|||
}} |
|||
> |
|||
<span className="flex items-center justify-center gap-2.5"> |
|||
<Image |
|||
src="/assets/images/noun-wedding-rings-6540466 1.svg" |
|||
alt="" |
|||
aria-hidden="true" |
|||
width={28} |
|||
height={28} |
|||
className="shrink-0" |
|||
// Above-the-fold CTA asset: fetch it with the initial
|
|||
// document instead of letting next/image lazy-load it.
|
|||
loading="eager" |
|||
fetchPriority="high" |
|||
/> |
|||
<span className="leading-none font-semibold">{t["Submit"]}</span> |
|||
</span> |
|||
</Button> |
|||
</FixToTheEnd> |
|||
</main> |
|||
|
|||
<SectionOverlayHost |
|||
open={Boolean(activeSectionSlug)} |
|||
onClose={handleCloseSection} |
|||
> |
|||
{activeSectionItem ? ( |
|||
<QuestionDetailClient |
|||
closeLabel={t["Close questions list"] ?? "Close"} |
|||
continueLabel={t["Continue"] ?? "Continue"} |
|||
description={activeSectionItem.summary} |
|||
informationLabel={t["Support"] ?? "Support"} |
|||
itemSlug={activeSectionItem.slug} |
|||
locale={locale} |
|||
questionsListHref={localizePath("/questions-list", locale)} |
|||
title={activeSectionItem.title} |
|||
onClose={handleCloseSection} |
|||
/> |
|||
) : null} |
|||
</SectionOverlayHost> |
|||
</> |
|||
); |
|||
} |
|||
@ -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<number, () => 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); |
|||
}); |
|||
}); |
|||
@ -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<MarriagePhoneFieldValue>; |
|||
|
|||
return ( |
|||
typeof phoneValue.countryCode === "string" && |
|||
typeof phoneValue.phoneNumber === "string" |
|||
); |
|||
} |
|||
|
|||
function getContactInfoPhoneItems( |
|||
contactInfoFields: MarriageField[] | null | undefined, |
|||
): ContactInfoPhoneItem[] { |
|||
if (!contactInfoFields) { |
|||
return []; |
|||
} |
|||
|
|||
return contactInfoFields |
|||
.map((field) => { |
|||
const phoneNumber = sanitizePhoneNumber(field.value); |
|||
|
|||
if (!phoneNumber) { |
|||
return null; |
|||
} |
|||
|
|||
const rawLabel = field.label || field.key; |
|||
const label = rawLabel |
|||
.replace(/\s+with\s+Country\s+Code/gi, "") |
|||
.replace(/\s+با\s+کد\s+کشور/g, "") |
|||
.trim(); |
|||
|
|||
return { |
|||
key: field.key, |
|||
label, |
|||
phoneNumber, |
|||
}; |
|||
}) |
|||
.filter((item): item is ContactInfoPhoneItem => item !== null); |
|||
} |
|||
|
|||
function ContactInfoPhoneCard({ item }: { item: ContactInfoPhoneItem }) { |
|||
return ( |
|||
<div className="flex items-center justify-between rounded-[15px] border border-[#E4E4E4] bg-[#FBFBFB] px-4 py-3.5 shadow-[0_4px_12px_rgba(0,0,0,0.03)] gap-4"> |
|||
<div className="text-left min-w-0"> |
|||
<p className="text-xs font-semibold text-[#8F8F8F] break-words"> |
|||
{item.label} |
|||
</p> |
|||
<p className="text-base font-bold text-[#1F1F1F] dir-ltr"> |
|||
{item.phoneNumber} |
|||
</p> |
|||
</div> |
|||
|
|||
<div className="flex items-center gap-2 shrink-0"> |
|||
<a |
|||
href={`tel:${item.phoneNumber}`} |
|||
onClick={(e) => { |
|||
if (typeof window !== "undefined" && "HabibApp" in window) { |
|||
e.preventDefault(); |
|||
try { |
|||
const app = ( |
|||
window as Window & { |
|||
HabibApp?: { postMessage: (msg: string) => void }; |
|||
} |
|||
).HabibApp; |
|||
app?.postMessage( |
|||
JSON.stringify({ |
|||
action: "open_external_url", |
|||
data: { |
|||
url: `tel:${item.phoneNumber}`, |
|||
mode: "externalApplication", |
|||
title: "phone_call", |
|||
}, |
|||
}), |
|||
); |
|||
} catch (err) { |
|||
console.error("Error calling HabibApp bridge", err); |
|||
} |
|||
} |
|||
}} |
|||
className="inline-flex p-3 items-center justify-center rounded-[10px] bg-[#F0445B] text-white shrink-0" |
|||
> |
|||
<Image |
|||
src={"/assets/images/Vecfdastor.svg"} |
|||
width={16} |
|||
height={16} |
|||
alt="call" |
|||
/> |
|||
</a> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export default function RequestAcceptedPage() { |
|||
const { dictionary: t, locale } = useI18n(); |
|||
const router = useRouter(); |
|||
const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false); |
|||
const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] = |
|||
useState(false); |
|||
const [isContactInfoSheetOpen, setIsContactInfoSheetOpen] = useState(false); |
|||
const [isSubscriptionSheetOpen, setIsSubscriptionSheetOpen] = useState(false); |
|||
const [paymentError, setPaymentError] = useState<string | null>(null); |
|||
const [isInsufficientCoins, setIsInsufficientCoins] = useState(false); |
|||
const [isOutcomeSheetOpen, setIsOutcomeSheetOpen] = useState(false); |
|||
const [isNoContactConfirmOpen, setIsNoContactConfirmOpen] = useState(false); |
|||
const [isContactReceivedConfirmOpen, setIsContactReceivedConfirmOpen] = |
|||
useState(false); |
|||
const [noContactReportedSuccess, setNoContactReportedSuccess] = |
|||
useState(false); |
|||
const [hasConfirmedFemaleContact, setHasConfirmedFemaleContact] = |
|||
useState(false); |
|||
const profileHref = localizePath("/new-match/profile", locale); |
|||
const { data: profile, isLoading } = useMarriageProfileQuery({ |
|||
refetchInterval: 3000, |
|||
}); |
|||
|
|||
const isFemaleProfile = profile?.gender === "female"; |
|||
const contactSharedAtStr = profile?.active_case?.contact_shared_at; |
|||
|
|||
useEffect(() => { |
|||
if (!profile || noContactReportedSuccess) { |
|||
return; |
|||
} |
|||
const targetPath = getSubmitPath(profile); |
|||
if (targetPath !== "/request-accepted") { |
|||
router.replace(localizePath(targetPath, locale)); |
|||
} |
|||
}, [profile, router, locale, noContactReportedSuccess]); |
|||
|
|||
const isRedirecting = useMemo(() => { |
|||
if (!profile) return false; |
|||
return getSubmitPath(profile) !== "/request-accepted"; |
|||
}, [profile]); |
|||
|
|||
const caseId = profile?.active_case?.case_id; |
|||
const caseStatus = profile?.active_case?.status; |
|||
const isFemaleContactConfirmed = |
|||
isFemaleProfile && |
|||
(caseStatus === "contacted" || hasConfirmedFemaleContact); |
|||
const recommendedPlanId = profile?.recommended_plan?.id; |
|||
const paymentMutation = useHabcoinPaymentMutation(); |
|||
const outcomeMutation = useSubmitMarriageOutcomeMutation(caseId ?? ""); |
|||
const contactStatusMutation = useSubmitMarriageContactStatusMutation( |
|||
caseId ?? "", |
|||
{ |
|||
onSuccess: (_data, variables) => { |
|||
if (variables?.action === "no_contact") { |
|||
setNoContactReportedSuccess(true); |
|||
} else { |
|||
if (!isFemaleProfile) { |
|||
router.push(localizePath("/finding-match", locale)); |
|||
} |
|||
} |
|||
}, |
|||
dehydrate, |
|||
HydrationBoundary, |
|||
QueryClient, |
|||
} from "@tanstack/react-query"; |
|||
import { isAuthenticatedToken } from "@/lib/entry-route-cache"; |
|||
import { fetchProfileSSR } from "@/lib/ssr-fetch"; |
|||
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; |
|||
import RequestAcceptedClient from "./request-accepted-client"; |
|||
|
|||
export const dynamic = "force-dynamic"; |
|||
|
|||
export default async function RequestAcceptedPage() { |
|||
const cookieStore = await cookies(); |
|||
|
|||
const token = |
|||
cookieStore.get("HABIB_TOKEN")?.value ?? |
|||
cookieStore.get("habib_token")?.value; |
|||
|
|||
const queryClient = new QueryClient({ |
|||
defaultOptions: { |
|||
queries: { staleTime: 10 * 1000 }, |
|||
}, |
|||
); |
|||
const contactInfoQuery = useMarriageContactInfoQuery(caseId, { |
|||
enabled: false, |
|||
}); |
|||
|
|||
if (isLoading || isRedirecting) { |
|||
return <PageLoadingSkeleton />; |
|||
} |
|||
|
|||
const titleText = isFemaleProfile |
|||
? t["Request Approved"] |
|||
: caseStatus === "payment_done" || caseStatus === "contacted" |
|||
? t["Contact info released"] |
|||
: t["Request approved!"]; |
|||
const primaryActionText = isFemaleProfile |
|||
? t["No Contact Received"] |
|||
: t["View profile"]; |
|||
const secondaryActionText = isFemaleProfile |
|||
? t["Contact Received"] |
|||
: caseStatus === "payment_done" || caseStatus === "contacted" |
|||
? t["View contact number"] |
|||
: t["Pay and get contact"]; |
|||
const contactInfoPhoneItems = getContactInfoPhoneItems( |
|||
contactInfoQuery.data?.contact_info, |
|||
); |
|||
|
|||
const handleSecondaryAction = async () => { |
|||
if (isFemaleProfile) { |
|||
setIsContactReceivedConfirmOpen(true); |
|||
return; |
|||
} |
|||
|
|||
if (caseStatus === "female_accepted" || caseStatus === "payment_pending") { |
|||
setIsSubscriptionSheetOpen(true); |
|||
return; |
|||
} |
|||
|
|||
if (caseStatus === "payment_done" || caseStatus === "contacted") { |
|||
if (!caseId) { |
|||
return; |
|||
} |
|||
|
|||
if (!contactInfoQuery.data) { |
|||
await contactInfoQuery.refetch(); |
|||
} |
|||
|
|||
setIsContactInfoSheetOpen(true); |
|||
} |
|||
}; |
|||
|
|||
const handlePayment = async () => { |
|||
if (!recommendedPlanId || paymentMutation.isPending) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
setPaymentError(null); |
|||
setIsInsufficientCoins(false); |
|||
const paymentResponse = |
|||
await paymentMutation.mutateAsync(recommendedPlanId); |
|||
const paymentUrl = extractHabcoinPaymentUrl(paymentResponse); |
|||
|
|||
if (paymentUrl) { |
|||
window.location.assign(paymentUrl); |
|||
return; |
|||
} |
|||
|
|||
setIsSubscriptionSheetOpen(false); |
|||
|
|||
if (caseId) { |
|||
await contactInfoQuery.refetch(); |
|||
setIsContactInfoSheetOpen(true); |
|||
} |
|||
} catch (err: any) { |
|||
console.error("Habcoin payment request failed", err); |
|||
const msg = |
|||
err?.response?.data?.error || err?.message || "Payment failed"; |
|||
if (msg === "Not enough coins") { |
|||
setIsInsufficientCoins(true); |
|||
setPaymentError( |
|||
t["Insufficient coin balance. Please recharge your account."] || |
|||
"Insufficient coin balance. Please recharge your account.", |
|||
); |
|||
} else { |
|||
setPaymentError(msg); |
|||
} |
|||
} |
|||
}; |
|||
|
|||
const handleNoContactReport = async () => { |
|||
if (!caseId || contactStatusMutation.isPending) return; |
|||
await contactStatusMutation.mutateAsync({ |
|||
action: "no_contact", |
|||
custom_note: |
|||
"No contact reported by female candidate after decision window", |
|||
if (isAuthenticatedToken(token)) { |
|||
await queryClient.prefetchQuery({ |
|||
queryKey: marriageQueryKeys.profile(), |
|||
queryFn: () => fetchProfileSSR(token!), |
|||
}); |
|||
}; |
|||
|
|||
const isFinalized = |
|||
caseStatus === "finalized" || profile?.status === "matched"; |
|||
} |
|||
|
|||
return ( |
|||
<> |
|||
<PageBackground /> |
|||
|
|||
{isCallResultSheetOpen ? ( |
|||
<CallResultSheet |
|||
onClose={() => setIsCallResultSheetOpen(false)} |
|||
onOtherReasonsClick={() => setIsDismissReasonSheetOpen(true)} |
|||
onSubmit={async (value) => { |
|||
if (caseId) { |
|||
await contactStatusMutation.mutateAsync({ |
|||
action: "contacted", |
|||
custom_note: value, |
|||
}); |
|||
} |
|||
}} |
|||
/> |
|||
) : null} |
|||
|
|||
{isContactReceivedConfirmOpen ? ( |
|||
<FemaleConsentSheet |
|||
title={t.Confirm} |
|||
description={ |
|||
<p className="font-bold text-[#00AC78] text-center my-4 text-base"> |
|||
{t["Are you sure contact has been made?"]} |
|||
</p> |
|||
} |
|||
buttons={ |
|||
<SwipeButton |
|||
theme="green" |
|||
text={t["Confirm"]} |
|||
onCancel={() => setIsContactReceivedConfirmOpen(false)} |
|||
onSuccess={async () => { |
|||
setIsContactReceivedConfirmOpen(false); |
|||
setHasConfirmedFemaleContact(true); |
|||
|
|||
if (!caseId) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
await contactStatusMutation.mutateAsync({ |
|||
action: "contacted", |
|||
custom_note: |
|||
"Contact received confirmed by female candidate", |
|||
}); |
|||
} catch (error) { |
|||
// The confirmation screen must advance immediately after a swipe.
|
|||
// Keep the local state visible while the profile query retries.
|
|||
console.error("Unable to persist received contact", error); |
|||
} |
|||
}} |
|||
/> |
|||
} |
|||
onClose={() => setIsContactReceivedConfirmOpen(false)} |
|||
closeOnOutside={true} |
|||
/> |
|||
) : null} |
|||
|
|||
{isDismissReasonSheetOpen ? ( |
|||
<DismissReasonSheet |
|||
onClose={() => setIsDismissReasonSheetOpen(false)} |
|||
onSubmit={async (value) => { |
|||
if (caseId) { |
|||
await outcomeMutation.mutateAsync({ |
|||
status: "failure", |
|||
custom_note: value, |
|||
}); |
|||
} |
|||
}} |
|||
/> |
|||
) : null} |
|||
|
|||
{isOutcomeSheetOpen ? ( |
|||
isFemaleProfile ? ( |
|||
<FemaleOutcomeSheet |
|||
onClose={() => setIsOutcomeSheetOpen(false)} |
|||
onSubmit={async (status, reason) => { |
|||
if (status === "success") { |
|||
// If they confirm they are in the acquaintance/proposal process and nothing is finalized yet:
|
|||
// No change is made to the profile, we just close the sheet.
|
|||
setIsOutcomeSheetOpen(false); |
|||
} else { |
|||
// If they cancel:
|
|||
if (caseId) { |
|||
await outcomeMutation.mutateAsync({ |
|||
status: "failure", |
|||
custom_note: reason, |
|||
}); |
|||
} |
|||
} |
|||
}} |
|||
/> |
|||
) : ( |
|||
<OutcomeSelectionSheet |
|||
onClose={() => setIsOutcomeSheetOpen(false)} |
|||
onSubmit={async (status) => { |
|||
if (status === "success") { |
|||
if (caseId) { |
|||
await outcomeMutation.mutateAsync({ |
|||
status: "success", |
|||
}); |
|||
} |
|||
} else { |
|||
setIsDismissReasonSheetOpen(true); |
|||
} |
|||
}} |
|||
/> |
|||
) |
|||
) : null} |
|||
|
|||
{isContactInfoSheetOpen ? ( |
|||
<FemaleConsentSheet |
|||
title={t["Contact Detail"]} |
|||
description={ |
|||
t[ |
|||
"Please mention during the call that you were introduced by the Habib Marriage app." |
|||
] |
|||
} |
|||
buttons={ |
|||
contactInfoPhoneItems.length ? ( |
|||
<div className="space-y-4"> |
|||
{contactInfoPhoneItems.map((item) => ( |
|||
<ContactInfoPhoneCard key={item.key} item={item} /> |
|||
))} |
|||
</div> |
|||
) : ( |
|||
<div className="rounded-[12px] bg-[#ECECEC] px-4 py-3 group-12 font-semibold text-[#555]"> |
|||
{t["Contact information is not available yet."]} |
|||
</div> |
|||
) |
|||
} |
|||
onClose={() => setIsContactInfoSheetOpen(false)} |
|||
/> |
|||
) : null} |
|||
|
|||
{isSubscriptionSheetOpen ? ( |
|||
<SubscriptionRequiredSheet |
|||
onClose={() => { |
|||
setIsSubscriptionSheetOpen(false); |
|||
setPaymentError(null); |
|||
setIsInsufficientCoins(false); |
|||
}} |
|||
onPayment={handlePayment} |
|||
isPaymentPending={!recommendedPlanId || paymentMutation.isPending} |
|||
errorMessage={paymentError} |
|||
showBuyCoins={isInsufficientCoins} |
|||
/> |
|||
) : null} |
|||
|
|||
{isNoContactConfirmOpen ? ( |
|||
<FemaleConsentSheet |
|||
title={t.Confirm} |
|||
description={ |
|||
<p className="font-bold text-[#E03950] text-center my-4 text-base"> |
|||
{ |
|||
t[ |
|||
"No contact has been made with you in any way or by any party." |
|||
] |
|||
} |
|||
</p> |
|||
} |
|||
buttons={ |
|||
<button |
|||
type="button" |
|||
onClick={async () => { |
|||
setIsNoContactConfirmOpen(false); |
|||
await handleNoContactReport(); |
|||
}} |
|||
className="w-full h-[48px] rounded-[15px] bg-[#E03950] text-white font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95" |
|||
> |
|||
{t.Confirm} |
|||
</button> |
|||
} |
|||
onClose={() => setIsNoContactConfirmOpen(false)} |
|||
closeOnOutside={true} |
|||
/> |
|||
) : null} |
|||
|
|||
<main className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-[calc(20px+var(--safe-bottom))] text-center"> |
|||
<PageHeader className="-mx-[6px]" /> |
|||
|
|||
<div className="flex flex-1 flex-col justify-between gap-6 pt-4"> |
|||
<section className="flex flex-col items-center"> |
|||
{isFinalized ? ( |
|||
<div className="flex flex-col items-center max-w-[320px] text-center my-auto py-12"> |
|||
<div className="relative flex items-center justify-center text-[70px] animate-bounce"> |
|||
🎉 |
|||
</div> |
|||
<h1 className="mt-8 text-center text-[24px] leading-[1.25] font-bold text-[#E03950]"> |
|||
{t["Congratulations! 🎉"]} |
|||
</h1> |
|||
<p className="mt-6 text-[#4A4A4A] group-14 font-medium leading-relaxed"> |
|||
{ |
|||
t[ |
|||
"Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed." |
|||
] |
|||
} |
|||
</p> |
|||
</div> |
|||
) : ( |
|||
<> |
|||
<div className="relative isolate flex items-center justify-center"> |
|||
<Image |
|||
src="/assets/images/Group 15978804fdasf68.svg" |
|||
alt={t["Request accepted"]} |
|||
width={131} |
|||
height={125} |
|||
priority |
|||
className="relative z-10" |
|||
/> |
|||
</div> |
|||
|
|||
<h1 className="mt-11 group-16 leading-none font-black tracking-[0.02em] text-[#171717] uppercase"> |
|||
{titleText} |
|||
</h1> |
|||
|
|||
{caseStatus === "contacted" || |
|||
isFemaleContactConfirmed || |
|||
(isFemaleProfile && contactStatusMutation.isPending) ? ( |
|||
<div className="w-full border border-[#ECECEC] bg-white rounded-[15px] mt-6 px-4 py-4 text-center shadow-sm max-w-[315px] flex flex-col items-center justify-center min-h-[100px]"> |
|||
{isFemaleProfile && contactStatusMutation.isPending ? ( |
|||
<LoadingThreeDot className="text-[#E03950]" /> |
|||
) : ( |
|||
<p className="text-[#555555] group-14 font-medium leading-relaxed"> |
|||
{isFemaleProfile |
|||
? t[ |
|||
"Thank you for your feedback. To complete the process, please submit the final outcome of this introduction/contact so that the final status can be determined. If the final status is not yet determined, you can stay in this state until it is finalized." |
|||
] |
|||
: t[ |
|||
"Thank you for giving us feedback, we would be very happy if you also let us know the final result." |
|||
]} |
|||
</p> |
|||
)} |
|||
</div> |
|||
) : ( |
|||
<p className="mt-4 max-w-[315px] group-12 leading-[1.45] font-semibold text-[#777777]"> |
|||
{noContactReportedSuccess |
|||
? t[ |
|||
"Thank you for your feedback. Our support team will investigate the matter and notify you of the result. Please wait patiently during the review; our support will contact you." |
|||
] |
|||
: isFemaleProfile |
|||
? t[ |
|||
"The selected candidate will contact your family shortly." |
|||
] |
|||
: t[ |
|||
"You can now view their family's contact details and arrange further steps." |
|||
]} |
|||
</p> |
|||
)} |
|||
|
|||
<> |
|||
{caseStatus === "contacted" || |
|||
isFemaleContactConfirmed || |
|||
(isFemaleProfile && contactStatusMutation.isPending) ? ( |
|||
<div className="flex mt-8 w-full gap-3 justify-center"> |
|||
{isFemaleProfile && |
|||
contactStatusMutation.isPending ? null : isFemaleProfile ? ( |
|||
<button |
|||
type="button" |
|||
onClick={() => setIsOutcomeSheetOpen(true)} |
|||
disabled={outcomeMutation.isPending} |
|||
className="w-full max-w-[315px] h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50" |
|||
> |
|||
{outcomeMutation.isPending ? ( |
|||
<LoadingThreeDot className="text-white" /> |
|||
) : ( |
|||
t["Share Result"] |
|||
)} |
|||
</button> |
|||
) : ( |
|||
<> |
|||
<button |
|||
type="button" |
|||
onClick={() => |
|||
router.push( |
|||
localizePath("/new-match/profile", locale), |
|||
) |
|||
} |
|||
className="flex-1 h-[52px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#F5F5F5]" |
|||
> |
|||
{t["View Profile"]} |
|||
</button> |
|||
|
|||
<button |
|||
type="button" |
|||
onClick={() => setIsOutcomeSheetOpen(true)} |
|||
disabled={outcomeMutation.isPending} |
|||
className="flex-1 h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50" |
|||
> |
|||
{outcomeMutation.isPending ? ( |
|||
<LoadingThreeDot className="text-white" /> |
|||
) : ( |
|||
t["Submit Final Outcome"] |
|||
)} |
|||
</button> |
|||
</> |
|||
)} |
|||
</div> |
|||
) : ( |
|||
<div className="flex mt-9 w-full justify-center gap-4 max-w-[315px] mx-auto"> |
|||
{isFemaleProfile ? ( |
|||
<button |
|||
type="button" |
|||
onClick={() => setIsNoContactConfirmOpen(true)} |
|||
disabled={ |
|||
contactStatusMutation.isPending || |
|||
noContactReportedSuccess |
|||
} |
|||
className="flex-1 h-[44px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-all cursor-pointer hover:bg-[#F5F5F5] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white" |
|||
> |
|||
{contactStatusMutation.isPending ? ( |
|||
<LoadingThreeDot /> |
|||
) : ( |
|||
primaryActionText |
|||
)} |
|||
</button> |
|||
) : ( |
|||
<Link |
|||
href={profileHref} |
|||
className="max-w-[212px] flex-1" |
|||
> |
|||
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C] min-h-[38px] flex items-center justify-center"> |
|||
{primaryActionText} |
|||
</div> |
|||
</Link> |
|||
)} |
|||
|
|||
<button |
|||
type="button" |
|||
onClick={() => { |
|||
void handleSecondaryAction(); |
|||
}} |
|||
disabled={paymentMutation.isPending} |
|||
className={ |
|||
isFemaleProfile |
|||
? "flex-1 h-[44px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-md shadow-[#FE6F82]/30 transition-all cursor-pointer hover:opacity-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none" |
|||
: "max-w-[212px] flex-1 appearance-none border-0 bg-transparent p-0 text-left" |
|||
} |
|||
> |
|||
{isFemaleProfile ? ( |
|||
paymentMutation.isPending ? ( |
|||
<LoadingThreeDot className="text-white" /> |
|||
) : ( |
|||
secondaryActionText |
|||
) |
|||
) : ( |
|||
<div className="bg-linear-180 from-[#FE6F82] to-[#E03950] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#fff] shadow-md shadow-[#F2596E]/60 flex items-center justify-center min-h-[38px]"> |
|||
{paymentMutation.isPending ? ( |
|||
<LoadingThreeDot /> |
|||
) : ( |
|||
secondaryActionText |
|||
)} |
|||
</div> |
|||
)} |
|||
</button> |
|||
</div> |
|||
)} |
|||
|
|||
{caseStatus !== "contacted" && |
|||
!isFemaleContactConfirmed && |
|||
!noContactReportedSuccess && |
|||
!(isFemaleProfile && contactStatusMutation.isPending) ? ( |
|||
<div className="border border-[#F0445B] bg-[#F0445B]/10 rounded-xl mt-4"> |
|||
<p className="text-[#F0445B] group-12 font-semibold py-2.5 px-3.5 whitespace-pre-line"> |
|||
{ |
|||
t[ |
|||
"Please be informed that from the time of this introduction, you have 48 hours (2 days) to contact the person or their respected family to declare your readiness and begin the acquaintance process. At this stage, merely an initial call to announce your presence is sufficient, and planning for further steps (such as an in-person meeting) depends entirely on your subsequent mutual agreements. Since failing to make contact within the specified time might be considered socially disrespectful, if no action is taken within these 2 days, the introduced match will be removed according to the platform's rules. We also remind you that this issue may lead to restrictions such as delays in future introductions and financial penalties." |
|||
] |
|||
} |
|||
</p> |
|||
</div> |
|||
) : null} |
|||
</> |
|||
</> |
|||
)} |
|||
</section> |
|||
|
|||
<div className="space-y-8 pb-20"> |
|||
{/* Advisor section */} |
|||
<AdvisorActionsCard |
|||
title={t["Get an advisor"]} |
|||
description={ |
|||
t[ |
|||
"Not sure what to do next? Our psychology section is here to guide you at every step." |
|||
] |
|||
} |
|||
avatars={advisorAvatars} |
|||
extraCount={7} |
|||
getAdvisorLabel={t["Get Advisor"]} |
|||
getAdvisorHref="/marriage-advisors" |
|||
/> |
|||
|
|||
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> |
|||
<div |
|||
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }} |
|||
className="pointer-events-auto px-[17px] pt-3" |
|||
> |
|||
<div className="flex items-center justify-center w-full gap-1 rounded-[11px] bg-[#DBDBDB] py-3.5"> |
|||
<Image |
|||
src="/assets/images/material-symbols_lock.svg" |
|||
width={24} |
|||
height={24} |
|||
alt="lock" |
|||
/> |
|||
|
|||
<h2 className="group-14 leading-none font-semibold text-[#747474]"> |
|||
{t["Profile is locked"]} |
|||
</h2> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
</div> |
|||
</div> |
|||
</main> |
|||
</> |
|||
<HydrationBoundary state={dehydrate(queryClient)}> |
|||
<RequestAcceptedClient /> |
|||
</HydrationBoundary> |
|||
); |
|||
} |
|||
@ -0,0 +1,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<MarriagePhoneFieldValue>; |
|||
|
|||
return ( |
|||
typeof phoneValue.countryCode === "string" && |
|||
typeof phoneValue.phoneNumber === "string" |
|||
); |
|||
} |
|||
|
|||
function getContactInfoPhoneItems( |
|||
contactInfoFields: MarriageField[] | null | undefined, |
|||
): ContactInfoPhoneItem[] { |
|||
if (!contactInfoFields) { |
|||
return []; |
|||
} |
|||
|
|||
return contactInfoFields |
|||
.map((field) => { |
|||
const phoneNumber = sanitizePhoneNumber(field.value); |
|||
|
|||
if (!phoneNumber) { |
|||
return null; |
|||
} |
|||
|
|||
const rawLabel = field.label || field.key; |
|||
const label = rawLabel |
|||
.replace(/\s+with\s+Country\s+Code/gi, "") |
|||
.replace(/\s+با\s+کد\s+کشور/g, "") |
|||
.trim(); |
|||
|
|||
return { |
|||
key: field.key, |
|||
label, |
|||
phoneNumber, |
|||
}; |
|||
}) |
|||
.filter((item): item is ContactInfoPhoneItem => item !== null); |
|||
} |
|||
|
|||
function ContactInfoPhoneCard({ item }: { item: ContactInfoPhoneItem }) { |
|||
return ( |
|||
<div className="flex items-center justify-between rounded-[15px] border border-[#E4E4E4] bg-[#FBFBFB] px-4 py-3.5 shadow-[0_4px_12px_rgba(0,0,0,0.03)] gap-4"> |
|||
<div className="text-left min-w-0"> |
|||
<p className="text-xs font-semibold text-[#8F8F8F] break-words"> |
|||
{item.label} |
|||
</p> |
|||
<p className="text-base font-bold text-[#1F1F1F] dir-ltr"> |
|||
{item.phoneNumber} |
|||
</p> |
|||
</div> |
|||
|
|||
<div className="flex items-center gap-2 shrink-0"> |
|||
<a |
|||
href={`tel:${item.phoneNumber}`} |
|||
onClick={(e) => { |
|||
if (typeof window !== "undefined" && "HabibApp" in window) { |
|||
e.preventDefault(); |
|||
try { |
|||
const app = ( |
|||
window as Window & { |
|||
HabibApp?: { postMessage: (msg: string) => void }; |
|||
} |
|||
).HabibApp; |
|||
app?.postMessage( |
|||
JSON.stringify({ |
|||
action: "open_external_url", |
|||
data: { |
|||
url: `tel:${item.phoneNumber}`, |
|||
mode: "externalApplication", |
|||
title: "phone_call", |
|||
}, |
|||
}), |
|||
); |
|||
} catch (err) { |
|||
console.error("Error calling HabibApp bridge", err); |
|||
} |
|||
} |
|||
}} |
|||
className="inline-flex p-3 items-center justify-center rounded-[10px] bg-[#F0445B] text-white shrink-0" |
|||
> |
|||
<Image |
|||
src={"/assets/images/Vecfdastor.svg"} |
|||
width={16} |
|||
height={16} |
|||
alt="call" |
|||
/> |
|||
</a> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export default function RequestAcceptedClient() { |
|||
const { dictionary: t, locale } = useI18n(); |
|||
const router = useRouter(); |
|||
const { 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<string | null>(null); |
|||
const [isInsufficientCoins, setIsInsufficientCoins] = useState(false); |
|||
const [isOutcomeSheetOpen, setIsOutcomeSheetOpen] = useState(false); |
|||
const [isNoContactConfirmOpen, setIsNoContactConfirmOpen] = useState(false); |
|||
const [isContactReceivedConfirmOpen, setIsContactReceivedConfirmOpen] = |
|||
useState(false); |
|||
const [noContactReportedSuccess, setNoContactReportedSuccess] = |
|||
useState(false); |
|||
const [hasConfirmedFemaleContact, setHasConfirmedFemaleContact] = |
|||
useState(false); |
|||
const profileHref = localizePath("/new-match/profile", locale); |
|||
const { data: profile, isLoading } = useMarriageProfileQuery({ |
|||
refetchInterval: 3000, |
|||
}); |
|||
|
|||
const isFemaleProfile = profile?.gender === "female"; |
|||
const contactSharedAtStr = profile?.active_case?.contact_shared_at; |
|||
|
|||
useEffect(() => { |
|||
if (!profile || noContactReportedSuccess) { |
|||
return; |
|||
} |
|||
const targetPath = getSubmitPath(profile); |
|||
if (targetPath !== "/request-accepted") { |
|||
router.replace(localizePath(targetPath, locale)); |
|||
} |
|||
}, [profile, router, locale, noContactReportedSuccess]); |
|||
|
|||
// Signal Flutter to lift its loading cover once the profile is available.
|
|||
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 <PageLoadingSkeleton />; |
|||
} |
|||
|
|||
const titleText = isFemaleProfile |
|||
? t["Request Approved"] |
|||
: caseStatus === "payment_done" || caseStatus === "contacted" |
|||
? t["Contact info released"] |
|||
: t["Request approved!"]; |
|||
const primaryActionText = isFemaleProfile |
|||
? t["No Contact Received"] |
|||
: t["View profile"]; |
|||
const secondaryActionText = isFemaleProfile |
|||
? t["Contact Received"] |
|||
: caseStatus === "payment_done" || caseStatus === "contacted" |
|||
? t["View contact number"] |
|||
: t["Pay and get contact"]; |
|||
const contactInfoPhoneItems = getContactInfoPhoneItems( |
|||
contactInfoQuery.data?.contact_info, |
|||
); |
|||
|
|||
const handleSecondaryAction = async () => { |
|||
if (isFemaleProfile) { |
|||
setIsContactReceivedConfirmOpen(true); |
|||
return; |
|||
} |
|||
|
|||
if (caseStatus === "female_accepted" || caseStatus === "payment_pending") { |
|||
setIsSubscriptionSheetOpen(true); |
|||
return; |
|||
} |
|||
|
|||
if (caseStatus === "payment_done" || caseStatus === "contacted") { |
|||
if (!caseId) { |
|||
return; |
|||
} |
|||
|
|||
if (!contactInfoQuery.data) { |
|||
await contactInfoQuery.refetch(); |
|||
} |
|||
|
|||
setIsContactInfoSheetOpen(true); |
|||
} |
|||
}; |
|||
|
|||
const handlePayment = async () => { |
|||
if (!recommendedPlanId || paymentMutation.isPending) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
setPaymentError(null); |
|||
setIsInsufficientCoins(false); |
|||
const paymentResponse = |
|||
await paymentMutation.mutateAsync(recommendedPlanId); |
|||
const paymentUrl = extractHabcoinPaymentUrl(paymentResponse); |
|||
|
|||
if (paymentUrl) { |
|||
window.location.assign(paymentUrl); |
|||
return; |
|||
} |
|||
|
|||
setIsSubscriptionSheetOpen(false); |
|||
|
|||
if (caseId) { |
|||
await contactInfoQuery.refetch(); |
|||
setIsContactInfoSheetOpen(true); |
|||
} |
|||
} catch (err: any) { |
|||
console.error("Habcoin payment request failed", err); |
|||
const msg = |
|||
err?.response?.data?.error || err?.message || "Payment failed"; |
|||
if (msg === "Not enough coins") { |
|||
setIsInsufficientCoins(true); |
|||
setPaymentError( |
|||
t["Insufficient coin balance. Please recharge your account."] || |
|||
"Insufficient coin balance. Please recharge your account.", |
|||
); |
|||
} else { |
|||
setPaymentError(msg); |
|||
} |
|||
} |
|||
}; |
|||
|
|||
const handleNoContactReport = async () => { |
|||
if (!caseId || contactStatusMutation.isPending) return; |
|||
await contactStatusMutation.mutateAsync({ |
|||
action: "no_contact", |
|||
custom_note: |
|||
"No contact reported by female candidate after decision window", |
|||
}); |
|||
}; |
|||
|
|||
const isFinalized = |
|||
caseStatus === "finalized" || profile?.status === "matched"; |
|||
|
|||
return ( |
|||
<> |
|||
<PageBackground /> |
|||
|
|||
{isCallResultSheetOpen ? ( |
|||
<CallResultSheet |
|||
onClose={() => setIsCallResultSheetOpen(false)} |
|||
onOtherReasonsClick={() => setIsDismissReasonSheetOpen(true)} |
|||
onSubmit={async (value) => { |
|||
if (caseId) { |
|||
await contactStatusMutation.mutateAsync({ |
|||
action: "contacted", |
|||
custom_note: value, |
|||
}); |
|||
} |
|||
}} |
|||
/> |
|||
) : null} |
|||
|
|||
{isContactReceivedConfirmOpen ? ( |
|||
<FemaleConsentSheet |
|||
title={t.Confirm} |
|||
description={ |
|||
<p className="font-bold text-[#00AC78] text-center my-4 text-base"> |
|||
{t["Are you sure contact has been made?"]} |
|||
</p> |
|||
} |
|||
buttons={ |
|||
<SwipeButton |
|||
theme="green" |
|||
text={t["Confirm"]} |
|||
onCancel={() => setIsContactReceivedConfirmOpen(false)} |
|||
onSuccess={async () => { |
|||
setIsContactReceivedConfirmOpen(false); |
|||
setHasConfirmedFemaleContact(true); |
|||
|
|||
if (!caseId) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
await contactStatusMutation.mutateAsync({ |
|||
action: "contacted", |
|||
custom_note: |
|||
"Contact received confirmed by female candidate", |
|||
}); |
|||
} catch (error) { |
|||
// The confirmation screen must advance immediately after a swipe.
|
|||
// Keep the local state visible while the profile query retries.
|
|||
console.error("Unable to persist received contact", error); |
|||
} |
|||
}} |
|||
/> |
|||
} |
|||
onClose={() => setIsContactReceivedConfirmOpen(false)} |
|||
closeOnOutside={true} |
|||
/> |
|||
) : null} |
|||
|
|||
{isDismissReasonSheetOpen ? ( |
|||
<DismissReasonSheet |
|||
onClose={() => setIsDismissReasonSheetOpen(false)} |
|||
onSubmit={async (value) => { |
|||
if (caseId) { |
|||
await outcomeMutation.mutateAsync({ |
|||
status: "failure", |
|||
custom_note: value, |
|||
}); |
|||
} |
|||
}} |
|||
/> |
|||
) : null} |
|||
|
|||
{isOutcomeSheetOpen ? ( |
|||
isFemaleProfile ? ( |
|||
<FemaleOutcomeSheet |
|||
onClose={() => setIsOutcomeSheetOpen(false)} |
|||
onSubmit={async (status, reason) => { |
|||
if (status === "success") { |
|||
// If they confirm they are in the acquaintance/proposal process and nothing is finalized yet:
|
|||
// No change is made to the profile, we just close the sheet.
|
|||
setIsOutcomeSheetOpen(false); |
|||
} else { |
|||
// If they cancel:
|
|||
if (caseId) { |
|||
await outcomeMutation.mutateAsync({ |
|||
status: "failure", |
|||
custom_note: reason, |
|||
}); |
|||
} |
|||
} |
|||
}} |
|||
/> |
|||
) : ( |
|||
<OutcomeSelectionSheet |
|||
onClose={() => setIsOutcomeSheetOpen(false)} |
|||
onSubmit={async (status) => { |
|||
if (status === "success") { |
|||
if (caseId) { |
|||
await outcomeMutation.mutateAsync({ |
|||
status: "success", |
|||
}); |
|||
} |
|||
} else { |
|||
setIsDismissReasonSheetOpen(true); |
|||
} |
|||
}} |
|||
/> |
|||
) |
|||
) : null} |
|||
|
|||
{isContactInfoSheetOpen ? ( |
|||
<FemaleConsentSheet |
|||
title={t["Contact Detail"]} |
|||
description={ |
|||
t[ |
|||
"Please mention during the call that you were introduced by the Habib Marriage app." |
|||
] |
|||
} |
|||
buttons={ |
|||
contactInfoPhoneItems.length ? ( |
|||
<div className="space-y-4"> |
|||
{contactInfoPhoneItems.map((item) => ( |
|||
<ContactInfoPhoneCard key={item.key} item={item} /> |
|||
))} |
|||
</div> |
|||
) : ( |
|||
<div className="rounded-[12px] bg-[#ECECEC] px-4 py-3 group-12 font-semibold text-[#555]"> |
|||
{t["Contact information is not available yet."]} |
|||
</div> |
|||
) |
|||
} |
|||
onClose={() => setIsContactInfoSheetOpen(false)} |
|||
/> |
|||
) : null} |
|||
|
|||
{isSubscriptionSheetOpen ? ( |
|||
<SubscriptionRequiredSheet |
|||
onClose={() => { |
|||
setIsSubscriptionSheetOpen(false); |
|||
setPaymentError(null); |
|||
setIsInsufficientCoins(false); |
|||
}} |
|||
onPayment={handlePayment} |
|||
isPaymentPending={!recommendedPlanId || paymentMutation.isPending} |
|||
errorMessage={paymentError} |
|||
showBuyCoins={isInsufficientCoins} |
|||
/> |
|||
) : null} |
|||
|
|||
{isNoContactConfirmOpen ? ( |
|||
<FemaleConsentSheet |
|||
title={t.Confirm} |
|||
description={ |
|||
<p className="font-bold text-[#E03950] text-center my-4 text-base"> |
|||
{ |
|||
t[ |
|||
"No contact has been made with you in any way or by any party." |
|||
] |
|||
} |
|||
</p> |
|||
} |
|||
buttons={ |
|||
<button |
|||
type="button" |
|||
onClick={async () => { |
|||
setIsNoContactConfirmOpen(false); |
|||
await handleNoContactReport(); |
|||
}} |
|||
className="w-full h-[48px] rounded-[15px] bg-[#E03950] text-white font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95" |
|||
> |
|||
{t.Confirm} |
|||
</button> |
|||
} |
|||
onClose={() => setIsNoContactConfirmOpen(false)} |
|||
closeOnOutside={true} |
|||
/> |
|||
) : null} |
|||
|
|||
<main className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-[calc(20px+var(--safe-bottom))] text-center"> |
|||
<PageHeader className="-mx-[6px]" profile={profile} /> |
|||
|
|||
<div className="flex flex-1 flex-col justify-between gap-6 pt-4"> |
|||
<section className="flex flex-col items-center"> |
|||
{isFinalized ? ( |
|||
<div className="flex flex-col items-center max-w-[320px] text-center my-auto py-12"> |
|||
<div className="relative flex items-center justify-center text-[70px] animate-bounce"> |
|||
🎉 |
|||
</div> |
|||
<h1 className="mt-8 text-center text-[24px] leading-[1.25] font-bold text-[#E03950]"> |
|||
{t["Congratulations! 🎉"]} |
|||
</h1> |
|||
<p className="mt-6 text-[#4A4A4A] group-14 font-medium leading-relaxed"> |
|||
{ |
|||
t[ |
|||
"Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed." |
|||
] |
|||
} |
|||
</p> |
|||
</div> |
|||
) : ( |
|||
<> |
|||
<div className="relative isolate flex items-center justify-center"> |
|||
<Image |
|||
src="/assets/images/Group 15978804fdasf68.svg" |
|||
alt={t["Request accepted"]} |
|||
width={131} |
|||
height={125} |
|||
priority |
|||
className="relative z-10" |
|||
/> |
|||
</div> |
|||
|
|||
<h1 className="mt-11 group-16 leading-none font-black tracking-[0.02em] text-[#171717] uppercase"> |
|||
{titleText} |
|||
</h1> |
|||
|
|||
{caseStatus === "contacted" || |
|||
isFemaleContactConfirmed || |
|||
(isFemaleProfile && contactStatusMutation.isPending) ? ( |
|||
<div className="w-full border border-[#ECECEC] bg-white rounded-[15px] mt-6 px-4 py-4 text-center shadow-sm max-w-[315px] flex flex-col items-center justify-center min-h-[100px]"> |
|||
{isFemaleProfile && contactStatusMutation.isPending ? ( |
|||
<LoadingThreeDot className="text-[#E03950]" /> |
|||
) : ( |
|||
<p className="text-[#555555] group-14 font-medium leading-relaxed"> |
|||
{isFemaleProfile |
|||
? t[ |
|||
"Thank you for your feedback. To complete the process, please submit the final outcome of this introduction/contact so that the final status can be determined. If the final status is not yet determined, you can stay in this state until it is finalized." |
|||
] |
|||
: t[ |
|||
"Thank you for giving us feedback, we would be very happy if you also let us know the final result." |
|||
]} |
|||
</p> |
|||
)} |
|||
</div> |
|||
) : ( |
|||
<p className="mt-4 max-w-[315px] group-12 leading-[1.45] font-semibold text-[#777777]"> |
|||
{noContactReportedSuccess |
|||
? t[ |
|||
"Thank you for your feedback. Our support team will investigate the matter and notify you of the result. Please wait patiently during the review; our support will contact you." |
|||
] |
|||
: isFemaleProfile |
|||
? t[ |
|||
"The selected candidate will contact your family shortly." |
|||
] |
|||
: t[ |
|||
"You can now view their family's contact details and arrange further steps." |
|||
]} |
|||
</p> |
|||
)} |
|||
|
|||
<> |
|||
{caseStatus === "contacted" || |
|||
isFemaleContactConfirmed || |
|||
(isFemaleProfile && contactStatusMutation.isPending) ? ( |
|||
<div className="flex mt-8 w-full gap-3 justify-center"> |
|||
{isFemaleProfile && |
|||
contactStatusMutation.isPending ? null : isFemaleProfile ? ( |
|||
<button |
|||
type="button" |
|||
onClick={() => setIsOutcomeSheetOpen(true)} |
|||
disabled={outcomeMutation.isPending} |
|||
className="w-full max-w-[315px] h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50" |
|||
> |
|||
{outcomeMutation.isPending ? ( |
|||
<LoadingThreeDot className="text-white" /> |
|||
) : ( |
|||
t["Share Result"] |
|||
)} |
|||
</button> |
|||
) : ( |
|||
<> |
|||
<button |
|||
type="button" |
|||
onClick={() => openProfile()} |
|||
className="flex-1 h-[52px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#F5F5F5]" |
|||
> |
|||
{t["View Profile"]} |
|||
</button> |
|||
|
|||
<button |
|||
type="button" |
|||
onClick={() => setIsOutcomeSheetOpen(true)} |
|||
disabled={outcomeMutation.isPending} |
|||
className="flex-1 h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50" |
|||
> |
|||
{outcomeMutation.isPending ? ( |
|||
<LoadingThreeDot className="text-white" /> |
|||
) : ( |
|||
t["Submit Final Outcome"] |
|||
)} |
|||
</button> |
|||
</> |
|||
)} |
|||
</div> |
|||
) : ( |
|||
<div className="flex mt-9 w-full justify-center gap-4 max-w-[315px] mx-auto"> |
|||
{isFemaleProfile ? ( |
|||
<button |
|||
type="button" |
|||
onClick={() => setIsNoContactConfirmOpen(true)} |
|||
disabled={ |
|||
contactStatusMutation.isPending || |
|||
noContactReportedSuccess |
|||
} |
|||
className="flex-1 h-[44px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-all cursor-pointer hover:bg-[#F5F5F5] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white" |
|||
> |
|||
{contactStatusMutation.isPending ? ( |
|||
<LoadingThreeDot /> |
|||
) : ( |
|||
primaryActionText |
|||
)} |
|||
</button> |
|||
) : ( |
|||
<Link |
|||
href={profileHref} |
|||
className="max-w-[212px] flex-1" |
|||
> |
|||
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C] min-h-[38px] flex items-center justify-center"> |
|||
{primaryActionText} |
|||
</div> |
|||
</Link> |
|||
)} |
|||
|
|||
<button |
|||
type="button" |
|||
onClick={() => { |
|||
void handleSecondaryAction(); |
|||
}} |
|||
disabled={paymentMutation.isPending} |
|||
className={ |
|||
isFemaleProfile |
|||
? "flex-1 h-[44px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-md shadow-[#FE6F82]/30 transition-all cursor-pointer hover:opacity-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none" |
|||
: "max-w-[212px] flex-1 appearance-none border-0 bg-transparent p-0 text-left" |
|||
} |
|||
> |
|||
{isFemaleProfile ? ( |
|||
paymentMutation.isPending ? ( |
|||
<LoadingThreeDot className="text-white" /> |
|||
) : ( |
|||
secondaryActionText |
|||
) |
|||
) : ( |
|||
<div className="bg-linear-180 from-[#FE6F82] to-[#E03950] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#fff] shadow-md shadow-[#F2596E]/60 flex items-center justify-center min-h-[38px]"> |
|||
{paymentMutation.isPending ? ( |
|||
<LoadingThreeDot /> |
|||
) : ( |
|||
secondaryActionText |
|||
)} |
|||
</div> |
|||
)} |
|||
</button> |
|||
</div> |
|||
)} |
|||
|
|||
{caseStatus !== "contacted" && |
|||
!isFemaleContactConfirmed && |
|||
!noContactReportedSuccess && |
|||
!(isFemaleProfile && contactStatusMutation.isPending) ? ( |
|||
<div className="border border-[#F0445B] bg-[#F0445B]/10 rounded-xl mt-4"> |
|||
<p className="text-[#F0445B] group-12 font-semibold py-2.5 px-3.5 whitespace-pre-line"> |
|||
{ |
|||
t[ |
|||
"Please be informed that from the time of this introduction, you have 48 hours (2 days) to contact the person or their respected family to declare your readiness and begin the acquaintance process. At this stage, merely an initial call to announce your presence is sufficient, and planning for further steps (such as an in-person meeting) depends entirely on your subsequent mutual agreements. Since failing to make contact within the specified time might be considered socially disrespectful, if no action is taken within these 2 days, the introduced match will be removed according to the platform's rules. We also remind you that this issue may lead to restrictions such as delays in future introductions and financial penalties." |
|||
] |
|||
} |
|||
</p> |
|||
</div> |
|||
) : null} |
|||
</> |
|||
</> |
|||
)} |
|||
</section> |
|||
|
|||
<div className="space-y-8 pb-20"> |
|||
{/* Advisor section */} |
|||
<AdvisorActionsCard |
|||
title={t["Get an advisor"]} |
|||
description={ |
|||
t[ |
|||
"Not sure what to do next? Our psychology section is here to guide you at every step." |
|||
] |
|||
} |
|||
avatars={advisorAvatars} |
|||
extraCount={7} |
|||
getAdvisorLabel={t["Get Advisor"]} |
|||
onGetAdvisor={openAdvisors} |
|||
/> |
|||
|
|||
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> |
|||
<div |
|||
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }} |
|||
className="pointer-events-auto px-[17px] pt-3" |
|||
> |
|||
<div className="flex items-center justify-center w-full gap-1 rounded-[11px] bg-[#DBDBDB] py-3.5"> |
|||
<Image |
|||
src="/assets/images/material-symbols_lock.svg" |
|||
width={24} |
|||
height={24} |
|||
alt="lock" |
|||
/> |
|||
|
|||
<h2 className="group-14 leading-none font-semibold text-[#747474]"> |
|||
{t["Profile is locked"]} |
|||
</h2> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
</div> |
|||
</div> |
|||
</main> |
|||
|
|||
<MarriageAdvisorsOverlay |
|||
open={isAdvisorOpen} |
|||
onClose={closeAdvisors} |
|||
/> |
|||
|
|||
<MatchProfileOverlay |
|||
open={isProfileOpen} |
|||
onClose={closeProfile} |
|||
/> |
|||
</> |
|||
); |
|||
} |
|||
@ -1,140 +1,39 @@ |
|||
"use client"; |
|||
|
|||
import Image from "next/image"; |
|||
import Link from "next/link"; |
|||
import { useRouter } from "next/navigation"; |
|||
import { useEffect, useMemo } from "react"; |
|||
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; |
|||
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; |
|||
import PageHeader from "@/components/Componentes/page-header"; |
|||
import { PageBackground } from "@/components/Componentes/page-background"; |
|||
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
|||
import { getSubmitPath } from "@/lib/get-submit-path"; |
|||
import { localizePath } from "@/translations/config"; |
|||
import { useI18n } from "@/translations/provider"; |
|||
|
|||
const advisorAvatars = [ |
|||
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" }, |
|||
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" }, |
|||
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" }, |
|||
]; |
|||
|
|||
export default function RequestSentPage() { |
|||
const router = useRouter(); |
|||
const { dictionary: t, locale } = useI18n(); |
|||
const { data: profile, isLoading } = useMarriageProfileQuery({ |
|||
refetchInterval: 3000, |
|||
import { cookies } from "next/headers"; |
|||
import { |
|||
dehydrate, |
|||
HydrationBoundary, |
|||
QueryClient, |
|||
} from "@tanstack/react-query"; |
|||
import { isAuthenticatedToken } from "@/lib/entry-route-cache"; |
|||
import { fetchProfileSSR } from "@/lib/ssr-fetch"; |
|||
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; |
|||
import RequestSentClient from "./request-sent-client"; |
|||
|
|||
export const dynamic = "force-dynamic"; |
|||
|
|||
export default async function RequestSentPage() { |
|||
const cookieStore = await cookies(); |
|||
|
|||
const token = |
|||
cookieStore.get("HABIB_TOKEN")?.value ?? |
|||
cookieStore.get("habib_token")?.value; |
|||
|
|||
const queryClient = new QueryClient({ |
|||
defaultOptions: { |
|||
queries: { staleTime: 10 * 1000 }, |
|||
}, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
if (!profile) { |
|||
return; |
|||
} |
|||
const targetPath = getSubmitPath(profile); |
|||
if (targetPath !== "/request-sent") { |
|||
router.replace(localizePath(targetPath, locale)); |
|||
} |
|||
}, [profile, locale, router]); |
|||
|
|||
const isRedirecting = useMemo(() => { |
|||
if (!profile) return false; |
|||
return getSubmitPath(profile) !== "/request-sent"; |
|||
}, [profile]); |
|||
|
|||
if (isLoading || isRedirecting) { |
|||
return <PageLoadingSkeleton />; |
|||
if (isAuthenticatedToken(token)) { |
|||
await queryClient.prefetchQuery({ |
|||
queryKey: marriageQueryKeys.profile(), |
|||
queryFn: () => fetchProfileSSR(token!), |
|||
}); |
|||
} |
|||
|
|||
const copy = { |
|||
advisorTitle: t["Get an advisor"], |
|||
advisorDescription: |
|||
t[ |
|||
"Not sure what to do next? Our psychology section is here to guide you at every step." |
|||
], |
|||
getAdvisor: t["Get Advisor"], |
|||
}; |
|||
const requestSentCopy = { |
|||
title: t["Request Sent"], |
|||
description: |
|||
t[ |
|||
"Your request has been sent. Once the lady reviews your request, you will be notified." |
|||
], |
|||
matchProfile: t["View More Details"], |
|||
profileLocked: t["Profile is locked"], |
|||
}; |
|||
|
|||
return ( |
|||
<> |
|||
<PageBackground /> |
|||
|
|||
<main |
|||
style={{ paddingBottom: "calc(40px + var(--safe-bottom))" }} |
|||
className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] text-center" |
|||
> |
|||
<PageHeader className="-mx-[6px]" /> |
|||
|
|||
<div className="flex flex-1 flex-col justify-between gap-20 pt-[109px]"> |
|||
<section className="flex flex-col items-center"> |
|||
<div className="relative isolate flex items-center justify-center"> |
|||
<Image |
|||
src="/assets/images/Group 15978804fdasf68.svg" |
|||
alt="Request sent" |
|||
width={131} |
|||
height={125} |
|||
priority |
|||
className="relative z-10" |
|||
/> |
|||
</div> |
|||
|
|||
<h1 className="mt-11 group-16 leading-none font-black tracking-[0.02em] text-[#171717] uppercase"> |
|||
{requestSentCopy.title} |
|||
</h1> |
|||
|
|||
<p className="mt-4 max-w-[315px] group-12 leading-[1.45] font-semibold text-[#777777]"> |
|||
{requestSentCopy.description} |
|||
</p> |
|||
|
|||
<Link |
|||
href={localizePath("/new-match/profile", locale)} |
|||
className="mt-9 w-full max-w-[212px]" |
|||
> |
|||
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C]"> |
|||
{requestSentCopy.matchProfile} |
|||
</div> |
|||
</Link> |
|||
</section> |
|||
|
|||
<div className="space-y-8 pb-20"> |
|||
<AdvisorActionsCard |
|||
title={copy.advisorTitle} |
|||
description={copy.advisorDescription} |
|||
avatars={advisorAvatars} |
|||
extraCount={7} |
|||
getAdvisorLabel={copy.getAdvisor} |
|||
getAdvisorHref="/marriage-advisors" |
|||
/> |
|||
|
|||
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> |
|||
<div |
|||
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }} |
|||
className="pointer-events-auto px-[17px] pt-3" |
|||
> |
|||
<div className="flex items-center justify-center w-full gap-1 rounded-[11px] bg-[#DBDBDB] py-3.5"> |
|||
<Image |
|||
src={"/assets/images/material-symbols_lock.svg"} |
|||
width={24} |
|||
height={24} |
|||
alt="lock" |
|||
/> |
|||
<h2 className="group-14 leading-none font-semibold text-[#747474]"> |
|||
{requestSentCopy.profileLocked} |
|||
</h2> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
</div> |
|||
</div> |
|||
</main> |
|||
</> |
|||
<HydrationBoundary state={dehydrate(queryClient)}> |
|||
<RequestSentClient /> |
|||
</HydrationBoundary> |
|||
); |
|||
} |
|||
@ -0,0 +1,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 <PageLoadingSkeleton />; |
|||
} |
|||
|
|||
const copy = { |
|||
advisorTitle: t["Get an advisor"], |
|||
advisorDescription: |
|||
t[ |
|||
"Not sure what to do next? Our psychology section is here to guide you at every step." |
|||
], |
|||
getAdvisor: t["Get Advisor"], |
|||
}; |
|||
const requestSentCopy = { |
|||
title: t["Request Sent"], |
|||
description: |
|||
t[ |
|||
"Your request has been sent. Once the lady reviews your request, you will be notified." |
|||
], |
|||
matchProfile: t["View More Details"], |
|||
profileLocked: t["Profile is locked"], |
|||
}; |
|||
|
|||
return ( |
|||
<> |
|||
<PageBackground /> |
|||
|
|||
<main |
|||
style={{ paddingBottom: "calc(40px + var(--safe-bottom))" }} |
|||
className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] text-center" |
|||
> |
|||
<PageHeader className="-mx-[6px]" profile={profile} /> |
|||
|
|||
<div className="flex flex-1 flex-col justify-between gap-20 pt-[109px]"> |
|||
<section className="flex flex-col items-center"> |
|||
<div className="relative isolate flex items-center justify-center"> |
|||
<Image |
|||
src="/assets/images/Group 15978804fdasf68.svg" |
|||
alt="Request sent" |
|||
width={131} |
|||
height={125} |
|||
priority |
|||
className="relative z-10" |
|||
/> |
|||
</div> |
|||
|
|||
<h1 className="mt-11 group-16 leading-none font-black tracking-[0.02em] text-[#171717] uppercase"> |
|||
{requestSentCopy.title} |
|||
</h1> |
|||
|
|||
<p className="mt-4 max-w-[315px] group-12 leading-[1.45] font-semibold text-[#777777]"> |
|||
{requestSentCopy.description} |
|||
</p> |
|||
|
|||
<button |
|||
type="button" |
|||
onClick={openProfile} |
|||
className="mt-9 w-full max-w-[212px] cursor-pointer" |
|||
> |
|||
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C] transition-transform active:scale-[0.98]"> |
|||
{requestSentCopy.matchProfile} |
|||
</div> |
|||
</button> |
|||
</section> |
|||
|
|||
<div className="space-y-8 pb-20"> |
|||
<AdvisorActionsCard |
|||
title={copy.advisorTitle} |
|||
description={copy.advisorDescription} |
|||
avatars={advisorAvatars} |
|||
extraCount={7} |
|||
getAdvisorLabel={copy.getAdvisor} |
|||
onGetAdvisor={openAdvisors} |
|||
/> |
|||
|
|||
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> |
|||
<div |
|||
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }} |
|||
className="pointer-events-auto px-[17px] pt-3" |
|||
> |
|||
<div className="flex items-center justify-center w-full gap-1 rounded-[11px] bg-[#DBDBDB] py-3.5"> |
|||
<Image |
|||
src={"/assets/images/material-symbols_lock.svg"} |
|||
width={24} |
|||
height={24} |
|||
alt="lock" |
|||
/> |
|||
<h2 className="group-14 leading-none font-semibold text-[#747474]"> |
|||
{requestSentCopy.profileLocked} |
|||
</h2> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
</div> |
|||
</div> |
|||
</main> |
|||
|
|||
<MarriageAdvisorsOverlay |
|||
open={isAdvisorOpen} |
|||
onClose={closeAdvisors} |
|||
/> |
|||
|
|||
<MatchProfileOverlay |
|||
open={isProfileOpen} |
|||
onClose={closeProfile} |
|||
/> |
|||
</> |
|||
); |
|||
} |
|||
@ -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( |
|||
<QueryClientProvider client={queryClient}> |
|||
<AuthDataBoundary> |
|||
<div data-testid="child">Child Content</div> |
|||
</AuthDataBoundary> |
|||
</QueryClientProvider>, |
|||
); |
|||
|
|||
// 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( |
|||
<QueryClientProvider client={queryClient}> |
|||
<AuthDataBoundary> |
|||
<div>Mounted</div> |
|||
</AuthDataBoundary> |
|||
</QueryClientProvider>, |
|||
); |
|||
|
|||
await waitFor(() => { |
|||
expect(window.localStorage.getItem("marriage:storage-migration:v3")).toBe("done"); |
|||
}); |
|||
expect(window.localStorage.getItem("marriage:sections:old_slug:answers:v2")).toBeNull(); |
|||
}); |
|||
}); |
|||
@ -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}</>; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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<CheckDiscountResult | null>(null); |
|||
const [errorMessage, setErrorMessage] = useState<string | null>(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 ( |
|||
<div className={`w-full ${className}`}> |
|||
{/* ── Collapsible Header ── */} |
|||
<button |
|||
type="button" |
|||
onClick={() => setIsExpanded((prev) => !prev)} |
|||
className="flex w-full items-center justify-between py-3.5 text-left transition-colors cursor-pointer select-none" |
|||
> |
|||
<div className="flex items-center gap-3"> |
|||
{/* Coupon icon */} |
|||
<svg |
|||
width="18" |
|||
height="18" |
|||
viewBox="0 0 18 18" |
|||
fill="none" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
className={`shrink-0 transition-colors ${ |
|||
isExpanded ? "text-[#747474]" : "text-[#F0445B]" |
|||
}`}
|
|||
> |
|||
<path |
|||
fillRule="evenodd" |
|||
clipRule="evenodd" |
|||
d="M1.60714 3.21429C1.60714 2.94686 1.82314 2.73214 2.08671 2.73214H15.9133C16.1769 2.73214 16.3929 2.94686 16.3929 3.21429V5.91943C15.8137 6.19949 15.3252 6.6371 14.9834 7.18208C14.6416 7.72706 14.4603 8.35734 14.4603 9.00064C14.4603 9.64395 14.6416 10.2742 14.9834 10.8192C15.3252 11.3642 15.8137 11.8018 16.3929 12.0819V14.7857C16.393 14.8489 16.3808 14.9114 16.3567 14.9698C16.3327 15.0282 16.2975 15.0813 16.2529 15.1261C16.2084 15.1709 16.1555 15.2064 16.0972 15.2308C16.0389 15.2551 15.9764 15.2677 15.9133 15.2679H2.08671C2.02357 15.2677 1.96107 15.2551 1.9028 15.2308C1.84452 15.2064 1.79161 15.1709 1.74707 15.1261C1.70254 15.0813 1.66726 15.0282 1.64325 14.9698C1.61924 14.9114 1.60697 14.8489 1.60714 14.7857V12.0909C2.19152 11.8129 2.68514 11.3751 3.03076 10.828C3.37637 10.2809 3.55982 9.64709 3.55982 9C3.55982 8.3529 3.37637 7.71907 3.03076 7.172C2.68514 6.62493 2.19152 6.18705 1.60714 5.90914V3.21429ZM2.08671 1.125C0.933429 1.125 0 2.06229 0 3.21429V6.46714C0 6.83229 0.246857 7.15114 0.599143 7.24371C0.987519 7.34535 1.33125 7.57285 1.57657 7.89063C1.82189 8.20841 1.95496 8.59855 1.95496 9C1.95496 9.40145 1.82189 9.79159 1.57657 10.1094C1.33125 10.4271 0.987519 10.6546 0.599143 10.7563C0.427554 10.8014 0.275728 10.9021 0.167347 11.0426C0.0589657 11.183 0.000124048 11.3554 0 11.5329L0 14.7857C0 15.939 0.933429 16.875 2.08671 16.875H15.9133C17.0666 16.875 18 15.939 18 14.7857V11.5277C18 11.3514 17.9419 11.1799 17.8348 11.0398C17.7277 10.8997 17.5775 10.7987 17.4073 10.7524C17.0222 10.6483 16.6822 10.4202 16.4398 10.1034C16.1973 9.78667 16.066 9.39889 16.066 9C16.066 8.60111 16.1973 8.21333 16.4398 7.89658C16.6822 7.57983 17.0222 7.35174 17.4073 7.24757C17.5775 7.20129 17.7277 7.10032 17.8348 6.96021C17.9419 6.8201 18 6.64865 18 6.47229V3.21429C18 2.06229 17.0666 1.125 15.9133 1.125H2.08671ZM12.7826 5.21743C12.708 5.14279 12.6194 5.08358 12.5218 5.04318C12.4243 5.00279 12.3198 4.98199 12.2143 4.98199C12.1087 4.98199 12.0042 5.00279 11.9067 5.04318C11.8092 5.08358 11.7206 5.14279 11.646 5.21743L5.21743 11.646C5.1428 11.7206 5.0836 11.8092 5.04321 11.9067C5.00282 12.0042 4.98204 12.1087 4.98204 12.2143C4.98204 12.3198 5.00282 12.4243 5.04321 12.5218C5.0836 12.6193 5.1428 12.7079 5.21743 12.7826C5.29206 12.8572 5.38065 12.9164 5.47816 12.9568C5.57567 12.9972 5.68017 13.018 5.78571 13.018C5.89125 13.018 5.99576 12.9972 6.09327 12.9568C6.19077 12.9164 6.27937 12.8572 6.354 12.7826L12.7826 6.354C12.8572 6.27938 12.9164 6.19078 12.9568 6.09328C12.9972 5.99577 13.018 5.89126 13.018 5.78571C13.018 5.68017 12.9972 5.57566 12.9568 5.47815C12.9164 5.38064 12.8572 5.29205 12.7826 5.21743ZM4.98214 6.42857C4.98214 6.23862 5.01956 6.05054 5.09225 5.87505C5.16494 5.69956 5.27148 5.54011 5.40579 5.40579C5.54011 5.27148 5.69956 5.16494 5.87505 5.09225C6.05054 5.01956 6.23862 4.98214 6.42857 4.98214C6.61852 4.98214 6.80661 5.01956 6.9821 5.09225C7.15758 5.16494 7.31704 5.27148 7.45135 5.40579C7.58566 5.54011 7.69221 5.69956 7.7649 5.87505C7.83759 6.05054 7.875 6.23862 7.875 6.42857C7.875 6.81219 7.72261 7.18009 7.45135 7.45135C7.18009 7.72261 6.81219 7.875 6.42857 7.875C6.04495 7.875 5.67705 7.72261 5.40579 7.45135C5.13453 7.18009 4.98214 6.81219 4.98214 6.42857ZM11.5714 10.125C11.1878 10.125 10.8199 10.2774 10.5486 10.5486C10.2774 10.8199 10.125 11.1878 10.125 11.5714C10.125 11.955 10.2774 12.3229 10.5486 12.5942C10.8199 12.8655 11.1878 13.0179 11.5714 13.0179C11.955 13.0179 12.3229 12.8655 12.5942 12.5942C12.8655 12.3229 13.0179 11.955 13.0179 11.5714C13.0179 11.1878 12.8655 10.8199 12.5942 10.5486C12.3229 10.2774 11.955 10.125 11.5714 10.125Z" |
|||
fill="currentColor" |
|||
/> |
|||
</svg> |
|||
|
|||
<span |
|||
className={`text-[14px] font-bold transition-colors ${ |
|||
isExpanded ? "text-[#747474]" : "text-[#F0445B]" |
|||
}`}
|
|||
> |
|||
{t["Do you have a discount code?"] || |
|||
"Do you have a discount code?"} |
|||
</span> |
|||
</div> |
|||
|
|||
{/* Arrow expand icon */} |
|||
<svg |
|||
aria-hidden="true" |
|||
className={`size-5 transition-transform duration-300 ${ |
|||
isExpanded ? "rotate-180 text-[#747474]" : "rotate-0 text-[#F0445B]" |
|||
}`}
|
|||
viewBox="0 0 24 24" |
|||
fill="none" |
|||
stroke="currentColor" |
|||
strokeWidth={2.5} |
|||
strokeLinecap="round" |
|||
strokeLinejoin="round" |
|||
> |
|||
<path d="m6 9 6 6 6-6" /> |
|||
</svg> |
|||
</button> |
|||
|
|||
{/* ── Expanded Form Body ── */} |
|||
{isExpanded && ( |
|||
<div className="pt-1 pb-2 w-full"> |
|||
<div |
|||
className={`flex h-[52px] w-full items-center rounded-[12px] border bg-white px-4 transition-all ${getBorderAndRing()}`} |
|||
> |
|||
<input |
|||
type="text" |
|||
dir="ltr" |
|||
value={code} |
|||
disabled={discountStatus === "loading" || discountResult !== null} |
|||
onChange={(e) => { |
|||
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" ? ( |
|||
<div className="shrink-0 pl-3"> |
|||
<div className="size-4.5 animate-spin rounded-full border-2 border-[#F0445B] border-t-transparent" /> |
|||
</div> |
|||
) : discountResult !== null ? ( |
|||
<button |
|||
type="button" |
|||
onClick={handleClear} |
|||
className="shrink-0 pl-3 text-[15px] font-semibold text-[#F0445B] hover:opacity-80 transition-opacity cursor-pointer" |
|||
> |
|||
{t["Clear"] || "Clear"} |
|||
</button> |
|||
) : ( |
|||
<button |
|||
type="button" |
|||
onClick={handleApply} |
|||
className="shrink-0 pl-3 text-[15px] font-semibold text-[#F0445B] hover:opacity-80 transition-opacity cursor-pointer" |
|||
> |
|||
{t["Apply"] || "Apply"} |
|||
</button> |
|||
)} |
|||
</div> |
|||
|
|||
{/* Status Message */} |
|||
{errorMessage && ( |
|||
<p className="mt-2 text-start text-[13px] font-medium text-[#D44747] leading-normal"> |
|||
{errorMessage} |
|||
</p> |
|||
)} |
|||
|
|||
{discountStatus === "success" && discountResult?.message && ( |
|||
<p className="mt-2 text-start text-[13px] font-medium text-[#00AC78] leading-normal"> |
|||
{discountResult.message} |
|||
</p> |
|||
)} |
|||
</div> |
|||
)} |
|||
</div> |
|||
); |
|||
} |
|||
@ -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; |
|||
@ -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 ( |
|||
<QueryClientProvider client={queryClient}> |
|||
<I18nProvider locale="fa">{children}</I18nProvider> |
|||
</QueryClientProvider> |
|||
); |
|||
}; |
|||
} |
|||
|
|||
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( |
|||
<MarriageAdvisorsOverlay open={true} onClose={handleClose} />, |
|||
{ 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( |
|||
<MarriageAdvisorsOverlay open={true} onClose={handleClose} />, |
|||
{ wrapper }, |
|||
); |
|||
|
|||
act(() => { |
|||
vi.advanceTimersByTime(16); |
|||
}); |
|||
|
|||
expect(screen.getByRole("dialog")).toHaveAttribute("data-state", "open"); |
|||
expect(document.body.classList.contains("section-overlay-open")).toBe(true); |
|||
|
|||
rerender(<MarriageAdvisorsOverlay open={false} onClose={handleClose} />); |
|||
|
|||
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(); |
|||
}); |
|||
}); |
|||
@ -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 ( |
|||
<SectionOverlayHost open={open} onClose={onClose}> |
|||
<MarriageAdvisorsPage onClose={onClose} /> |
|||
</SectionOverlayHost> |
|||
); |
|||
} |
|||
|
|||
export default MarriageAdvisorsOverlay; |
|||
@ -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 ( |
|||
<QueryClientProvider client={queryClient}> |
|||
<I18nProvider locale="fa">{children}</I18nProvider> |
|||
</QueryClientProvider> |
|||
); |
|||
}; |
|||
} |
|||
|
|||
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( |
|||
<MatchProfileOverlay open={true} onClose={handleClose} />, |
|||
{ 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( |
|||
<MatchProfileOverlay open={true} onClose={handleClose} />, |
|||
{ wrapper }, |
|||
); |
|||
|
|||
act(() => { |
|||
vi.advanceTimersByTime(16); |
|||
}); |
|||
|
|||
expect(screen.getByRole("dialog")).toHaveAttribute("data-state", "open"); |
|||
expect(document.body.classList.contains("section-overlay-open")).toBe(true); |
|||
|
|||
rerender(<MatchProfileOverlay open={false} onClose={handleClose} />); |
|||
|
|||
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(); |
|||
}); |
|||
}); |
|||
@ -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 ( |
|||
<SectionOverlayHost open={open} onClose={onClose}> |
|||
<NewMatchProfilePage onClose={onClose} /> |
|||
</SectionOverlayHost> |
|||
); |
|||
} |
|||
|
|||
export default MatchProfileOverlay; |
|||
@ -0,0 +1,116 @@ |
|||
import { act, cleanup, render, screen } from "@testing-library/react"; |
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
|||
import { I18nProvider } from "@/translations/provider"; |
|||
import SectionOverlayHost from "./section-overlay-host"; |
|||
|
|||
describe("SectionOverlayHost", () => { |
|||
beforeEach(() => { |
|||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); |
|||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { |
|||
return setTimeout(() => cb(Date.now()), 0); |
|||
}); |
|||
vi.stubGlobal("cancelAnimationFrame", (id: number) => { |
|||
clearTimeout(id); |
|||
}); |
|||
document.body.className = ""; |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
cleanup(); |
|||
vi.restoreAllMocks(); |
|||
vi.useRealTimers(); |
|||
document.body.className = ""; |
|||
}); |
|||
|
|||
it("returns null when no children or open is false", () => { |
|||
const { container } = render( |
|||
<I18nProvider locale="en"> |
|||
<SectionOverlayHost open={false}>{null}</SectionOverlayHost> |
|||
</I18nProvider>, |
|||
); |
|||
|
|||
expect(container.firstChild).toBeNull(); |
|||
expect(document.body.classList.contains("section-overlay-open")).toBe(false); |
|||
}); |
|||
|
|||
it("renders overlay and adds body class in LTR mode", () => { |
|||
render( |
|||
<I18nProvider locale="en"> |
|||
<SectionOverlayHost open={true}> |
|||
<div data-testid="detail-content">Detail Page Content</div> |
|||
</SectionOverlayHost> |
|||
</I18nProvider>, |
|||
); |
|||
|
|||
// Initial paint frame
|
|||
act(() => { |
|||
vi.advanceTimersByTime(16); |
|||
}); |
|||
|
|||
const overlay = screen.getByRole("dialog"); |
|||
expect(overlay).toBeInTheDocument(); |
|||
expect(overlay).toHaveClass("section-overlay"); |
|||
expect(overlay).toHaveAttribute("data-dir", "ltr"); |
|||
expect(overlay).toHaveAttribute("data-state", "open"); |
|||
expect(screen.getByTestId("detail-content")).toBeInTheDocument(); |
|||
expect(document.body.classList.contains("section-overlay-open")).toBe(true); |
|||
}); |
|||
|
|||
it("sets RTL direction when locale is fa", () => { |
|||
render( |
|||
<I18nProvider locale="fa"> |
|||
<SectionOverlayHost open={true}> |
|||
<div data-testid="detail-content">محتوای جزئیات</div> |
|||
</SectionOverlayHost> |
|||
</I18nProvider>, |
|||
); |
|||
|
|||
act(() => { |
|||
vi.advanceTimersByTime(16); |
|||
}); |
|||
|
|||
const overlay = screen.getByRole("dialog"); |
|||
expect(overlay).toHaveAttribute("data-dir", "rtl"); |
|||
expect(overlay).toHaveAttribute("dir", "rtl"); |
|||
}); |
|||
|
|||
it("retains children and animates to closing state when open becomes false", () => { |
|||
const { rerender } = render( |
|||
<I18nProvider locale="en"> |
|||
<SectionOverlayHost open={true}> |
|||
<div data-testid="detail-content">Detail Page Content</div> |
|||
</SectionOverlayHost> |
|||
</I18nProvider>, |
|||
); |
|||
|
|||
act(() => { |
|||
vi.advanceTimersByTime(16); |
|||
}); |
|||
|
|||
expect(screen.getByTestId("detail-content")).toBeInTheDocument(); |
|||
expect(screen.getByRole("dialog")).toHaveAttribute("data-state", "open"); |
|||
|
|||
// Close overlay (simulate back / close action)
|
|||
rerender( |
|||
<I18nProvider locale="en"> |
|||
<SectionOverlayHost open={false}> |
|||
<div data-testid="detail-content">Detail Page Content</div> |
|||
</SectionOverlayHost> |
|||
</I18nProvider>, |
|||
); |
|||
|
|||
// Content is retained during closing transition
|
|||
const overlay = screen.getByRole("dialog"); |
|||
expect(overlay).toHaveAttribute("data-state", "closing"); |
|||
expect(screen.getByTestId("detail-content")).toBeInTheDocument(); |
|||
expect(document.body.classList.contains("section-overlay-open")).toBe(false); |
|||
|
|||
// Advance past REVERSE_DURATION_MS (200ms)
|
|||
act(() => { |
|||
vi.advanceTimersByTime(210); |
|||
}); |
|||
|
|||
expect(screen.queryByTestId("detail-content")).not.toBeInTheDocument(); |
|||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,148 @@ |
|||
"use client"; |
|||
|
|||
import { |
|||
createContext, |
|||
type ReactNode, |
|||
useContext, |
|||
useEffect, |
|||
useMemo, |
|||
useRef, |
|||
useState, |
|||
} from "react"; |
|||
import { localeDirections } from "@/translations/config"; |
|||
import { useI18n } from "@/translations/provider"; |
|||
|
|||
type SectionOverlayContextValue = { |
|||
isOverlay: true; |
|||
onClose?: () => void; |
|||
}; |
|||
|
|||
const SectionOverlayContext = |
|||
createContext<SectionOverlayContextValue | null>(null); |
|||
|
|||
export function useSectionOverlay() { |
|||
return useContext(SectionOverlayContext); |
|||
} |
|||
|
|||
type SectionOverlayHostProps = { |
|||
open?: boolean; |
|||
onClose?: () => void; |
|||
children?: ReactNode; |
|||
}; |
|||
|
|||
const REVERSE_DURATION_MS = 200; |
|||
|
|||
export function SectionOverlayHost({ |
|||
open = false, |
|||
onClose, |
|||
children, |
|||
}: SectionOverlayHostProps) { |
|||
const { locale } = useI18n(); |
|||
const dir = (locale && localeDirections[locale]) || "ltr"; |
|||
|
|||
// Retain last rendered children during closing animation (like hosseinieh-app PanelSlot)
|
|||
const [activeChild, setActiveChild] = useState<ReactNode | null>( |
|||
open ? children ?? null : null, |
|||
); |
|||
const [mounted, setMounted] = useState(open); |
|||
const [state, setState] = useState<"closed" | "open" | "closing">( |
|||
open ? "open" : "closed", |
|||
); |
|||
|
|||
const isClosingRef = useRef(false); |
|||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
|||
|
|||
useEffect(() => { |
|||
if (open) { |
|||
if (closeTimerRef.current) { |
|||
clearTimeout(closeTimerRef.current); |
|||
closeTimerRef.current = null; |
|||
} |
|||
isClosingRef.current = false; |
|||
setMounted(true); |
|||
if (children) { |
|||
setActiveChild(children); |
|||
} |
|||
|
|||
const frame = requestAnimationFrame(() => { |
|||
setState("open"); |
|||
if (typeof document !== "undefined") { |
|||
document.body.classList.add("section-overlay-open"); |
|||
} |
|||
}); |
|||
return () => cancelAnimationFrame(frame); |
|||
} |
|||
|
|||
// When closing
|
|||
if (mounted && !isClosingRef.current) { |
|||
isClosingRef.current = true; |
|||
setState("closing"); |
|||
if (typeof document !== "undefined") { |
|||
document.body.classList.remove("section-overlay-open"); |
|||
} |
|||
|
|||
closeTimerRef.current = setTimeout(() => { |
|||
setMounted(false); |
|||
setState("closed"); |
|||
setActiveChild(null); |
|||
isClosingRef.current = false; |
|||
closeTimerRef.current = null; |
|||
}, REVERSE_DURATION_MS); |
|||
} |
|||
}, [open, children, mounted]); |
|||
|
|||
useEffect(() => { |
|||
return () => { |
|||
if (closeTimerRef.current) { |
|||
clearTimeout(closeTimerRef.current); |
|||
} |
|||
if (typeof document !== "undefined") { |
|||
document.body.classList.remove("section-overlay-open"); |
|||
} |
|||
}; |
|||
}, []); |
|||
|
|||
// ESC key handler to close panel
|
|||
useEffect(() => { |
|||
if (!open || !onClose) return; |
|||
const handleEsc = (e: KeyboardEvent) => { |
|||
if (e.key === "Escape") { |
|||
onClose(); |
|||
} |
|||
}; |
|||
window.addEventListener("keydown", handleEsc); |
|||
return () => window.removeEventListener("keydown", handleEsc); |
|||
}, [open, onClose]); |
|||
|
|||
const contextValue = useMemo<SectionOverlayContextValue>( |
|||
() => ({ |
|||
isOverlay: true, |
|||
onClose, |
|||
}), |
|||
[onClose], |
|||
); |
|||
|
|||
if (!mounted && !open && state === "closed") { |
|||
return null; |
|||
} |
|||
|
|||
const contentToRender = open ? children || activeChild : activeChild; |
|||
|
|||
return ( |
|||
<SectionOverlayContext.Provider value={contextValue}> |
|||
<aside |
|||
data-slot="section-overlay" |
|||
data-state={state} |
|||
data-dir={dir} |
|||
dir={dir} |
|||
aria-modal="true" |
|||
role="dialog" |
|||
className="section-overlay" |
|||
> |
|||
{contentToRender} |
|||
</aside> |
|||
</SectionOverlayContext.Provider> |
|||
); |
|||
} |
|||
|
|||
export default SectionOverlayHost; |
|||
@ -1,23 +1,8 @@ |
|||
import type { MarriageProfile } from "@/hooks/marriage/types"; |
|||
|
|||
/** |
|||
* Support is unavailable until a user has finished their profile. Women gain |
|||
* access on the waiting screen; men additionally need a currently valid |
|||
* subscription. |
|||
* Support is accessible for users across all main screens. |
|||
*/ |
|||
export function hasSupportAccess(profile?: MarriageProfile): boolean { |
|||
if (!profile || !["waiting", "in_case", "matched"].includes(profile.status)) { |
|||
return false; |
|||
} |
|||
|
|||
if (profile.gender === "female") { |
|||
return true; |
|||
} |
|||
|
|||
const subscription = profile.active_subscription; |
|||
return ( |
|||
profile.gender === "male" && |
|||
subscription?.is_active === true && |
|||
subscription.is_valid === true |
|||
); |
|||
return true; |
|||
} |
|||
@ -6,6 +6,9 @@ import { MdOutlineSwitchAccount } from "react-icons/md"; |
|||
import { getClientCookie, setClientCookie } from "@/lib/cookies"; |
|||
import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache"; |
|||
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
|||
// ۱. توکنهای ثابت سیستم (System Preset Tokens)
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
|||
export const FEMALE_TOKEN = "545f61bb3e061ccb9b19f84715eb1b1ed4740331"; |
|||
export const MALE_TOKEN = "f3a7543b44ef0a713d1ee0d4f7866b3825cf1308"; |
|||
export const MALE_2_TOKEN = "56cd0fceb93f7cecf7ffa84c590135026cf54a1d"; |
|||
@ -15,32 +18,130 @@ export const MALE_EMAIL = "[email protected]"; |
|||
export const MALE_2_EMAIL = "[email protected]"; |
|||
|
|||
const TOKEN_COOKIE_NAME = "HABIB_TOKEN"; |
|||
const DEV_CUSTOM_TOKENS_STORAGE_KEY = "HABIB_DEV_CUSTOM_TOKENS_V1"; |
|||
const DEV_ACTIVE_NAME_STORAGE_KEY = "HABIB_DEV_ACTIVE_NAME_V1"; |
|||
const DEV_ACTIVE_EMOJI_STORAGE_KEY = "HABIB_DEV_ACTIVE_EMOJI_V1"; |
|||
|
|||
type WindowWithHabibToken = Window & { |
|||
HABIB_TOKEN?: string; |
|||
}; |
|||
|
|||
export interface DevAccountItem { |
|||
id?: number | null; |
|||
token: string; |
|||
title: string; |
|||
subtitle?: string; |
|||
emoji?: string; |
|||
isCustom?: boolean; |
|||
} |
|||
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
|||
// ۲. لیست حسابهای پیشفرض و Inline درون کد:
|
|||
// هر توسعهدهنده میتواند توکنهای تستی جدید خود را همراه با نام دلخواه به این آرایه اضافه کند.
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
|||
export const PRESET_DEV_ACCOUNTS: DevAccountItem[] = [ |
|||
{ |
|||
id: 17119, |
|||
token: MALE_TOKEN, |
|||
title: "حساب آقا", |
|||
subtitle: MALE_EMAIL, |
|||
emoji: "👨", |
|||
}, |
|||
{ |
|||
id: 146024, |
|||
token: MALE_2_TOKEN, |
|||
title: "حساب آقا ۲", |
|||
subtitle: MALE_2_EMAIL, |
|||
emoji: "👨", |
|||
}, |
|||
{ |
|||
id: 147714, |
|||
token: FEMALE_TOKEN, |
|||
title: "حساب خانم", |
|||
subtitle: FEMALE_EMAIL, |
|||
emoji: "👩", |
|||
}, |
|||
// نمونه افزودن توکن جدید Inline در کد:
|
|||
// {
|
|||
// token: "YOUR_DEV_TOKEN_HERE",
|
|||
// title: "حساب تست طلاق",
|
|||
// subtitle: "تست مرحله ۳",
|
|||
// emoji: "🧪",
|
|||
// },
|
|||
]; |
|||
|
|||
const AVAILABLE_EMOJIS = ["🔑", "👨", "👩", "👤", "🧪", "⚡", "🎯", "🚀"]; |
|||
|
|||
function normalizeToken(token?: string | null): string { |
|||
if (!token) return ""; |
|||
let clean = decodeURIComponent(token).trim(); |
|||
if (clean.startsWith('"') && clean.endsWith('"')) { |
|||
clean = clean.slice(1, -1).trim(); |
|||
} |
|||
if (clean.toLowerCase().startsWith("token ")) { |
|||
clean = clean.slice(6).trim(); |
|||
} |
|||
return clean; |
|||
} |
|||
|
|||
type TokenSwitcherProps = { |
|||
variant?: "default" | "transparent"; |
|||
className?: string; |
|||
inlineAccounts?: DevAccountItem[]; |
|||
}; |
|||
|
|||
export function TokenSwitcher({ |
|||
variant = "default", |
|||
className, |
|||
inlineAccounts = [], |
|||
}: TokenSwitcherProps) { |
|||
const [mounted, setMounted] = useState(false); |
|||
const [isOpen, setIsOpen] = useState(false); |
|||
const [currentToken, setCurrentToken] = useState<string>(""); |
|||
const [activeStoredName, setActiveStoredName] = useState<string>(""); |
|||
const [activeStoredEmoji, setActiveStoredEmoji] = useState<string>(""); |
|||
const [savedCustomAccounts, setSavedCustomAccounts] = useState< |
|||
DevAccountItem[] |
|||
>([]); |
|||
const [customTitleInput, setCustomTitleInput] = useState<string>(""); |
|||
const [customTokenInput, setCustomTokenInput] = useState<string>(""); |
|||
const [isCustomInputOpen, setIsCustomInputOpen] = useState(false); |
|||
const [selectedEmoji, setSelectedEmoji] = useState<string>("🔑"); |
|||
|
|||
useEffect(() => { |
|||
setMounted(true); |
|||
if (typeof window !== "undefined") { |
|||
const token = |
|||
(window as any).HABIB_TOKEN ?? |
|||
const win = window as WindowWithHabibToken; |
|||
const rawToken = |
|||
win.HABIB_TOKEN ?? |
|||
getClientCookie(TOKEN_COOKIE_NAME) ?? |
|||
getClientCookie("habib_token") ?? |
|||
sessionStorage.getItem(TOKEN_COOKIE_NAME) ?? |
|||
""; |
|||
setCurrentToken(token); |
|||
const norm = normalizeToken(rawToken); |
|||
setCurrentToken(norm); |
|||
|
|||
try { |
|||
const savedName = window.localStorage.getItem( |
|||
DEV_ACTIVE_NAME_STORAGE_KEY, |
|||
); |
|||
const savedEmoji = window.localStorage.getItem( |
|||
DEV_ACTIVE_EMOJI_STORAGE_KEY, |
|||
); |
|||
if (savedName) setActiveStoredName(savedName); |
|||
if (savedEmoji) setActiveStoredEmoji(savedEmoji); |
|||
|
|||
const saved = window.localStorage.getItem( |
|||
DEV_CUSTOM_TOKENS_STORAGE_KEY, |
|||
); |
|||
if (saved) { |
|||
const parsed = JSON.parse(saved); |
|||
if (Array.isArray(parsed)) { |
|||
setSavedCustomAccounts(parsed); |
|||
} |
|||
} |
|||
} catch (e) { |
|||
console.error("Failed to load dev saved custom accounts:", e); |
|||
} |
|||
} |
|||
}, []); |
|||
|
|||
@ -48,60 +149,169 @@ export function TokenSwitcher({ |
|||
return null; |
|||
} |
|||
|
|||
const isMale = currentToken === MALE_TOKEN; |
|||
const isMale2 = currentToken === MALE_2_TOKEN; |
|||
const isFemale = currentToken === FEMALE_TOKEN; |
|||
const isNoToken = currentToken === "NO_TOKEN" || !currentToken; |
|||
const isCustomToken = |
|||
!isMale && !isMale2 && !isFemale && !isNoToken; |
|||
|
|||
const handleSelectToken = (newToken: string) => { |
|||
const trimmed = newToken.trim(); |
|||
if (!trimmed) return; |
|||
|
|||
const persistCustomAccounts = (accounts: DevAccountItem[]) => { |
|||
setSavedCustomAccounts(accounts); |
|||
if (typeof window !== "undefined") { |
|||
try { |
|||
window.localStorage.clear(); |
|||
window.sessionStorage.clear(); |
|||
window.localStorage.setItem( |
|||
DEV_CUSTOM_TOKENS_STORAGE_KEY, |
|||
JSON.stringify(accounts), |
|||
); |
|||
} catch (e) { |
|||
console.error("Failed to clear storage", e); |
|||
console.warn("Failed to persist custom accounts:", e); |
|||
} |
|||
} |
|||
}; |
|||
|
|||
const safeClearStorage = (keepName?: string, keepEmoji?: string) => { |
|||
if (typeof window === "undefined") return; |
|||
try { |
|||
const savedTokens = window.localStorage.getItem( |
|||
DEV_CUSTOM_TOKENS_STORAGE_KEY, |
|||
); |
|||
window.localStorage.clear(); |
|||
window.sessionStorage.clear(); |
|||
if (savedTokens) { |
|||
window.localStorage.setItem(DEV_CUSTOM_TOKENS_STORAGE_KEY, savedTokens); |
|||
} |
|||
if (keepName) { |
|||
window.localStorage.setItem(DEV_ACTIVE_NAME_STORAGE_KEY, keepName); |
|||
} |
|||
if (keepEmoji) { |
|||
window.localStorage.setItem(DEV_ACTIVE_EMOJI_STORAGE_KEY, keepEmoji); |
|||
} |
|||
} catch (e) { |
|||
console.error("Failed to clear storage safely:", e); |
|||
} |
|||
}; |
|||
|
|||
const allAccounts: DevAccountItem[] = [ |
|||
...PRESET_DEV_ACCOUNTS, |
|||
...inlineAccounts, |
|||
...savedCustomAccounts, |
|||
]; |
|||
|
|||
const normCurrent = normalizeToken(currentToken); |
|||
const activeAccount = allAccounts.find( |
|||
(acc) => normalizeToken(acc.token) === normCurrent, |
|||
); |
|||
const isNoToken = |
|||
normCurrent === "NO_TOKEN" || !normCurrent || normCurrent === ""; |
|||
|
|||
const handleSelectToken = ( |
|||
newToken: string, |
|||
accountTitle?: string, |
|||
accountEmoji?: string, |
|||
) => { |
|||
const cleanToken = normalizeToken(newToken); |
|||
if (!cleanToken && cleanToken !== "NO_TOKEN") return; |
|||
|
|||
// Find account info if title was not explicitly passed
|
|||
const targetAccount = allAccounts.find( |
|||
(acc) => normalizeToken(acc.token) === cleanToken, |
|||
); |
|||
const resolvedTitle = |
|||
accountTitle || |
|||
(cleanToken === "NO_TOKEN" |
|||
? "کاربر بدون توکن" |
|||
: targetAccount?.title || "حساب سفارشی"); |
|||
const resolvedEmoji = |
|||
accountEmoji || |
|||
(cleanToken === "NO_TOKEN" ? "👤" : targetAccount?.emoji || "🔑"); |
|||
|
|||
if (typeof window !== "undefined") { |
|||
safeClearStorage(resolvedTitle, resolvedEmoji); |
|||
|
|||
setCachedMarriageEntryPath(null); |
|||
setClientCookie(TOKEN_COOKIE_NAME, trimmed); |
|||
setClientCookie("habib_token", trimmed); |
|||
setClientCookie(TOKEN_COOKIE_NAME, cleanToken); |
|||
setClientCookie("habib_token", cleanToken); |
|||
try { |
|||
sessionStorage.setItem(TOKEN_COOKIE_NAME, trimmed); |
|||
sessionStorage.setItem(TOKEN_COOKIE_NAME, cleanToken); |
|||
} catch (e) { |
|||
console.warn("sessionStorage is not accessible:", e); |
|||
} |
|||
(window as any).HABIB_TOKEN = trimmed; |
|||
setCurrentToken(trimmed); |
|||
(window as WindowWithHabibToken).HABIB_TOKEN = cleanToken; |
|||
setCurrentToken(cleanToken); |
|||
setActiveStoredName(resolvedTitle); |
|||
setActiveStoredEmoji(resolvedEmoji); |
|||
setIsOpen(false); |
|||
|
|||
window.location.replace("/"); |
|||
} |
|||
}; |
|||
|
|||
const handleAddAndSelectAccount = (e?: React.FormEvent) => { |
|||
if (e) e.preventDefault(); |
|||
const cleanToken = normalizeToken(customTokenInput); |
|||
if (!cleanToken) return; |
|||
|
|||
const cleanTitle = |
|||
customTitleInput.trim() || `حساب سفارشی (${cleanToken.slice(0, 6)}...)`; |
|||
|
|||
const newAccount: DevAccountItem = { |
|||
token: cleanToken, |
|||
title: cleanTitle, |
|||
emoji: selectedEmoji, |
|||
isCustom: true, |
|||
}; |
|||
|
|||
const updated = [ |
|||
newAccount, |
|||
...savedCustomAccounts.filter( |
|||
(a) => normalizeToken(a.token) !== cleanToken, |
|||
), |
|||
]; |
|||
persistCustomAccounts(updated); |
|||
setCustomTitleInput(""); |
|||
setCustomTokenInput(""); |
|||
handleSelectToken(cleanToken, cleanTitle, selectedEmoji); |
|||
}; |
|||
|
|||
const handleSaveAccountOnly = () => { |
|||
const cleanToken = normalizeToken(customTokenInput); |
|||
if (!cleanToken) return; |
|||
|
|||
const cleanTitle = |
|||
customTitleInput.trim() || `حساب سفارشی (${cleanToken.slice(0, 6)}...)`; |
|||
|
|||
const newAccount: DevAccountItem = { |
|||
token: cleanToken, |
|||
title: cleanTitle, |
|||
emoji: selectedEmoji, |
|||
isCustom: true, |
|||
}; |
|||
|
|||
const updated = [ |
|||
newAccount, |
|||
...savedCustomAccounts.filter( |
|||
(a) => normalizeToken(a.token) !== cleanToken, |
|||
), |
|||
]; |
|||
persistCustomAccounts(updated); |
|||
setCustomTitleInput(""); |
|||
setCustomTokenInput(""); |
|||
}; |
|||
|
|||
const handleDeleteCustomAccount = (tokenToDelete: string) => { |
|||
const cleanDelete = normalizeToken(tokenToDelete); |
|||
const updated = savedCustomAccounts.filter( |
|||
(acc) => normalizeToken(acc.token) !== cleanDelete, |
|||
); |
|||
persistCustomAccounts(updated); |
|||
}; |
|||
|
|||
const handleResetUser = async (userId: number | null, label: string) => { |
|||
const message = |
|||
userId !== null |
|||
? `آیا از پاک کردن تمامی اطلاعات هویتی، پاسخها و پیشرفت این کاربر (${label}) و شروع مجدد از مرحله اول آنبوردینگ مطمئن هستید؟` |
|||
: `آیا از پاک کردن تمامی اطلاعات محلی و کوکیهای مربوط به این حالت (بدون توکن) و شروع مجدد مطمئن هستید؟`; |
|||
: `آیا از پاک کردن تمامی اطلاعات محلی و کوکیهای مربوط به این حالت (${label}) و شروع مجدد مطمئن هستید؟`; |
|||
|
|||
if (!window.confirm(message)) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
if (typeof window !== "undefined") { |
|||
try { |
|||
window.localStorage.clear(); |
|||
window.sessionStorage.clear(); |
|||
} catch (e) { |
|||
console.error("Failed to clear storage", e); |
|||
} |
|||
} |
|||
safeClearStorage(); |
|||
|
|||
if (userId !== null) { |
|||
const response = await fetch("/api/dev-reset-profile", { |
|||
@ -128,63 +338,45 @@ export function TokenSwitcher({ |
|||
? MALE_2_TOKEN |
|||
: "NO_TOKEN"; |
|||
|
|||
setCachedMarriageEntryPath(null); |
|||
setClientCookie(TOKEN_COOKIE_NAME, targetToken); |
|||
setClientCookie("habib_token", targetToken); |
|||
if (typeof window !== "undefined") { |
|||
try { |
|||
sessionStorage.setItem(TOKEN_COOKIE_NAME, targetToken); |
|||
} catch (e) { |
|||
console.warn("sessionStorage is not accessible:", e); |
|||
} |
|||
(window as any).HABIB_TOKEN = targetToken; |
|||
setCurrentToken(targetToken); |
|||
} |
|||
const targetAccount = allAccounts.find( |
|||
(a) => normalizeToken(a.token) === normalizeToken(targetToken), |
|||
); |
|||
const targetTitle = targetAccount?.title || label; |
|||
const targetEmoji = targetAccount?.emoji || "🔑"; |
|||
|
|||
setIsOpen(false); |
|||
window.location.replace("/"); |
|||
handleSelectToken(targetToken, targetTitle, targetEmoji); |
|||
} catch (error) { |
|||
console.error("Failed to reset user:", error); |
|||
alert("خطایی در انجام عملیات رخ داد."); |
|||
} |
|||
}; |
|||
|
|||
const mainAccounts = [ |
|||
{ |
|||
id: 17119, |
|||
token: MALE_TOKEN, |
|||
title: "حساب آقا", |
|||
subtitle: MALE_EMAIL, |
|||
emoji: "👨", |
|||
isActive: isMale, |
|||
}, |
|||
{ |
|||
id: 146024, |
|||
token: MALE_2_TOKEN, |
|||
title: "حساب آقا ۲", |
|||
subtitle: MALE_2_EMAIL, |
|||
emoji: "👨", |
|||
isActive: isMale2, |
|||
}, |
|||
{ |
|||
id: 147714, |
|||
token: FEMALE_TOKEN, |
|||
title: "حساب خانم", |
|||
subtitle: FEMALE_EMAIL, |
|||
emoji: "👩", |
|||
isActive: isFemale, |
|||
}, |
|||
]; |
|||
// ─────────────────────────────────────────────────────────────────────────
|
|||
// عنوان و نام نمایشی روی Badge بسته شده (Floating Closed Badge Label)
|
|||
// ─────────────────────────────────────────────────────────────────────────
|
|||
const getFloatingButtonLabel = () => { |
|||
if (isNoToken) { |
|||
return "بدون توکن 👤"; |
|||
} |
|||
if (activeAccount) { |
|||
return `${activeAccount.title} ${activeAccount.emoji || "🔑"}`; |
|||
} |
|||
if (activeStoredName) { |
|||
return `${activeStoredName} ${activeStoredEmoji || "🔑"}`; |
|||
} |
|||
return "حساب سفارشی 🔑"; |
|||
}; |
|||
|
|||
return ( |
|||
<> |
|||
{/* Floating Badge (وقتی بسته است فقط نام حساب و ایموجی را نمایش میدهد) */} |
|||
<button |
|||
type="button" |
|||
onClick={() => setIsOpen(true)} |
|||
aria-label="تغییر توکن کاربر (تست)" |
|||
title="تغییر توکن (آقا / خانم)" |
|||
title="تغییر توکن کاربر (محیط توسعه)" |
|||
className={[ |
|||
"fixed top-4 left-4 z-[99999] inline-flex items-center gap-1.5 rounded-[15px] px-3 py-2 text-xs font-bold transition-all active:scale-95 shadow-md border border-slate-200/80 dark:border-slate-700/80 backdrop-blur-md", |
|||
"fixed top-4 left-4 z-[99999] inline-flex items-center gap-2 rounded-[15px] px-3.5 py-2 text-xs font-extrabold transition-all active:scale-95 shadow-md border border-slate-200/80 dark:border-slate-700/80 backdrop-blur-md max-w-[260px] cursor-pointer", |
|||
variant === "transparent" |
|||
? "bg-black/40 text-white hover:bg-black/50" |
|||
: "bg-white/95 text-slate-800 hover:bg-white dark:bg-slate-900/95 dark:text-slate-100", |
|||
@ -193,33 +385,31 @@ export function TokenSwitcher({ |
|||
.filter(Boolean) |
|||
.join(" ")} |
|||
> |
|||
<MdOutlineSwitchAccount className="size-5 text-rose-500" /> |
|||
<span> |
|||
{isMale |
|||
? "آقا 👨" |
|||
: isMale2 |
|||
? "آقا ۲ 👨" |
|||
: isFemale |
|||
? "خانم 👩" |
|||
: isNoToken |
|||
? "بدون توکن 👤" |
|||
: "سفارشی 🔑"} |
|||
</span> |
|||
<MdOutlineSwitchAccount className="size-5 text-rose-500 shrink-0" /> |
|||
<span className="truncate">{getFloatingButtonLabel()}</span> |
|||
</button> |
|||
|
|||
{isOpen && ( |
|||
<div |
|||
className="fixed inset-0 z-[100000] flex items-center justify-center bg-black/60 p-4 backdrop-blur-xs animate-in fade-in duration-200" |
|||
onClick={() => setIsOpen(false)} |
|||
> |
|||
<div className="fixed inset-0 z-[100000] flex items-center justify-center p-4 animate-in fade-in duration-200"> |
|||
{/* Backdrop Button */} |
|||
<button |
|||
type="button" |
|||
aria-label="بستن پنجره" |
|||
onClick={() => setIsOpen(false)} |
|||
className="fixed inset-0 bg-black/60 backdrop-blur-xs cursor-default" |
|||
/> |
|||
|
|||
{/* Modal Container */} |
|||
<div |
|||
className="w-full max-w-sm rounded-2xl bg-white p-5 shadow-2xl dark:bg-slate-900 border border-slate-100 dark:border-slate-800 max-h-[90vh] flex flex-col" |
|||
onClick={(e) => e.stopPropagation()} |
|||
className="relative z-10 w-full max-w-md rounded-2xl bg-white shadow-2xl dark:bg-slate-900 border border-slate-100 dark:border-slate-800 max-h-[90vh] flex flex-col overflow-hidden text-right" |
|||
dir="rtl" |
|||
role="dialog" |
|||
aria-modal="true" |
|||
> |
|||
{/* Modal Header */} |
|||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100 dark:border-slate-800 shrink-0 bg-slate-50/50 dark:bg-slate-900/50"> |
|||
<div className="flex items-center gap-3"> |
|||
<div className="size-10 rounded-xl bg-rose-500/10 text-rose-500 flex items-center justify-center"> |
|||
<div className="size-10 rounded-xl bg-rose-500/10 text-rose-500 flex items-center justify-center shrink-0"> |
|||
<MdOutlineSwitchAccount className="size-5" /> |
|||
</div> |
|||
<div> |
|||
@ -227,67 +417,300 @@ export function TokenSwitcher({ |
|||
انتخاب حساب کاربری (تست) |
|||
</h3> |
|||
<p className="text-xs text-slate-500 dark:text-slate-400"> |
|||
تغییر سریع پروفایل و توکن جهت تست |
|||
تغییر سریع پروفایل، نامگذاری و مدیریت توکنها |
|||
</p> |
|||
</div> |
|||
</div> |
|||
<button |
|||
type="button" |
|||
onClick={() => setIsOpen(false)} |
|||
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 text-sm p-1" |
|||
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 text-sm p-1 cursor-pointer" |
|||
> |
|||
<IoClose className="size-5" /> |
|||
</button> |
|||
</div> |
|||
|
|||
{/* Scrollable Account Cards */} |
|||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-2.5 min-h-0"> |
|||
{/* Main Predefined Accounts */} |
|||
{mainAccounts.map((acc) => ( |
|||
{/* Scrollable Content */} |
|||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4 min-h-0"> |
|||
{/* Predefined System Accounts */} |
|||
<div className="space-y-2"> |
|||
<div className="text-[11px] font-bold text-slate-400 dark:text-slate-500 px-1"> |
|||
حسابهای پیشفرض سیستم |
|||
</div> |
|||
<div className="space-y-2"> |
|||
{PRESET_DEV_ACCOUNTS.map((acc) => { |
|||
const isActive = normalizeToken(acc.token) === normCurrent; |
|||
return ( |
|||
<div |
|||
key={acc.token} |
|||
className={[ |
|||
"flex items-center justify-between gap-3 p-3 rounded-2xl border transition-all select-none", |
|||
isActive |
|||
? "border-rose-500 bg-rose-50/70 dark:bg-rose-950/40 shadow-xs ring-1 ring-rose-500/30" |
|||
: "border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700 bg-white dark:bg-slate-800/80 hover:bg-slate-50/60 dark:hover:bg-slate-800", |
|||
].join(" ")} |
|||
> |
|||
<button |
|||
type="button" |
|||
onClick={() => |
|||
handleSelectToken(acc.token, acc.title, acc.emoji) |
|||
} |
|||
className="flex-1 min-w-0 flex items-center gap-3 text-right cursor-pointer bg-transparent border-0 p-0 text-inherit" |
|||
> |
|||
<div className="size-11 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-2xl shrink-0 border border-slate-200/60 dark:border-slate-700/60 shadow-3xs"> |
|||
{acc.emoji || "👨"} |
|||
</div> |
|||
<div className="flex-1 min-w-0 flex flex-col justify-center gap-0.5 text-right"> |
|||
<span className="text-sm font-bold text-slate-900 dark:text-white leading-tight"> |
|||
{acc.title} |
|||
</span> |
|||
{acc.subtitle && ( |
|||
<span |
|||
className="text-xs text-slate-500 dark:text-slate-400 font-medium truncate block leading-tight text-right" |
|||
dir="ltr" |
|||
> |
|||
{acc.subtitle} |
|||
</span> |
|||
)} |
|||
<span |
|||
className="text-[10px] font-mono text-slate-400 dark:text-slate-500 truncate block text-left leading-tight" |
|||
dir="ltr" |
|||
> |
|||
{acc.token} |
|||
</span> |
|||
</div> |
|||
</button> |
|||
|
|||
<div className="flex items-center gap-1.5 shrink-0 pr-1"> |
|||
{isActive && ( |
|||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-rose-500 text-white shadow-xs"> |
|||
فعال |
|||
</span> |
|||
)} |
|||
<button |
|||
type="button" |
|||
onClick={() => |
|||
handleResetUser(acc.id ?? null, acc.title) |
|||
} |
|||
title="پاک کردن اطلاعات و شروع مجدد" |
|||
className="size-8 rounded-lg flex items-center justify-center text-slate-400 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition-colors cursor-pointer" |
|||
> |
|||
<IoTrashOutline className="size-4" /> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
); |
|||
})} |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Inline Passed Accounts (if any) */} |
|||
{inlineAccounts.length > 0 && ( |
|||
<div className="space-y-2"> |
|||
<div className="text-[11px] font-bold text-slate-400 dark:text-slate-500 px-1"> |
|||
حسابهای کد (Inline) |
|||
</div> |
|||
<div className="space-y-2"> |
|||
{inlineAccounts.map((acc) => { |
|||
const isActive = |
|||
normalizeToken(acc.token) === normCurrent; |
|||
return ( |
|||
<div |
|||
key={acc.token} |
|||
className={[ |
|||
"flex items-center justify-between gap-3 p-3 rounded-2xl border transition-all select-none", |
|||
isActive |
|||
? "border-rose-500 bg-rose-50/70 dark:bg-rose-950/40 shadow-xs ring-1 ring-rose-500/30" |
|||
: "border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700 bg-white dark:bg-slate-800/80 hover:bg-slate-50/60 dark:hover:bg-slate-800", |
|||
].join(" ")} |
|||
> |
|||
<button |
|||
type="button" |
|||
onClick={() => |
|||
handleSelectToken(acc.token, acc.title, acc.emoji) |
|||
} |
|||
className="flex-1 min-w-0 flex items-center gap-3 text-right cursor-pointer bg-transparent border-0 p-0 text-inherit" |
|||
> |
|||
<div className="size-11 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-2xl shrink-0 border border-slate-200/60 dark:border-slate-700/60 shadow-3xs"> |
|||
{acc.emoji || "🔑"} |
|||
</div> |
|||
<div className="flex-1 min-w-0 flex flex-col justify-center gap-0.5 text-right"> |
|||
<span className="text-sm font-bold text-slate-900 dark:text-white leading-tight"> |
|||
{acc.title} |
|||
</span> |
|||
<span |
|||
className="text-[10px] font-mono text-slate-400 dark:text-slate-500 truncate block text-left leading-tight" |
|||
dir="ltr" |
|||
> |
|||
{acc.token} |
|||
</span> |
|||
</div> |
|||
</button> |
|||
|
|||
<div className="flex items-center gap-1.5 shrink-0 pr-1"> |
|||
{isActive && ( |
|||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-rose-500 text-white shadow-xs"> |
|||
فعال |
|||
</span> |
|||
)} |
|||
</div> |
|||
</div> |
|||
); |
|||
})} |
|||
</div> |
|||
</div> |
|||
)} |
|||
|
|||
{/* Saved Custom Named Accounts */} |
|||
{savedCustomAccounts.length > 0 && ( |
|||
<div className="space-y-2"> |
|||
<div className="text-[11px] font-bold text-slate-400 dark:text-slate-500 px-1"> |
|||
حسابهای سفارشی شما |
|||
</div> |
|||
<div className="space-y-2"> |
|||
{savedCustomAccounts.map((acc) => { |
|||
const isActive = |
|||
normalizeToken(acc.token) === normCurrent; |
|||
return ( |
|||
<div |
|||
key={acc.token} |
|||
className={[ |
|||
"flex items-center justify-between gap-3 p-3 rounded-2xl border transition-all select-none", |
|||
isActive |
|||
? "border-rose-500 bg-rose-50/70 dark:bg-rose-950/40 shadow-xs ring-1 ring-rose-500/30" |
|||
: "border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700 bg-white dark:bg-slate-800/80 hover:bg-slate-50/60 dark:hover:bg-slate-800", |
|||
].join(" ")} |
|||
> |
|||
<button |
|||
type="button" |
|||
onClick={() => |
|||
handleSelectToken(acc.token, acc.title, acc.emoji) |
|||
} |
|||
className="flex-1 min-w-0 flex items-center gap-3 text-right cursor-pointer bg-transparent border-0 p-0 text-inherit" |
|||
> |
|||
<div className="size-11 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-2xl shrink-0 border border-slate-200/60 dark:border-slate-700/60 shadow-3xs"> |
|||
{acc.emoji || "🔑"} |
|||
</div> |
|||
<div className="flex-1 min-w-0 flex flex-col justify-center gap-0.5 text-right"> |
|||
<span className="text-sm font-bold text-slate-900 dark:text-white leading-tight"> |
|||
{acc.title} |
|||
</span> |
|||
<span |
|||
className="text-[10px] font-mono text-slate-400 dark:text-slate-500 truncate block text-left leading-tight" |
|||
dir="ltr" |
|||
> |
|||
{acc.token} |
|||
</span> |
|||
</div> |
|||
</button> |
|||
|
|||
<div className="flex items-center gap-1.5 shrink-0 pr-1"> |
|||
{isActive && ( |
|||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-rose-500 text-white shadow-xs"> |
|||
فعال |
|||
</span> |
|||
)} |
|||
<button |
|||
type="button" |
|||
onClick={() => |
|||
handleDeleteCustomAccount(acc.token) |
|||
} |
|||
title="حذف از لیست حسابهای سفارشی" |
|||
className="size-8 rounded-lg flex items-center justify-center text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/40 transition-colors cursor-pointer" |
|||
> |
|||
<IoTrashOutline className="size-4" /> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
); |
|||
})} |
|||
</div> |
|||
</div> |
|||
)} |
|||
|
|||
{/* Unnamed Active Custom Token (if any active token not in list) */} |
|||
{!activeAccount && !isNoToken && ( |
|||
<div className="space-y-2"> |
|||
<div className="text-[11px] font-bold text-slate-400 dark:text-slate-500 px-1"> |
|||
توکن فعال فعلی |
|||
</div> |
|||
<div className="flex items-center justify-between gap-3 p-3 rounded-2xl border border-rose-500 bg-rose-50/70 dark:bg-rose-950/40 shadow-xs ring-1 ring-rose-500/30 select-none"> |
|||
<div className="flex-1 min-w-0 flex items-center gap-3 text-right"> |
|||
<div className="size-11 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-2xl shrink-0 border border-slate-200/60 dark:border-slate-700/60 shadow-3xs"> |
|||
{activeStoredEmoji || "🔑"} |
|||
</div> |
|||
<div className="flex-1 min-w-0 flex flex-col justify-center gap-0.5 text-right"> |
|||
<span className="text-sm font-bold text-slate-900 dark:text-white leading-tight"> |
|||
{activeStoredName || "توکن سفارشی فعال"} |
|||
</span> |
|||
<span |
|||
className="text-[10px] font-mono text-slate-500 dark:text-slate-400 truncate block text-left leading-tight" |
|||
dir="ltr" |
|||
> |
|||
{currentToken} |
|||
</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="flex items-center gap-1.5 shrink-0 pr-1"> |
|||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-rose-500 text-white shadow-xs"> |
|||
فعال |
|||
</span> |
|||
<button |
|||
type="button" |
|||
onClick={() => handleResetUser(null, "توکن سفارشی")} |
|||
title="پاک کردن اطلاعات و شروع مجدد" |
|||
className="size-8 rounded-lg flex items-center justify-center text-slate-400 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition-colors cursor-pointer" |
|||
> |
|||
<IoTrashOutline className="size-4" /> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
)} |
|||
|
|||
{/* No Token Option Card */} |
|||
<div className="space-y-2"> |
|||
<div className="text-[11px] font-bold text-slate-400 dark:text-slate-500 px-1"> |
|||
حالت بدون احراز هویت |
|||
</div> |
|||
<div |
|||
key={acc.token} |
|||
className={[ |
|||
"flex items-center justify-between gap-3 p-3 rounded-2xl border transition-all select-none", |
|||
acc.isActive |
|||
isNoToken |
|||
? "border-rose-500 bg-rose-50/70 dark:bg-rose-950/40 shadow-xs ring-1 ring-rose-500/30" |
|||
: "border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700 bg-white dark:bg-slate-800/80 hover:bg-slate-50/60 dark:hover:bg-slate-800", |
|||
].join(" ")} |
|||
> |
|||
<div |
|||
onClick={() => handleSelectToken(acc.token)} |
|||
className="flex-1 min-w-0 flex items-center gap-3 text-right cursor-pointer" |
|||
<button |
|||
type="button" |
|||
onClick={() => |
|||
handleSelectToken("NO_TOKEN", "کاربر بدون توکن", "👤") |
|||
} |
|||
className="flex-1 min-w-0 flex items-center gap-3 text-right cursor-pointer bg-transparent border-0 p-0 text-inherit" |
|||
> |
|||
<div className="size-11 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-2xl shrink-0 border border-slate-200/60 dark:border-slate-700/60 shadow-3xs"> |
|||
{acc.emoji} |
|||
👤 |
|||
</div> |
|||
<div className="flex-1 min-w-0 flex flex-col justify-center gap-0.5 text-right"> |
|||
<span className="text-sm font-bold text-slate-900 dark:text-white leading-tight"> |
|||
{acc.title} |
|||
</span> |
|||
<span |
|||
className="text-xs text-slate-500 dark:text-slate-400 font-medium truncate block leading-tight text-right" |
|||
dir="ltr" |
|||
> |
|||
{acc.subtitle} |
|||
کاربر بدون توکن |
|||
</span> |
|||
<span |
|||
className="text-[10px] font-mono text-slate-400 dark:text-slate-500 truncate block text-left leading-tight" |
|||
dir="ltr" |
|||
> |
|||
{acc.token} |
|||
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium truncate block leading-tight"> |
|||
حالت مهمان / کاربر جدید بدون لاگین |
|||
</span> |
|||
</div> |
|||
</div> |
|||
</button> |
|||
|
|||
<div className="flex items-center gap-1.5 shrink-0 pr-1"> |
|||
{acc.isActive && ( |
|||
{isNoToken && ( |
|||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-rose-500 text-white shadow-xs"> |
|||
فعال |
|||
</span> |
|||
)} |
|||
<button |
|||
type="button" |
|||
onClick={() => handleResetUser(acc.id, acc.title)} |
|||
onClick={() => handleResetUser(null, "کاربر بدون توکن")} |
|||
title="پاک کردن اطلاعات و شروع مجدد" |
|||
className="size-8 rounded-lg flex items-center justify-center text-slate-400 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition-colors cursor-pointer" |
|||
> |
|||
@ -295,117 +718,84 @@ export function TokenSwitcher({ |
|||
</button> |
|||
</div> |
|||
</div> |
|||
))} |
|||
</div> |
|||
|
|||
{/* Custom Active Token Card (if any custom token is set) */} |
|||
{isCustomToken && ( |
|||
<div className="flex items-center justify-between gap-3 p-3 rounded-2xl border border-rose-500 bg-rose-50/70 dark:bg-rose-950/40 shadow-xs ring-1 ring-rose-500/30 select-none"> |
|||
<div className="flex-1 min-w-0 flex items-center gap-3 text-right"> |
|||
<div className="size-11 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-2xl shrink-0 border border-slate-200/60 dark:border-slate-700/60 shadow-3xs"> |
|||
🔑 |
|||
</div> |
|||
<div className="flex-1 min-w-0 flex flex-col justify-center gap-0.5 text-right"> |
|||
<span className="text-sm font-bold text-slate-900 dark:text-white leading-tight"> |
|||
توکن سفارشی |
|||
</span> |
|||
<span |
|||
className="text-[10px] font-mono text-slate-500 dark:text-slate-400 truncate block text-left leading-tight" |
|||
dir="ltr" |
|||
> |
|||
{currentToken} |
|||
</span> |
|||
</div> |
|||
{/* Add New Custom Named Token Form */} |
|||
<form |
|||
onSubmit={handleAddAndSelectAccount} |
|||
className="rounded-2xl border border-dashed border-slate-300 dark:border-slate-700 p-4 bg-slate-50/70 dark:bg-slate-800/40 space-y-3" |
|||
> |
|||
<div className="flex items-center justify-between"> |
|||
<div className="flex items-center gap-2 text-xs font-bold text-slate-700 dark:text-slate-300"> |
|||
<IoKeyOutline className="size-4 text-amber-500" /> |
|||
<span>افزودن حساب تستی با نام دلخواه:</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="flex items-center gap-1.5 shrink-0 pr-1"> |
|||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-rose-500 text-white shadow-xs"> |
|||
فعال |
|||
</span> |
|||
{/* Emoji Selector */} |
|||
<div className="flex items-center gap-1.5 overflow-x-auto pb-1"> |
|||
<span className="text-[11px] text-slate-500 dark:text-slate-400 shrink-0 ml-1"> |
|||
آیکون: |
|||
</span> |
|||
{AVAILABLE_EMOJIS.map((emoji) => ( |
|||
<button |
|||
key={emoji} |
|||
type="button" |
|||
onClick={() => handleResetUser(null, "توکن سفارشی")} |
|||
title="پاک کردن اطلاعات و شروع مجدد" |
|||
className="size-8 rounded-lg flex items-center justify-center text-slate-400 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition-colors cursor-pointer" |
|||
onClick={() => setSelectedEmoji(emoji)} |
|||
className={[ |
|||
"size-7 rounded-lg text-sm flex items-center justify-center transition-all cursor-pointer", |
|||
selectedEmoji === emoji |
|||
? "bg-rose-500/20 ring-1 ring-rose-500 scale-110 shadow-3xs" |
|||
: "bg-white dark:bg-slate-900 hover:bg-slate-100 dark:hover:bg-slate-800 border border-slate-200 dark:border-slate-700", |
|||
].join(" ")} |
|||
> |
|||
<IoTrashOutline className="size-4" /> |
|||
{emoji} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
)} |
|||
|
|||
{/* No Token Option Card */} |
|||
<div |
|||
className={[ |
|||
"flex items-center justify-between gap-3 p-3 rounded-2xl border transition-all select-none", |
|||
isNoToken |
|||
? "border-rose-500 bg-rose-50/70 dark:bg-rose-950/40 shadow-xs ring-1 ring-rose-500/30" |
|||
: "border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700 bg-white dark:bg-slate-800/80 hover:bg-slate-50/60 dark:hover:bg-slate-800", |
|||
].join(" ")} |
|||
> |
|||
<div |
|||
onClick={() => handleSelectToken("NO_TOKEN")} |
|||
className="flex-1 min-w-0 flex items-center gap-3 text-right cursor-pointer" |
|||
> |
|||
<div className="size-11 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-2xl shrink-0 border border-slate-200/60 dark:border-slate-700/60 shadow-3xs"> |
|||
👤 |
|||
</div> |
|||
<div className="flex-1 min-w-0 flex flex-col justify-center gap-0.5 text-right"> |
|||
<span className="text-sm font-bold text-slate-900 dark:text-white leading-tight"> |
|||
کاربر بدون توکن |
|||
</span> |
|||
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium truncate block leading-tight"> |
|||
حالت مهمان / بدون احراز هویت |
|||
</span> |
|||
</div> |
|||
))} |
|||
</div> |
|||
|
|||
<div className="flex items-center gap-1.5 shrink-0 pr-1"> |
|||
{isNoToken && ( |
|||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-rose-500 text-white shadow-xs"> |
|||
فعال |
|||
</span> |
|||
)} |
|||
<button |
|||
type="button" |
|||
onClick={() => handleResetUser(null, "کاربر بدون توکن")} |
|||
title="پاک کردن اطلاعات و شروع مجدد" |
|||
className="size-8 rounded-lg flex items-center justify-center text-slate-400 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition-colors cursor-pointer" |
|||
> |
|||
<IoTrashOutline className="size-4" /> |
|||
</button> |
|||
{/* Title Input */} |
|||
<div> |
|||
<input |
|||
type="text" |
|||
value={customTitleInput} |
|||
onChange={(e) => setCustomTitleInput(e.target.value)} |
|||
placeholder="نام یا عنوان حساب (مثلاً: تست طلاق، کاربر سناریو ۲)" |
|||
className="w-full rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-3 py-2 text-xs text-slate-800 dark:text-slate-200 outline-none focus:border-rose-500 focus:ring-1 focus:ring-rose-500/20 transition-all placeholder:text-slate-400 placeholder:text-[11px]" |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Custom Token Input Section */} |
|||
<div className="rounded-2xl border border-dashed border-slate-300 dark:border-slate-700 p-3.5 bg-slate-50/70 dark:bg-slate-800/40 space-y-2.5 mt-3"> |
|||
<div className="flex items-center gap-2 text-xs font-bold text-slate-700 dark:text-slate-300"> |
|||
<IoKeyOutline className="size-4 text-amber-500" /> |
|||
<span>افزودن یا تنظیم توکن دلخواه:</span> |
|||
</div> |
|||
<div className="flex items-center gap-2"> |
|||
{/* Token Input */} |
|||
<div> |
|||
<input |
|||
type="text" |
|||
value={customTokenInput} |
|||
onChange={(e) => setCustomTokenInput(e.target.value)} |
|||
placeholder="توکن ۳۲/۴۰ کاراکتری..." |
|||
placeholder="رشته توکن (۳۲ یا ۴۰ کاراکتری)..." |
|||
dir="ltr" |
|||
className="flex-1 min-w-0 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-3 py-2 text-xs font-mono text-slate-800 dark:text-slate-200 outline-none focus:border-rose-500 focus:ring-1 focus:ring-rose-500/20 transition-all placeholder:text-slate-400 placeholder:text-[11px]" |
|||
onKeyDown={(e) => { |
|||
if (e.key === "Enter" && customTokenInput.trim()) { |
|||
handleSelectToken(customTokenInput); |
|||
} |
|||
}} |
|||
className="w-full rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-3 py-2 text-xs font-mono text-slate-800 dark:text-slate-200 outline-none focus:border-rose-500 focus:ring-1 focus:ring-rose-500/20 transition-all placeholder:text-slate-400 placeholder:text-[11px]" |
|||
/> |
|||
</div> |
|||
|
|||
{/* Action Buttons */} |
|||
<div className="flex items-center gap-2 pt-1"> |
|||
<button |
|||
type="submit" |
|||
disabled={!customTokenInput.trim()} |
|||
className="flex-1 rounded-xl bg-rose-500 hover:bg-rose-600 active:scale-95 py-2 text-xs font-bold text-white shadow-xs disabled:opacity-40 disabled:cursor-not-allowed transition-all cursor-pointer text-center" |
|||
> |
|||
ذخیره و ورود به حساب |
|||
</button> |
|||
<button |
|||
type="button" |
|||
disabled={!customTokenInput.trim()} |
|||
onClick={() => handleSelectToken(customTokenInput)} |
|||
className="rounded-xl bg-rose-500 hover:bg-rose-600 active:scale-95 px-4 py-2 text-xs font-bold text-white shadow-xs disabled:opacity-40 disabled:cursor-not-allowed transition-all shrink-0 cursor-pointer" |
|||
onClick={handleSaveAccountOnly} |
|||
className="rounded-xl border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 active:scale-95 px-3 py-2 text-xs font-medium text-slate-700 dark:text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed transition-all cursor-pointer shrink-0" |
|||
> |
|||
اعمال |
|||
فقط ذخیره در لیست |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
|
|||
{/* Modal Footer */} |
|||
|
|||
@ -0,0 +1,2 @@ |
|||
export * from "./input"; |
|||
export * from "./textarea"; |
|||
@ -0,0 +1,37 @@ |
|||
import * as React from "react"; |
|||
import { cn } from "@/lib/utils"; |
|||
|
|||
export interface InputProps extends React.ComponentProps<"input"> { |
|||
hasError?: boolean; |
|||
} |
|||
|
|||
const Input = React.forwardRef<HTMLInputElement, InputProps>( |
|||
({ className, type = "text", hasError, ...props }, ref) => { |
|||
return ( |
|||
<input |
|||
ref={ref} |
|||
type={type} |
|||
data-snap-drag-ignore="true" |
|||
autoComplete="off" |
|||
autoCorrect="off" |
|||
autoCapitalize="none" |
|||
spellCheck="false" |
|||
aria-autocomplete="none" |
|||
data-lpignore="true" |
|||
data-1p-ignore="true" |
|||
data-bwignore="true" |
|||
data-form-type="other" |
|||
className={cn( |
|||
"h-[54px] w-full rounded-[16px] border border-[#D0D5DD] bg-white px-4.5 text-[15px] font-medium text-[#181818] outline-none transition-all placeholder:text-[#98A2B3] hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F] disabled:cursor-not-allowed disabled:bg-[#F5F2F1] disabled:text-[#7C7472]", |
|||
hasError && "border-[#F2465F] ring-1 ring-[#F2465F] focus:border-[#F2465F] focus:ring-[#F2465F]", |
|||
className, |
|||
)} |
|||
{...props} |
|||
/> |
|||
); |
|||
}, |
|||
); |
|||
|
|||
Input.displayName = "Input"; |
|||
|
|||
export { Input }; |
|||
@ -0,0 +1,36 @@ |
|||
import * as React from "react"; |
|||
import { cn } from "@/lib/utils"; |
|||
|
|||
export interface TextareaProps extends React.ComponentProps<"textarea"> { |
|||
hasError?: boolean; |
|||
} |
|||
|
|||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( |
|||
({ className, hasError, ...props }, ref) => { |
|||
return ( |
|||
<textarea |
|||
ref={ref} |
|||
data-snap-drag-ignore="true" |
|||
autoComplete="off" |
|||
autoCorrect="off" |
|||
autoCapitalize="none" |
|||
spellCheck="false" |
|||
aria-autocomplete="none" |
|||
data-lpignore="true" |
|||
data-1p-ignore="true" |
|||
data-bwignore="true" |
|||
data-form-type="other" |
|||
className={cn( |
|||
"w-full rounded-[16px] border border-[#D0D5DD] bg-white px-4.5 py-4 text-[15px] font-medium text-[#181818] outline-none transition-all placeholder:text-[#98A2B3] hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F] disabled:cursor-not-allowed disabled:bg-[#F5F2F1] disabled:text-[#7C7472] resize-none", |
|||
hasError && "border-[#F2465F] ring-1 ring-[#F2465F] focus:border-[#F2465F] focus:ring-[#F2465F]", |
|||
className, |
|||
)} |
|||
{...props} |
|||
/> |
|||
); |
|||
}, |
|||
); |
|||
|
|||
Textarea.displayName = "Textarea"; |
|||
|
|||
export { Textarea }; |
|||
@ -0,0 +1,27 @@ |
|||
"use client"; |
|||
|
|||
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; |
|||
import { http } from "@/lib/http"; |
|||
|
|||
export type HabcoinInventory = { |
|||
coin_balance: number; |
|||
}; |
|||
|
|||
export async function getHabcoinInventory(): Promise<HabcoinInventory> { |
|||
const { data } = await http.get<HabcoinInventory>("/habcoin/inventory/"); |
|||
return data; |
|||
} |
|||
|
|||
export function useHabcoinInventoryQuery( |
|||
options?: Omit< |
|||
UseQueryOptions<HabcoinInventory, Error>, |
|||
"queryKey" | "queryFn" |
|||
>, |
|||
) { |
|||
return useQuery({ |
|||
queryKey: ["habcoin", "inventory"], |
|||
queryFn: getHabcoinInventory, |
|||
staleTime: 15 * 1000, |
|||
...options, |
|||
}); |
|||
} |
|||
@ -0,0 +1,78 @@ |
|||
"use client"; |
|||
|
|||
import { useMutation } from "@tanstack/react-query"; |
|||
import { http } from "@/lib/http"; |
|||
import type { MutationOptions } from "./options"; |
|||
|
|||
const HABCOIN_SERVICE = "marriagesubscriptionplan" as const; |
|||
|
|||
export interface CheckDiscountResult { |
|||
valid: boolean; |
|||
code: string; |
|||
originalPrice: number; |
|||
discountedPrice: number; |
|||
discountAmount: number; |
|||
discountPercentage: number; |
|||
message?: string; |
|||
} |
|||
|
|||
export interface ValidateDiscountParams { |
|||
code: string; |
|||
objectId: number | string; |
|||
service?: string; |
|||
} |
|||
|
|||
export async function validateDiscountCode({ |
|||
code, |
|||
objectId, |
|||
service = HABCOIN_SERVICE, |
|||
}: ValidateDiscountParams): Promise<CheckDiscountResult> { |
|||
try { |
|||
const { data } = await http.post<any>("/habcoin/validate-discount/", { |
|||
code, |
|||
service, |
|||
app_name: "marriage", |
|||
object_id: objectId, |
|||
}); |
|||
|
|||
return { |
|||
valid: Boolean(data?.valid ?? true), |
|||
code, |
|||
originalPrice: Number(data?.original_price ?? 0), |
|||
discountedPrice: Number(data?.discounted_price ?? 0), |
|||
discountAmount: Number(data?.discount_amount ?? 0), |
|||
discountPercentage: Number(data?.discount_percentage ?? 0), |
|||
message: data?.message || undefined, |
|||
}; |
|||
} catch (err: any) { |
|||
const responseData = err?.response?.data; |
|||
const errors = responseData?.errors; |
|||
const message = |
|||
errors?.code?.[0] || |
|||
errors?.service?.[0] || |
|||
errors?.object_id?.[0] || |
|||
responseData?.detail || |
|||
responseData?.message || |
|||
err?.message || |
|||
"Invalid discount code"; |
|||
|
|||
return { |
|||
valid: false, |
|||
code, |
|||
originalPrice: 0, |
|||
discountedPrice: 0, |
|||
discountAmount: 0, |
|||
discountPercentage: 0, |
|||
message, |
|||
}; |
|||
} |
|||
} |
|||
|
|||
export function useValidateDiscountMutation( |
|||
options?: MutationOptions<CheckDiscountResult, ValidateDiscountParams>, |
|||
) { |
|||
return useMutation({ |
|||
...options, |
|||
mutationFn: validateDiscountCode, |
|||
}); |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
"use client"; |
|||
|
|||
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
|||
|
|||
/** |
|||
* Returns the current authenticated user's profile ID. |
|||
* Returns null when profile hasn't loaded yet or user is unauthenticated. |
|||
*/ |
|||
export function useCurrentProfileId(): number | null { |
|||
const { data: profile } = useMarriageProfileQuery(); |
|||
return profile?.id ?? null; |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
"use client"; |
|||
|
|||
import { useEffect } from "react"; |
|||
|
|||
/** |
|||
* Shared destination readiness hook. |
|||
* |
|||
* Call this from final destination client components to signal that the page UI |
|||
* is ready and Flutter can remove its native loading cover. |
|||
* |
|||
* This hook only **requests** readiness — it does not directly interact with |
|||
* the Flutter bridge. The root bootstrap script in layout.tsx owns actual |
|||
* delivery and handles the case where HabibApp is injected late. |
|||
* |
|||
* @param ready - Whether the page considers itself visually ready. |
|||
* Pass `true` once critical data is available or the fallback |
|||
* UI is showing. The signal fires at most once per mount. |
|||
*/ |
|||
export function useHabibWebReady(ready: boolean) { |
|||
useEffect(() => { |
|||
if (ready && typeof window !== "undefined") { |
|||
window.__announceHabibWebReady?.(); |
|||
} |
|||
}, [ready]); |
|||
} |
|||
@ -0,0 +1,84 @@ |
|||
"use client"; |
|||
|
|||
import { useEffect, useRef } from "react"; |
|||
|
|||
/** |
|||
* Global stack of hardware-back handlers. |
|||
* |
|||
* When Flutter sends a hardware back event, the root handler in layout.tsx |
|||
* pops the last handler from this stack and calls it. If the handler returns |
|||
* true, it means "I handled it — keep the WebView open". If it returns false |
|||
* (or the stack is empty), Flutter should close the WebView screen. |
|||
* |
|||
* Pages register themselves with useHardwareBackHandler(). Sheets and modals |
|||
* also register — the last one wins, matching the visual stacking order. |
|||
*/ |
|||
const backHandlerStack: Array<() => boolean | Promise<boolean>> = []; |
|||
|
|||
/** |
|||
* Register a hardware-back handler. When the user presses the hardware back |
|||
* button (Android), Flutter calls window.__habibHandleHardwareBack(). The |
|||
* root handler walks the stack from top to bottom and calls the first handler. |
|||
* |
|||
* @param handler - Return true if you handled the back (e.g. closed a sheet, |
|||
* flushed answers and navigated). Return false if you didn't handle it |
|||
* (Flutter should close the WebView). |
|||
* @param enabled - When false, the handler is not registered. Useful for |
|||
* conditionally enabling back handling. |
|||
* |
|||
* @example |
|||
* // In questions-list (root): back = close service
|
|||
* useHardwareBackHandler(() => false); |
|||
* |
|||
* // In question-detail: back = flush + navigate
|
|||
* useHardwareBackHandler(async () => { |
|||
* await flushAnswers({ force: true }); |
|||
* router.replace(questionsListHref); |
|||
* return true; |
|||
* }); |
|||
*/ |
|||
export function useHardwareBackHandler( |
|||
handler: () => boolean | Promise<boolean>, |
|||
enabled = true, |
|||
) { |
|||
const handlerRef = useRef(handler); |
|||
handlerRef.current = handler; |
|||
|
|||
useEffect(() => { |
|||
if (!enabled) return; |
|||
|
|||
// Wrap in a stable closure so we can remove the exact reference.
|
|||
const stableHandler = () => handlerRef.current(); |
|||
backHandlerStack.push(stableHandler); |
|||
|
|||
return () => { |
|||
const index = backHandlerStack.lastIndexOf(stableHandler); |
|||
if (index !== -1) { |
|||
backHandlerStack.splice(index, 1); |
|||
} |
|||
}; |
|||
}, [enabled]); |
|||
} |
|||
|
|||
/** |
|||
* Called by the root bootstrap script when Flutter sends a hardware back event. |
|||
* Returns { handled: true } if a web handler consumed the event, or |
|||
* { handled: false } if Flutter should close the WebView screen. |
|||
*/ |
|||
export async function handleHardwareBack(): Promise<{ handled: boolean }> { |
|||
if (backHandlerStack.length === 0) { |
|||
return { handled: false }; |
|||
} |
|||
|
|||
// Pop the topmost handler (last registered = topmost in visual stack).
|
|||
const handler = backHandlerStack[backHandlerStack.length - 1]; |
|||
try { |
|||
const result = await handler(); |
|||
return { handled: result }; |
|||
} catch (error) { |
|||
console.warn("[HardwareBack] Handler threw:", error); |
|||
return { handled: false }; |
|||
} |
|||
} |
|||
|
|||
export default useHardwareBackHandler; |
|||
@ -0,0 +1,89 @@ |
|||
// Server-only module: imported only by Server Components (page.tsx wrappers).
|
|||
|
|||
/** |
|||
* Helper to get the API base URL for server-side fetches. |
|||
* It reads from NEXT_PUBLIC_API_BASE_URL. |
|||
*/ |
|||
function getApiBaseUrl(): string { |
|||
return process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:8001"; |
|||
} |
|||
|
|||
/** |
|||
* Fetches the marriage profile server-side. |
|||
* Runs only on the server, typically inside a Next.js Server Component. |
|||
* Returns null if the request fails, allowing the client-side React Query to handle retries. |
|||
* |
|||
* @param token - The user authentication token |
|||
* @returns Profile data or null on failure |
|||
*/ |
|||
export async function fetchProfileSSR( |
|||
token?: string | null, |
|||
timeoutMs = 1500, |
|||
): Promise<any | null> { |
|||
if (!token || token === "NO_TOKEN" || token.trim() === "") return null; |
|||
|
|||
const controller = new AbortController(); |
|||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); |
|||
|
|||
try { |
|||
const baseUrl = getApiBaseUrl(); |
|||
const url = `${baseUrl}/api/marriage/profile/main/`; |
|||
|
|||
const response = await fetch(url, { |
|||
method: "GET", |
|||
cache: "no-store", |
|||
signal: controller.signal, |
|||
headers: { |
|||
Accept: "application/json", |
|||
Authorization: `Token ${token.trim()}`, |
|||
}, |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
return null; |
|||
} |
|||
|
|||
return await response.json(); |
|||
} catch (error) { |
|||
return null; |
|||
} finally { |
|||
clearTimeout(timeoutId); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Fetches the marriage config server-side. |
|||
* Returns null on failure so the client can fall back to its own fetch or |
|||
* local fallback images. |
|||
*/ |
|||
export async function fetchConfigSSR( |
|||
timeoutMs = 1500, |
|||
): Promise<any | null> { |
|||
const controller = new AbortController(); |
|||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); |
|||
|
|||
try { |
|||
const baseUrl = getApiBaseUrl(); |
|||
const url = `${baseUrl}/api/marriage/config/`; |
|||
|
|||
const response = await fetch(url, { |
|||
method: "GET", |
|||
cache: "no-store", |
|||
signal: controller.signal, |
|||
headers: { |
|||
Accept: "application/json", |
|||
}, |
|||
}); |
|||
|
|||
if (!response.ok) { |
|||
return null; |
|||
} |
|||
|
|||
return await response.json(); |
|||
} catch (error) { |
|||
return null; |
|||
} finally { |
|||
clearTimeout(timeoutId); |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,163 @@ |
|||
import { describe, expect, it, beforeEach } from "vitest"; |
|||
import { |
|||
SCOPED_STORAGE_VERSION, |
|||
getScopedSectionDraftKey, |
|||
getScopedAssessmentDraftKey, |
|||
readScopedSectionDraft, |
|||
readScopedAssessmentDraft, |
|||
writeScopedSectionDraft, |
|||
writeScopedAssessmentDraft, |
|||
removeScopedSectionDraft, |
|||
removeScopedAssessmentDraft, |
|||
migrateLegacyStorageKeys, |
|||
isLegacyMigrationDone, |
|||
isDraftOwnedBy, |
|||
getAllScopedSectionDraftKeys, |
|||
extractSlugFromScopedKey, |
|||
} from "./user-scoped-storage"; |
|||
|
|||
describe("user-scoped-storage", () => { |
|||
beforeEach(() => { |
|||
window.localStorage.clear(); |
|||
}); |
|||
|
|||
describe("Key generators", () => { |
|||
it("generates profile-scoped section draft keys with version", () => { |
|||
const key = getScopedSectionDraftKey(42, "personal_info"); |
|||
expect(key).toBe(`marriage:user:42:sections:personal_info:draft:v${SCOPED_STORAGE_VERSION}`); |
|||
}); |
|||
|
|||
it("generates profile-scoped assessment draft keys with version", () => { |
|||
const key = getScopedAssessmentDraftKey(42, "personality_test"); |
|||
expect(key).toBe(`marriage:user:42:tests:personality_test:draft:v${SCOPED_STORAGE_VERSION}`); |
|||
}); |
|||
|
|||
it("extracts slug correctly from scoped key", () => { |
|||
const key = getScopedSectionDraftKey(101, "family_background"); |
|||
expect(extractSlugFromScopedKey(key)).toBe("family_background"); |
|||
}); |
|||
}); |
|||
|
|||
describe("Section draft isolation across accounts", () => { |
|||
it("isolates Account A edits from Account B", () => { |
|||
const accountAProfileId = 1001; |
|||
const accountBProfileId = 2002; |
|||
|
|||
// Account A writes a draft
|
|||
writeScopedSectionDraft(accountAProfileId, "job_info", { |
|||
job_title: { |
|||
key: "job_title", |
|||
label: "Job Title", |
|||
type: "text", |
|||
value: "Engineer", |
|||
}, |
|||
}); |
|||
|
|||
// Account A can read it
|
|||
const draftA = readScopedSectionDraft(accountAProfileId, "job_info"); |
|||
expect(draftA).not.toBeNull(); |
|||
expect(draftA?.pending.job_title.value).toBe("Engineer"); |
|||
expect(draftA?.ownerProfileId).toBe(accountAProfileId); |
|||
|
|||
// Account B CANNOT read Account A's draft
|
|||
const draftB = readScopedSectionDraft(accountBProfileId, "job_info"); |
|||
expect(draftB).toBeNull(); |
|||
}); |
|||
|
|||
it("removes draft when pending is empty", () => { |
|||
const profileId = 123; |
|||
writeScopedSectionDraft(profileId, "bio", { |
|||
about: { key: "about", label: "About", type: "text", value: "Hello" }, |
|||
}); |
|||
expect(readScopedSectionDraft(profileId, "bio")).not.toBeNull(); |
|||
|
|||
// Write empty pending map -> draft is removed
|
|||
writeScopedSectionDraft(profileId, "bio", {}); |
|||
expect(readScopedSectionDraft(profileId, "bio")).toBeNull(); |
|||
}); |
|||
|
|||
it("removes draft explicitly via removeScopedSectionDraft", () => { |
|||
const profileId = 123; |
|||
writeScopedSectionDraft(profileId, "bio", { |
|||
about: { key: "about", label: "About", type: "text", value: "Hello" }, |
|||
}); |
|||
removeScopedSectionDraft(profileId, "bio"); |
|||
expect(readScopedSectionDraft(profileId, "bio")).toBeNull(); |
|||
}); |
|||
|
|||
it("lists all scoped draft keys for a given profile", () => { |
|||
writeScopedSectionDraft(1, "sec_1", { q: { key: "q", label: "Q", type: "text", value: "A" } }); |
|||
writeScopedSectionDraft(1, "sec_2", { q: { key: "q", label: "Q", type: "text", value: "B" } }); |
|||
writeScopedSectionDraft(2, "sec_3", { q: { key: "q", label: "Q", type: "text", value: "C" } }); |
|||
|
|||
const keys1 = getAllScopedSectionDraftKeys(1); |
|||
expect(keys1).toHaveLength(2); |
|||
expect(keys1.map(extractSlugFromScopedKey)).toEqual(expect.arrayContaining(["sec_1", "sec_2"])); |
|||
|
|||
const keys2 = getAllScopedSectionDraftKeys(2); |
|||
expect(keys2).toHaveLength(1); |
|||
expect(extractSlugFromScopedKey(keys2[0])).toBe("sec_3"); |
|||
}); |
|||
}); |
|||
|
|||
describe("Assessment draft isolation across accounts", () => { |
|||
it("isolates assessment answers across accounts", () => { |
|||
const userA = 501; |
|||
const userB = 502; |
|||
|
|||
writeScopedAssessmentDraft(userA, "personality_test", { 1: "A", 2: "B" }, 2, 10); |
|||
|
|||
const draftA = readScopedAssessmentDraft(userA, "personality_test"); |
|||
expect(draftA).not.toBeNull(); |
|||
expect(draftA?.answers).toEqual({ 1: "A", 2: "B" }); |
|||
expect(draftA?.currentIndex).toBe(2); |
|||
expect(draftA?.totalQuestions).toBe(10); |
|||
|
|||
const draftB = readScopedAssessmentDraft(userB, "personality_test"); |
|||
expect(draftB).toBeNull(); |
|||
}); |
|||
|
|||
it("removes assessment draft explicitly", () => { |
|||
writeScopedAssessmentDraft(100, "glasser_5_needs_test", { 1: 5 }, 1, 20); |
|||
removeScopedAssessmentDraft(100, "glasser_5_needs_test"); |
|||
expect(readScopedAssessmentDraft(100, "glasser_5_needs_test")).toBeNull(); |
|||
}); |
|||
}); |
|||
|
|||
describe("Legacy migration", () => { |
|||
it("removes old unscoped keys while preserving unrelated localStorage", () => { |
|||
// Setup old keys
|
|||
window.localStorage.setItem("marriage:sections:about:answers:v2", JSON.stringify({ fields: [] })); |
|||
window.localStorage.setItem("marriage:tests:personality_test:draft", JSON.stringify({ answers: { 1: "A" } })); |
|||
window.localStorage.setItem("marriage:sections:glasser_5_needs_test:answers", JSON.stringify({ completed: true })); |
|||
|
|||
// Setup unrelated keys that MUST NOT be touched
|
|||
window.localStorage.setItem("marriage:device_id", "device-12345"); |
|||
window.localStorage.setItem("theme_preference", "dark"); |
|||
|
|||
expect(isLegacyMigrationDone()).toBe(false); |
|||
|
|||
migrateLegacyStorageKeys(); |
|||
|
|||
expect(isLegacyMigrationDone()).toBe(true); |
|||
|
|||
// Old keys should be gone
|
|||
expect(window.localStorage.getItem("marriage:sections:about:answers:v2")).toBeNull(); |
|||
expect(window.localStorage.getItem("marriage:tests:personality_test:draft")).toBeNull(); |
|||
expect(window.localStorage.getItem("marriage:sections:glasser_5_needs_test:answers")).toBeNull(); |
|||
|
|||
// Unrelated keys must be preserved
|
|||
expect(window.localStorage.getItem("marriage:device_id")).toBe("device-12345"); |
|||
expect(window.localStorage.getItem("theme_preference")).toBe("dark"); |
|||
}); |
|||
}); |
|||
|
|||
describe("Ownership verification helper", () => { |
|||
it("verifies draft ownership correctly", () => { |
|||
expect(isDraftOwnedBy({ ownerProfileId: 99 }, 99)).toBe(true); |
|||
expect(isDraftOwnedBy({ ownerProfileId: 99 }, 100)).toBe(false); |
|||
expect(isDraftOwnedBy(null, 99)).toBe(false); |
|||
expect(isDraftOwnedBy(undefined, 99)).toBe(false); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,285 @@ |
|||
/** |
|||
* User-scoped localStorage utilities. |
|||
* |
|||
* All draft/answer data is keyed by the authenticated user's profile ID |
|||
* so that Account A's unsynced edits can never be displayed or synced |
|||
* for Account B. |
|||
* |
|||
* The raw auth token is NEVER used as a key component. |
|||
*/ |
|||
|
|||
export const SCOPED_STORAGE_VERSION = 3; |
|||
|
|||
// ─── Types ───────────────────────────────────────────────────────────
|
|||
|
|||
export interface ScopedSectionDraft { |
|||
version: typeof SCOPED_STORAGE_VERSION; |
|||
ownerProfileId: number; |
|||
slug: string; |
|||
pending: Record<string, ScopedPendingField>; |
|||
updatedAt: string; |
|||
} |
|||
|
|||
export interface ScopedPendingField { |
|||
key: string; |
|||
label: string; |
|||
type: string; |
|||
value: unknown; |
|||
option_id?: string | string[] | null; |
|||
private?: boolean; |
|||
} |
|||
|
|||
export interface ScopedAssessmentDraft { |
|||
version: typeof SCOPED_STORAGE_VERSION; |
|||
ownerProfileId: number; |
|||
slug: string; |
|||
answers: Record<number, string | number>; |
|||
currentIndex: number; |
|||
totalQuestions: number; |
|||
} |
|||
|
|||
// ─── Key Generators ──────────────────────────────────────────────────
|
|||
|
|||
export function getScopedSectionDraftKey( |
|||
profileId: number, |
|||
slug: string, |
|||
): string { |
|||
return `marriage:user:${profileId}:sections:${slug}:draft:v${SCOPED_STORAGE_VERSION}`; |
|||
} |
|||
|
|||
export function getScopedAssessmentDraftKey( |
|||
profileId: number, |
|||
slug: string, |
|||
): string { |
|||
return `marriage:user:${profileId}:tests:${slug}:draft:v${SCOPED_STORAGE_VERSION}`; |
|||
} |
|||
|
|||
// ─── Ownership Verification ──────────────────────────────────────────
|
|||
|
|||
export function isDraftOwnedBy( |
|||
draft: { ownerProfileId?: number } | null | undefined, |
|||
profileId: number, |
|||
): boolean { |
|||
if (!draft || typeof draft !== "object") return false; |
|||
return draft.ownerProfileId === profileId; |
|||
} |
|||
|
|||
// ─── Reading ─────────────────────────────────────────────────────────
|
|||
|
|||
export function readScopedSectionDraft( |
|||
profileId: number, |
|||
slug: string, |
|||
): ScopedSectionDraft | null { |
|||
try { |
|||
const key = getScopedSectionDraftKey(profileId, slug); |
|||
const raw = window.localStorage.getItem(key); |
|||
if (!raw) return null; |
|||
|
|||
const parsed = JSON.parse(raw); |
|||
if ( |
|||
!parsed || |
|||
parsed.version !== SCOPED_STORAGE_VERSION || |
|||
parsed.ownerProfileId !== profileId || |
|||
parsed.slug !== slug |
|||
) { |
|||
// Corrupted or mismatched — remove it
|
|||
window.localStorage.removeItem(key); |
|||
return null; |
|||
} |
|||
|
|||
return parsed as ScopedSectionDraft; |
|||
} catch { |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
export function readScopedAssessmentDraft( |
|||
profileId: number, |
|||
slug: string, |
|||
): ScopedAssessmentDraft | null { |
|||
try { |
|||
const key = getScopedAssessmentDraftKey(profileId, slug); |
|||
const raw = window.localStorage.getItem(key); |
|||
if (!raw) return null; |
|||
|
|||
const parsed = JSON.parse(raw); |
|||
if ( |
|||
!parsed || |
|||
parsed.version !== SCOPED_STORAGE_VERSION || |
|||
parsed.ownerProfileId !== profileId |
|||
) { |
|||
window.localStorage.removeItem(key); |
|||
return null; |
|||
} |
|||
|
|||
return parsed as ScopedAssessmentDraft; |
|||
} catch { |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
// ─── Writing ─────────────────────────────────────────────────────────
|
|||
|
|||
export function writeScopedSectionDraft( |
|||
profileId: number, |
|||
slug: string, |
|||
pending: Record<string, ScopedPendingField>, |
|||
): void { |
|||
try { |
|||
const key = getScopedSectionDraftKey(profileId, slug); |
|||
|
|||
if (Object.keys(pending).length === 0) { |
|||
window.localStorage.removeItem(key); |
|||
return; |
|||
} |
|||
|
|||
const draft: ScopedSectionDraft = { |
|||
version: SCOPED_STORAGE_VERSION, |
|||
ownerProfileId: profileId, |
|||
slug, |
|||
pending, |
|||
updatedAt: new Date().toISOString(), |
|||
}; |
|||
|
|||
window.localStorage.setItem(key, JSON.stringify(draft)); |
|||
} catch { |
|||
// localStorage can fail in private mode or when storage quota is exhausted.
|
|||
} |
|||
} |
|||
|
|||
export function writeScopedAssessmentDraft( |
|||
profileId: number, |
|||
slug: string, |
|||
answers: Record<number, string | number>, |
|||
currentIndex: number, |
|||
totalQuestions: number, |
|||
): void { |
|||
try { |
|||
const key = getScopedAssessmentDraftKey(profileId, slug); |
|||
|
|||
if (Object.keys(answers).length === 0) { |
|||
window.localStorage.removeItem(key); |
|||
return; |
|||
} |
|||
|
|||
const draft: ScopedAssessmentDraft = { |
|||
version: SCOPED_STORAGE_VERSION, |
|||
ownerProfileId: profileId, |
|||
slug, |
|||
answers, |
|||
currentIndex, |
|||
totalQuestions, |
|||
}; |
|||
|
|||
window.localStorage.setItem(key, JSON.stringify(draft)); |
|||
} catch { |
|||
// localStorage can fail.
|
|||
} |
|||
} |
|||
|
|||
export function removeScopedSectionDraft( |
|||
profileId: number, |
|||
slug: string, |
|||
): void { |
|||
try { |
|||
window.localStorage.removeItem( |
|||
getScopedSectionDraftKey(profileId, slug), |
|||
); |
|||
} catch {} |
|||
} |
|||
|
|||
export function removeScopedAssessmentDraft( |
|||
profileId: number, |
|||
slug: string, |
|||
): void { |
|||
try { |
|||
window.localStorage.removeItem( |
|||
getScopedAssessmentDraftKey(profileId, slug), |
|||
); |
|||
} catch {} |
|||
} |
|||
|
|||
// ─── Legacy Migration ────────────────────────────────────────────────
|
|||
|
|||
/** |
|||
* Remove old V2 unscoped answer keys. Since they have no owner metadata, |
|||
* ownership cannot be proven, so we do NOT migrate them. |
|||
* We also do NOT call localStorage.clear() because the application stores |
|||
* unrelated device/UI flags. |
|||
*/ |
|||
export function migrateLegacyStorageKeys(): void { |
|||
try { |
|||
const keysToRemove: string[] = []; |
|||
|
|||
for (let i = 0; i < window.localStorage.length; i++) { |
|||
const key = window.localStorage.key(i); |
|||
if (!key) continue; |
|||
|
|||
// Old V2 normal section keys: marriage:sections:{slug}:answers:v2
|
|||
if (key.match(/^marriage:sections:[^:]+:answers:v2$/)) { |
|||
keysToRemove.push(key); |
|||
} |
|||
|
|||
// Old unscoped assessment draft keys: marriage:tests:{slug}:draft
|
|||
if (key.match(/^marriage:tests:[^:]+:draft$/) && !key.includes(":user:")) { |
|||
keysToRemove.push(key); |
|||
} |
|||
|
|||
// Old unscoped completion markers: marriage:sections:{slug}:answers
|
|||
// (used by assessments to store {completed: true})
|
|||
if (key.match(/^marriage:sections:[^:]+:answers$/) && !key.includes(":user:")) { |
|||
keysToRemove.push(key); |
|||
} |
|||
} |
|||
|
|||
for (const key of keysToRemove) { |
|||
window.localStorage.removeItem(key); |
|||
} |
|||
|
|||
// Mark migration as done so we don't re-scan every page load
|
|||
window.localStorage.setItem("marriage:storage-migration:v3", "done"); |
|||
} catch { |
|||
// Non-critical; will retry next page load.
|
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Check if the V3 migration has already been performed. |
|||
*/ |
|||
export function isLegacyMigrationDone(): boolean { |
|||
try { |
|||
return window.localStorage.getItem("marriage:storage-migration:v3") === "done"; |
|||
} catch { |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Enumerate all scoped section draft keys for a given profile. |
|||
*/ |
|||
export function getAllScopedSectionDraftKeys( |
|||
profileId: number, |
|||
): string[] { |
|||
const prefix = `marriage:user:${profileId}:sections:`; |
|||
const suffix = `:draft:v${SCOPED_STORAGE_VERSION}`; |
|||
const keys: string[] = []; |
|||
|
|||
try { |
|||
for (let i = 0; i < window.localStorage.length; i++) { |
|||
const key = window.localStorage.key(i); |
|||
if (key && key.startsWith(prefix) && key.endsWith(suffix)) { |
|||
keys.push(key); |
|||
} |
|||
} |
|||
} catch {} |
|||
|
|||
return keys; |
|||
} |
|||
|
|||
/** |
|||
* Extract the slug from a scoped section draft key. |
|||
*/ |
|||
export function extractSlugFromScopedKey(key: string): string | null { |
|||
const match = key.match(/^marriage:user:\d+:sections:([^:]+):draft:v\d+$/); |
|||
return match ? match[1] : null; |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Write
Preview
Loading…
Cancel
Save
Reference in new issue