Browse Source

Merge branch 'master' into Dev

Dev
parent
commit
d50abdccc2
  1. 87
      .zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md
  2. BIN
      public/fonts/Amiri/Amiri-Bold.ttf
  3. BIN
      public/fonts/Amiri/Amiri-BoldItalic.ttf
  4. BIN
      public/fonts/Amiri/Amiri-Italic.ttf
  5. BIN
      public/fonts/Amiri/Amiri-Regular.ttf
  6. 93
      public/fonts/Amiri/OFL.txt
  7. 44
      src/app/[lang]/page.tsx
  8. 1
      src/app/[lang]/questions-list/[slug]/loading.tsx
  9. 411
      src/app/candidate-contact/candidate-contact-client.tsx
  10. 405
      src/app/candidate-contact/page.tsx
  11. 191
      src/app/finding-match/finding-match-client.tsx
  12. 205
      src/app/finding-match/page.tsx
  13. 94
      src/app/globals.css
  14. 197
      src/app/intro/intro-client.tsx
  15. 222
      src/app/intro/page.tsx
  16. 104
      src/app/layout.tsx
  17. 13
      src/app/marriage-advisors/page.tsx
  18. 916
      src/app/new-match/new-match-client.tsx
  19. 689
      src/app/new-match/page.tsx
  20. 162
      src/app/new-match/profile/page.tsx
  21. 30
      src/app/page.tsx
  22. 11
      src/app/providers.tsx
  23. 21
      src/app/questions-list/[slug]/loading.tsx
  24. 109
      src/app/questions-list/[slug]/question-detail-client.tsx
  25. 818
      src/app/questions-list/page.tsx
  26. 899
      src/app/questions-list/questions-list-client.tsx
  27. 87
      src/app/questions-list/section-prefetch-race.test.ts
  28. 774
      src/app/request-accepted/page.tsx
  29. 773
      src/app/request-accepted/request-accepted-client.tsx
  30. 165
      src/app/request-sent/page.tsx
  31. 165
      src/app/request-sent/request-sent-client.tsx
  32. 5
      src/app/terms/page.tsx
  33. 7
      src/components/Componentes/advisor-actions-card.tsx
  34. 77
      src/components/Componentes/auth-data-boundary.test.tsx
  35. 99
      src/components/Componentes/auth-data-boundary.tsx
  36. 25
      src/components/Componentes/data-error-state.tsx
  37. 98
      src/components/Componentes/dev-tap-instrumentation.tsx
  38. 218
      src/components/Componentes/discount-widget.tsx
  39. 11
      src/components/Componentes/entry-route-resolver.tsx
  40. 28
      src/components/Componentes/hardware-back-bridge.tsx
  41. 31
      src/components/Componentes/information-sheet.tsx
  42. 134
      src/components/Componentes/marriage-advisors-overlay.test.tsx
  43. 77
      src/components/Componentes/marriage-advisors-overlay.tsx
  44. 140
      src/components/Componentes/match-profile-overlay.test.tsx
  45. 77
      src/components/Componentes/match-profile-overlay.tsx
  46. 260
      src/components/Componentes/navigation-button.tsx
  47. 76
      src/components/Componentes/page-header.tsx
  48. 232
      src/components/Componentes/question-answer-storage.tsx
  49. 4
      src/components/Componentes/question-birthplace.tsx
  50. 11
      src/components/Componentes/question-card.tsx
  51. 35
      src/components/Componentes/question-date-sheet.tsx
  52. 15
      src/components/Componentes/question-date.test.tsx
  53. 66
      src/components/Componentes/question-date.tsx
  54. 10
      src/components/Componentes/question-dropdown.tsx
  55. 10
      src/components/Componentes/question-exit-navigation-button.tsx
  56. 15
      src/components/Componentes/question-number.tsx
  57. 161
      src/components/Componentes/question-phone.tsx
  58. 19
      src/components/Componentes/question-section-flow.tsx
  59. 10
      src/components/Componentes/question-sheet.tsx
  60. 260
      src/components/Componentes/question-snap-list.test.tsx
  61. 357
      src/components/Componentes/question-snap-list.tsx
  62. 57
      src/components/Componentes/question-text.tsx
  63. 12
      src/components/Componentes/question-textarea.tsx
  64. 116
      src/components/Componentes/section-overlay-host.test.tsx
  65. 148
      src/components/Componentes/section-overlay-host.tsx
  66. 1
      src/components/Componentes/slider-page.test.tsx
  67. 4
      src/components/Componentes/sticky-header.tsx
  68. 198
      src/components/Componentes/subscription-required-sheet.tsx
  69. 19
      src/components/Componentes/support-access.ts
  70. 22
      src/components/Componentes/test-questions-flow.tsx
  71. 806
      src/components/Componentes/token-switcher.tsx
  72. 15
      src/components/Componentes/use-sheet-scroll-lock.ts
  73. 2
      src/components/ui/index.ts
  74. 37
      src/components/ui/input.tsx
  75. 36
      src/components/ui/textarea.tsx
  76. 1
      src/hooks/marriage/query-keys.ts
  77. 27
      src/hooks/marriage/use-habcoin-inventory.ts
  78. 33
      src/hooks/marriage/use-habcoin-payment.ts
  79. 3
      src/hooks/marriage/use-marriage-config.ts
  80. 36
      src/hooks/marriage/use-profile-main.ts
  81. 78
      src/hooks/marriage/use-validate-discount.ts
  82. 12
      src/hooks/use-current-profile-id.ts
  83. 25
      src/hooks/use-habib-web-ready.ts
  84. 84
      src/hooks/use-hardware-back-handler.ts
  85. 51
      src/hooks/useFlutterBridge.ts
  86. 89
      src/lib/ssr-fetch.ts
  87. 163
      src/lib/user-scoped-storage.test.ts
  88. 285
      src/lib/user-scoped-storage.ts
  89. 26
      src/lib/webview-actions.test.ts
  90. 14
      src/lib/webview-actions.ts
  91. 20
      src/translations/locales/ar.json
  92. 20
      src/translations/locales/az.json
  93. 20
      src/translations/locales/bn.json
  94. 20
      src/translations/locales/da.json
  95. 20
      src/translations/locales/de.json
  96. 19
      src/translations/locales/en.json
  97. 20
      src/translations/locales/es.json
  98. 19
      src/translations/locales/fa.json
  99. 20
      src/translations/locales/fr.json
  100. 20
      src/translations/locales/gu.json

87
.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md

@ -0,0 +1,87 @@
# انیمیشن اسلاید صفحه جزئیات سکشن روی لیست سکشن‌ها (مطابق حسینیه‌اپ)
## خلاصه تحقیق
**حسینیه‌اپ** (مرجع): پنل مداح URL-driven است (`?panel=provider:id`) و صفحه لیست هرگز unmount نمی‌شود؛ پنل با CSS خالص (بدون کتابخانه) با `translateX(±100%) → 0` و زمان‌بندی **0.28s / cubic-bezier(0.32, 0.72, 0, 1)** اسلاید می‌شود. جهت با `side` فیزیکی انتخاب می‌شود: اپ RTL → پنل از **چپ** می‌آید (قرینه سایدبار راست). الگوی `PanelSlot` محتوای پنل را تا پایان انیمیشن خروج mount نگه می‌دارد.
**اپ مریج**: Next.js 16.2.10 App Router، بدون هیچ کتابخانه انیمیشن. تمام مسیرهای بستن صفحه جزئیات از نوع `router.replace(questionsListHref)` هستند (۳ دکمه هدر، دکمه خروج با flush، هاردور بک) و دکمه close حالت تست به‌صورت پیش‌فرض `router.back()` می‌زند — یعنی طراحی باید مثل حسینیه «URL-driven با retention» باشد تا همه مسیرها خودکار انیمیت شوند، بدون دستکاری تک‌تک call-siteها.
**معماری انتخابی** (طبق انتخاب شما): Parallel Route `@modal` + Intercepting Route `(.)[slug]` + هاست retention — الگوی رسمی modal مستندات همین نسخه Next (`node_modules/next/dist/docs/.../parallel-routes.md` و `intercepting-routes.md`، هر دو verify شد).
## ساختار فایل‌ها
### ۱. فایل‌های جدید (تماماً additive)
```
src/app/[lang]/questions-list/
├── layout.tsx ← رندر {children} + {modal} (server)
└── @modal/
├── layout.tsx ← wrapper نازک → SectionOverlayHost
├── default.tsx ← return null (الگوی رسمی؛ جلوگیری از 404 در hard-load)
└── (.)[slug]/
└── page.tsx ← export { default } from "@/app/questions-list/[slug]/page"
```
```
src/components/Componentes/section-overlay-host.tsx ← "use client" — قلب مکانیزم
```
### ۲. `SectionOverlayHost` (ترجمه‌ی PanelSlot حسینیه به Next App Router)
- در `@modal/layout.ts` رندر می‌شود؛ چون layout اسلات در ناوبری‌های soft زنده می‌ماند، state آن پایدار است.
- **State machine**: `phase: 'enter' | 'open' | 'closing' | 'closed'`
- **باز شدن**: فرزند اسلات (صفحه intercept شده) mount می‌شود → `SectionOverlayContext` که host ارائه می‌دهد با `markActive()` از داخل `QuestionDetailClient` صدا زده می‌شود → host پنل را با کلاس off-screen رندر کرده و با `requestAnimationFrame` کلاس `open` اضافه می‌کند → CSS transition اسلاید ورود.
- **بسته شدن (هر مسیری: replace / back / هاردور)**: children اسلات به `default` (null) تغییر می‌کند → host عنصر قبلی را در state نگه می‌دارد (retention) و کلاس `closing` می‌دهد → ۲۹۰ms بعد unmount واقعی. دقیقاً همان `activeDescriptor`/`mounted` در PanelSlot.
- **RTL**: از `useI18n().locale` + `localeDirections``data-dir` روی پنل. **LTR از راست، RTL از چپ** (قرینه حسینیه که در RTL از چپ می‌آید).
- **قفل اسکرول**: کلاس `section-overlay-open` روی `body` (قرینه الگوی موجود `body.dropdown-open .app-shell` در globals.css خط ۲۳۲).
- **سایزینگ**: `position: fixed; inset-inline: 0; top/bottom: 0; margin-inline: auto` + `w-full sm:w-[375px]` + `padding-inline: 17px` + `padding-bottom: var(--safe-bottom)` — دقیقاً قرینه `.app-shell` (body فلکس و وسط‌چین است، globals.css خط ۱۵۴) تا `main` با `-mx-[17px]` داخل پنل مثل قبل رفتار کند.
### ۳. تغییر در `question-detail-client.tsx` (حداقلی، ~۶ خط)
```tsx
const overlay = useSectionOverlay(); // خارج از overlay → undefined → no-op
useEffect(() => overlay?.markActive(), [overlay]);
```
تست‌های موجود (`question-detail-client.test.tsx`) مستقیم رندر می‌کنند و context ندارند → بدون تغییر رفتار.
### ۴. CSS در `globals.css`
```css
.section-overlay {
transform: translateX(100%); /* LTR: ورود از راست */
transition: transform 280ms cubic-bezier(0.32, 0.72, 0, 1);
will-change: transform;
}
[dir="rtl"] .section-overlay { transform: translateX(-100%); } /* RTL: ورود از چپ */
.section-overlay[data-open="true"], .section-overlay[data-open="closing"] { transform: translateX(0); }
/* closing = همان حالت 0 که با برداشتن data-open="true" به سمت ابتدایی برمی‌گردد */
body.section-overlay-open .app-shell { overflow-y: hidden; }
@media (prefers-reduced-motion: reduce) { .section-overlay { transition: none; } }
```
+ سایه لبه داخلی پنل مثل `shadow-[-18px_0_50px_rgba(0,0,0,.45)]` حسینیه (جهت سایه هم با RTL برعکس).
منحنی و مدت زمان عیناً از `anim-sheet-left` حسینیه (`0.28s cubic-bezier(0.32, 0.72, 0, 1)`).
## رفتار نهایی
- کلیک روی سکشن در `/en/questions-list` → URL به `/en/questions-list/personal_identity` تغییر می‌کند، لیست زیر پنل می‌ماند (اسکرول حفظ می‌شود)، پنل از **راست** (در `/fa` از **چپ**) با همان انیمیشن حسینیه اسلاید می‌شود.
- هر مسیر بستن (دکمه close با flush پاسخ‌ها، back مرورگر، هاردور بک اندروید، اتمام سابمیت) → پنل با همان انیمیشن به همان سمت جمع می‌شود و لیست از زیر پیدا می‌شود.
- بارگذاری مستقیم/refresh روی URL جزئیات → صفحه کامل فعلی بدون انیمیشن (مطابق رفتار حسینیه در hard-load).
- متن جدیدی اضافه نمی‌شود → بدون تغییر فایل‌های locale.
## نکات اجرا و ریسک
- Dev با `--webpack` اجرا می‌شود؛ interception با webpack پشتیبانی می‌شود.
- `router.prefetch` از لیست (که الان هم هست) نسخه intercept شده را prefetch می‌کند → ورود آنی.
- اگر build روی static-params اسلات گیر کرد (بعید، همه force-dynamic هستند): `export const dynamic = "force-dynamic"` به صفحه intercept شده اضافه می‌شود.
- کد مرده `info-progress-card.tsx` (لینک non-localized بدون استفاده) دست نمی‌خورد.
## تست و راستی‌آزمایی
1. `npm run test` (vitest) — تست‌های موجود نباید بشکنند.
2. `npm run lint` (biome).
3. دستی با dev server روی پورت 3001:
- `/en/questions-list` → کلیک سکشن: اسلاید از راست؛ `/fa/questions-list` → اسلاید از چپ.
- بستن با دکمه close (flush)، back مرورگر، و شبیه‌سازی هاردور بک — همه با انیمیشن خروج.
- refresh مستقیم روی `/en/questions-list/personal_identity` → صفحه کامل.
- حفظ اسکرول لیست پس از بستن؛ قفل اسکرول پشت پنل هنگام باز بودن.له

BIN
public/fonts/Amiri/Amiri-Bold.ttf

BIN
public/fonts/Amiri/Amiri-BoldItalic.ttf

BIN
public/fonts/Amiri/Amiri-Italic.ttf

BIN
public/fonts/Amiri/Amiri-Regular.ttf

93
public/fonts/Amiri/OFL.txt

@ -0,0 +1,93 @@
Copyright 2010-2022 The Amiri Project Authors (https://github.com/aliftype/amiri).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

44
src/app/[lang]/page.tsx

@ -11,6 +11,7 @@ import {
getSubmitPath, getSubmitPath,
hasCompletedMarriageProfileBasics, hasCompletedMarriageProfileBasics,
} from "@/lib/get-submit-path"; } from "@/lib/get-submit-path";
import { fetchProfileSSR } from "@/lib/ssr-fetch";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -25,6 +26,13 @@ export default async function LocaleEntryPage({
cookieStore.get("HABIB_TOKEN")?.value ?? cookieStore.get("HABIB_TOKEN")?.value ??
cookieStore.get("habib_token")?.value; cookieStore.get("habib_token")?.value;
const hasToken = isAuthenticatedToken(token); const hasToken = isAuthenticatedToken(token);
// 1. If not authenticated, render <Intro /> instantly in SSR
if (!hasToken) {
return <Intro />;
}
// 2. Check cached entry path first
const cachedEntryPath = getAuthenticatedCachedEntryPath( const cachedEntryPath = getAuthenticatedCachedEntryPath(
token, token,
cookieStore.get(MARRIAGE_ENTRY_PATH_COOKIE)?.value, cookieStore.get(MARRIAGE_ENTRY_PATH_COOKIE)?.value,
@ -34,38 +42,16 @@ export default async function LocaleEntryPage({
redirect(localizePath(cachedEntryPath, lang)); redirect(localizePath(cachedEntryPath, lang));
} }
// 1. If not authenticated, render <Intro /> instantly on the server (0ms client delay)
if (!hasToken) {
return <Intro />;
}
// 2. If authenticated without cached route, resolve target route directly on the server
let targetPath: string | null = null;
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
if (apiBaseUrl) {
try {
const res = await fetch(`${apiBaseUrl}/api/marriage/profile/main/`, {
headers: {
Authorization: `Token ${token}`,
Accept: "application/json",
},
cache: "no-store",
});
if (res.ok) {
const profile = await res.json();
targetPath = hasCompletedMarriageProfileBasics(profile)
? getSubmitPath(profile)
: "/intro";
}
} catch {
// Graceful fallback to client-side resolver if server fetch fails
}
}
if (targetPath) {
// 3. Deep SSR Profile Resolution: resolve profile & redirect directly on server
const profile = await fetchProfileSSR(token);
if (profile) {
const targetPath = hasCompletedMarriageProfileBasics(profile)
? getSubmitPath(profile)
: "/intro";
redirect(localizePath(targetPath, lang)); redirect(localizePath(targetPath, lang));
} }
// 4. Graceful Fallback if server fetch was unreachable
return ( return (
<> <>
<EntryRouteResolver anonymousEntryVisible={false} /> <EntryRouteResolver anonymousEntryVisible={false} />

1
src/app/[lang]/questions-list/[slug]/loading.tsx

@ -0,0 +1 @@
export { default } from "@/app/questions-list/[slug]/loading";

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

@ -0,0 +1,411 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import CallResultSheet from "@/components/Componentes/call-result-sheet";
import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet";
import OutcomeSelectionSheet from "@/components/Componentes/outcome-selection-sheet";
import PageHeader from "@/components/Componentes/page-header";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import MarriageAdvisorsOverlay, {
useMarriageAdvisorsOverlay,
} from "@/components/Componentes/marriage-advisors-overlay";
import MatchProfileOverlay, {
useMatchProfileOverlay,
} from "@/components/Componentes/match-profile-overlay";
import { PageBackground } from "@/components/Componentes/page-background";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import {
useSubmitMarriageContactStatusMutation,
useSubmitMarriageOutcomeMutation,
} from "@/hooks/marriage/use-contact-status";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" },
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" },
];
export default function CandidateContactClient() {
const { dictionary: t, locale } = useI18n();
const router = useRouter();
const [isOutcomeSheetOpen, setIsOutcomeSheetOpen] = useState(false);
const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false);
const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] =
useState(false);
const { isAdvisorOpen, openAdvisors, closeAdvisors } =
useMarriageAdvisorsOverlay();
const { isProfileOpen, openProfile, closeProfile } =
useMatchProfileOverlay();
const { data: profile, isLoading: isProfileLoading } =
useMarriageProfileQuery({
refetchInterval: 3000,
});
useEffect(() => {
if (!profile) {
return;
}
const targetPath = getSubmitPath(profile);
if (targetPath !== "/candidate-contact") {
router.replace(localizePath(targetPath, locale));
}
}, [profile, router, locale]);
// Signal Flutter to lift its loading cover once the profile is available.
useHabibWebReady(!!profile && !isProfileLoading);
const isRedirecting = useMemo(() => {
if (!profile) return false;
return getSubmitPath(profile) !== "/candidate-contact";
}, [profile]);
const caseId = profile?.active_case?.case_id;
const caseStatus = profile?.active_case?.status;
const isFemale = profile?.gender === "female";
const isFinalized =
caseStatus === "finalized" || profile?.status === "matched";
const contactStatusMutation = useSubmitMarriageContactStatusMutation(
caseId ?? "",
);
const outcomeMutation = useSubmitMarriageOutcomeMutation(caseId ?? "");
const handleNoContactReport = async () => {
if (!caseId || contactStatusMutation.isPending) return;
await contactStatusMutation.mutateAsync({
action: "no_contact",
custom_note:
"No contact reported by female candidate after decision window",
});
};
if (isProfileLoading || isRedirecting) {
return <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}
/>
</>
);
}

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

@ -1,380 +1,39 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import CallResultSheet from "@/components/Componentes/call-result-sheet";
import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet";
import OutcomeSelectionSheet from "@/components/Componentes/outcome-selection-sheet";
import PageHeader from "@/components/Componentes/page-header";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import { PageBackground } from "@/components/Componentes/page-background";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { cookies } from "next/headers";
import { import {
useSubmitMarriageContactStatusMutation,
useSubmitMarriageOutcomeMutation,
} from "@/hooks/marriage/use-contact-status";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" },
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" },
];
export default function CandidateContactPage() {
const { dictionary: t, locale } = useI18n();
const router = useRouter();
const [isOutcomeSheetOpen, setIsOutcomeSheetOpen] = useState(false);
const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false);
const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] =
useState(false);
const { data: profile, isLoading: isProfileLoading } =
useMarriageProfileQuery({
refetchInterval: 3000,
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { isAuthenticatedToken } from "@/lib/entry-route-cache";
import { fetchProfileSSR } from "@/lib/ssr-fetch";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import CandidateContactClient from "./candidate-contact-client";
export const dynamic = "force-dynamic";
export default async function CandidateContactPage() {
const cookieStore = await cookies();
const token =
cookieStore.get("HABIB_TOKEN")?.value ??
cookieStore.get("habib_token")?.value;
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 10 * 1000 },
},
});
if (isAuthenticatedToken(token)) {
await queryClient.prefetchQuery({
queryKey: marriageQueryKeys.profile(),
queryFn: () => fetchProfileSSR(token!),
}); });
useEffect(() => {
if (!profile) {
return;
}
const targetPath = getSubmitPath(profile);
if (targetPath !== "/candidate-contact") {
router.replace(localizePath(targetPath, locale));
}
}, [profile, router, locale]);
const isRedirecting = useMemo(() => {
if (!profile) return false;
return getSubmitPath(profile) !== "/candidate-contact";
}, [profile]);
const caseId = profile?.active_case?.case_id;
const caseStatus = profile?.active_case?.status;
const isFemale = profile?.gender === "female";
const isFinalized =
caseStatus === "finalized" || profile?.status === "matched";
const contactStatusMutation = useSubmitMarriageContactStatusMutation(
caseId ?? "",
);
const outcomeMutation = useSubmitMarriageOutcomeMutation(caseId ?? "");
const handleNoContactReport = async () => {
if (!caseId || contactStatusMutation.isPending) return;
await contactStatusMutation.mutateAsync({
action: "no_contact",
custom_note:
"No contact reported by female candidate after decision window",
});
};
if (isProfileLoading || isRedirecting) {
return <PageLoadingSkeleton />;
} }
// If female, render the beautiful, customized layout matching the design
if (isFemale) {
return (
<>
<PageBackground />
{isOutcomeSheetOpen ? (
<OutcomeSelectionSheet
onClose={() => setIsOutcomeSheetOpen(false)}
onSubmit={async (status) => {
if (status === "success") {
if (caseId) {
await outcomeMutation.mutateAsync({
status: "success",
});
}
} else {
setIsDismissReasonSheetOpen(true);
}
}}
/>
) : null}
{isDismissReasonSheetOpen ? (
<DismissReasonSheet
onClose={() => setIsDismissReasonSheetOpen(false)}
onSubmit={async (value) => {
if (caseId) {
await outcomeMutation.mutateAsync({
status: "failure",
custom_note: value,
});
}
}}
/>
) : null}
<main className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-[calc(20px+var(--safe-bottom))]">
<PageHeader
className="-mx-[6px]"
leftButton={
isFemale ? { icon: "back", className: "hidden" } : undefined
}
/>
<div className="flex flex-1 flex-col justify-between gap-6 pt-4">
<section className="flex flex-col items-center">
{isFinalized ? (
<div className="flex flex-col items-center max-w-[320px] text-center my-auto py-12">
<div className="relative flex items-center justify-center text-[70px] animate-bounce">
🎉
</div>
<h1 className="mt-8 text-center text-[24px] leading-[1.25] font-bold text-[#E03950]">
{t["Congratulations! 🎉"]}
</h1>
<p className="mt-6 text-[#4A4A4A] group-14 font-medium leading-relaxed">
{
t[
"Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed."
]
}
</p>
</div>
) : (
<>
{/* Illustration section */}
<div className="relative mt-4 flex items-center justify-center">
<Image
src="/assets/images/Group 159788fd0467.svg"
alt={t["Selected candidate contact status"]}
width={160}
height={152}
priority
/>
</div>
{/* Title / Status message */}
<h1 className="mt-8 text-center text-[20px] leading-[1.25] font-bold text-[#1A1A1A] max-w-[280px]">
{
t[
"The selected candidate will contact your family shortly."
]
}
</h1>
{caseStatus === "contacted" ? (
<>
{/* Outcome instructions and button */}
<div className="w-full border border-[#ECECEC] bg-white rounded-[15px] mt-8 px-4 py-4 text-center shadow-sm">
<p className="text-[#555555] group-14 font-medium leading-relaxed">
{
t[
"Thank you for giving us feedback, we would be very happy if you also let us know the final result."
]
}
</p>
</div>
<div className="flex mt-6 w-full gap-3">
<button
type="button"
onClick={() =>
router.push(
localizePath("/new-match/profile", locale),
)
}
className="flex-1 h-[52px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#F5F5F5]"
>
{t["View Profile"]}
</button>
<button
type="button"
onClick={() => setIsOutcomeSheetOpen(true)}
disabled={outcomeMutation.isPending}
className="flex-1 h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50"
>
{outcomeMutation.isPending ? (
<LoadingThreeDot className="text-white" />
) : (
t["Submit Final Outcome"]
)}
</button>
</div>
</>
) : (
<>
{/* Action Buttons Row */}
<div className="flex mt-8 w-full gap-3">
<button
type="button"
onClick={handleNoContactReport}
disabled={contactStatusMutation.isPending}
className="flex-1 h-[52px] rounded-[15px] bg-[#F5F5F7] text-[#8E8E93] font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#EAEAEF] disabled:opacity-50"
>
{contactStatusMutation.isPending ? (
<LoadingThreeDot className="text-[#8E8E93]" />
) : (
t["Report No Contact"]
)}
</button>
<button
type="button"
onClick={async () => {
if (caseId) {
await contactStatusMutation.mutateAsync({
action: "contacted",
});
}
}}
disabled={contactStatusMutation.isPending}
className="flex-1 h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50"
>
{contactStatusMutation.isPending ? (
<LoadingThreeDot className="text-white" />
) : (
t["Confirm Contacted"]
)}
</button>
</div>
{/* Red warning box */}
<div className="w-full border border-[#F0445B] bg-[#FFF5F6] rounded-[15px] mt-4 px-4 py-3 text-center">
<p className="text-[#F0445B] group-12 font-semibold leading-normal">
{
t[
"To keep the process moving smoothly, the other party has a 48-hour (2-day) window to make initial contact with you or your family. If no contact is established after 2 days, you have the option to decline his request or notify us that he hasn't reached out."
]
}
</p>
</div>
</>
)}
</>
)}
</section>
<div className="space-y-4 pb-20">
{/* Advisor section */}
<AdvisorActionsCard
title={t["Get an advisor"]}
description={
t[
"Not sure what to do next? Our psychology section is here to guide you at every step."
]
}
avatars={advisorAvatars}
extraCount={7}
getAdvisorLabel={t["Get Advisor"]}
getAdvisorHref="/marriage-advisors"
/>
{/* Profile Locked banner */}
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]">
<div
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }}
className="pointer-events-auto px-[17px] pt-3"
>
<div className="flex items-center justify-center w-full gap-1 rounded-[11px] bg-[#DBDBDB] py-3.5">
<Image
src="/assets/images/material-symbols_lock.svg"
width={24}
height={24}
alt="lock"
/>
<h2 className="group-14 leading-none font-semibold text-[#747474]">
{t["Profile is locked"]}
</h2>
</div>
</div>
</section>
</div>
</div>
</main>
</>
);
}
// Fallback UI for male/other users
return ( return (
<>
<PageBackground />
{isCallResultSheetOpen ? (
<CallResultSheet
onClose={() => setIsCallResultSheetOpen(false)}
onOtherReasonsClick={() => setIsDismissReasonSheetOpen(true)}
onSubmit={async (value) => {
if (caseId) {
await contactStatusMutation.mutateAsync({
action: "contacted",
custom_note: value,
});
}
}}
/>
) : null}
{isDismissReasonSheetOpen ? (
<DismissReasonSheet
onClose={() => setIsDismissReasonSheetOpen(false)}
onSubmit={async (value) => {
if (caseId) {
await contactStatusMutation.mutateAsync({
action: "contacted",
custom_note: value,
});
}
}}
/>
) : null}
<main className="-mx-[17px] flex min-h-screen flex-col px-[17px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-36">
<PageHeader />
<section className="flex flex-1 items-center justify-center">
<div className="flex max-w-[300px] flex-col items-center">
<Image
src="/assets/images/Group 1597880467.svg"
alt={t["Selected candidate contact status"]}
width={131}
height={125}
/>
<h1 className="mt-10 text-center text-[18px] leading-[1.2] font-bold text-[#1A1A1A]">
{t["The selected candidate will contact your family shortly."]}
</h1>
</div>
</section>
<section
style={{ paddingBottom: `calc(2rem + var(--safe-bottom))` }}
className="fixed right-0 bottom-0 left-0 z-20 mx-auto flex w-full sm:max-w-[375px] gap-3 rounded-t-[30px] bg-white px-[17px] pt-6 shadow-[0_-18px_50px_rgba(15,23,42,0.08)]"
>
<button
type="button"
onClick={() => setIsCallResultSheetOpen(true)}
className="flex-1 h-[52px] rounded-[11px] bg-gradient-to-tl from-[#FE6F82] to-[#E03950] text-white font-semibold flex items-center justify-center cursor-pointer transition-opacity active:opacity-90"
>
{t["Confirm Contacted"]}
</button>
<button
type="button"
onClick={handleNoContactReport}
disabled={contactStatusMutation.isPending}
className="flex-1 h-[52px] rounded-[11px] border border-[#8B8B8B] bg-transparent text-[#8B8B8B] font-semibold flex items-center justify-center cursor-pointer transition-opacity active:opacity-90 disabled:opacity-50"
>
{contactStatusMutation.isPending ? (
<LoadingThreeDot className="text-[#8B8B8B]" />
) : (
t["Report No Contact"]
)}
</button>
</section>
</main>
</>
<HydrationBoundary state={dehydrate(queryClient)}>
<CandidateContactClient />
</HydrationBoundary>
); );
} }

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

@ -0,0 +1,191 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { FaLock, FaPen } from "react-icons/fa6";
import { IoAlertCircle } from "react-icons/io5";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import MarriageAdvisorsOverlay, {
useMarriageAdvisorsOverlay,
} from "@/components/Componentes/marriage-advisors-overlay";
import Button from "@/components/Componentes/button";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import { PageBackground } from "@/components/Componentes/page-background";
import PageHeader from "@/components/Componentes/page-header";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useRejectionSeenMutation } from "@/hooks/marriage/use-rejection-seen";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" },
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" },
];
export default function FindingMatchClient() {
const router = useRouter();
const { dictionary: t, locale } = useI18n();
const { isAdvisorOpen, openAdvisors, closeAdvisors } =
useMarriageAdvisorsOverlay();
const { data: profile, isLoading } = useMarriageProfileQuery({
refetchInterval: 3000,
});
const { mutate: markRejectionSeen, isPending: isMarkingSeen } =
useRejectionSeenMutation();
useEffect(() => {
if (!profile) {
return;
}
const targetPath = getSubmitPath(profile);
if (targetPath !== "/finding-match") {
router.replace(localizePath(targetPath, locale));
}
}, [profile, locale, router]);
// Signal Flutter to lift its loading cover once the profile is available.
useHabibWebReady(!!profile && !isLoading);
const isRedirecting = useMemo(() => {
if (!profile) return false;
return getSubmitPath(profile) !== "/finding-match";
}, [profile]);
if (isLoading || isRedirecting) {
return <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}
/>
</>
);
}

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

@ -1,180 +1,39 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
import { FaLock, FaPen } from "react-icons/fa6";
import { IoAlertCircle } from "react-icons/io5";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import Button from "@/components/Componentes/button";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import { PageBackground } from "@/components/Componentes/page-background";
import PageHeader from "@/components/Componentes/page-header";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useRejectionSeenMutation } from "@/hooks/marriage/use-rejection-seen";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" },
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" },
];
export default function FindingMatchPage() {
const router = useRouter();
const { dictionary: t, locale } = useI18n();
const { data: profile, isLoading } = useMarriageProfileQuery({
refetchInterval: 3000,
import { cookies } from "next/headers";
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { isAuthenticatedToken } from "@/lib/entry-route-cache";
import { fetchProfileSSR } from "@/lib/ssr-fetch";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import FindingMatchClient from "./finding-match-client";
export const dynamic = "force-dynamic";
export default async function FindingMatchPage() {
const cookieStore = await cookies();
const token =
cookieStore.get("HABIB_TOKEN")?.value ??
cookieStore.get("habib_token")?.value;
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 10 * 1000 },
},
}); });
const { mutate: markRejectionSeen, isPending: isMarkingSeen } =
useRejectionSeenMutation();
useEffect(() => {
if (!profile) {
return;
}
const targetPath = getSubmitPath(profile);
if (targetPath !== "/finding-match") {
router.replace(localizePath(targetPath, locale));
}
}, [profile, locale, router]);
const isRedirecting = useMemo(() => {
if (!profile) return false;
return getSubmitPath(profile) !== "/finding-match";
}, [profile]);
if (isLoading || isRedirecting) {
return <PageLoadingSkeleton />;
if (isAuthenticatedToken(token)) {
await queryClient.prefetchQuery({
queryKey: marriageQueryKeys.profile(),
queryFn: () => fetchProfileSSR(token!),
});
} }
const copy = {
title: t["SEARCH IN PROGRESS"],
description:
t[
"Our system is actively looking for compatible partners based on your criteria. This process requires time and patience. We will notify you immediately once a profile is ready for your review."
],
advisorTitle: t["Get an advisor"],
advisorDescription:
t[
"Not sure what to do next? Our psychology section is here to guide you at every step."
],
getAdvisor: t["Get Advisor"],
editProfile: t["Edit Profile"],
};
const matchImageSrc = "/assets/images/Group 1597880466.svg";
// This notice belongs exclusively to the gentleman whose accepted request
// was later rejected by the lady. The API enforces the same rule.
const unseenRejection =
profile?.gender === "male" ? profile.unseen_rejection : null;
return ( return (
<>
<PageBackground />
<main
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }}
className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] text-center"
>
<PageHeader
className="-mx-[6px]"
rightButton={{ icon: "subscription", iconLabel: "Subscribe" }}
/>
<section className="mt-20 flex flex-1 flex-col items-center">
<div className="relative h-[124px] w-[130px]" aria-hidden="true">
<Image
src={matchImageSrc}
alt=""
fill
sizes="58px"
className="object-cover"
priority
/>
</div>
<h1 className="mt-5 group-16 font-bold leading-none tracking-[0.02em] text-[#171717] uppercase">
{copy.title}
</h1>
<p className="mt-3 max-w-[320px] mx-auto text-center group-12 leading-[1.35] font-semibold text-[#747474]">
{copy.description}
</p>
{unseenRejection && (
<aside
className="mt-5 w-full rounded-[20px] border border-[#F2465F]/15 bg-white p-4 text-start shadow-[0_10px_28px_rgba(242,70,95,0.08)]"
aria-live="polite"
aria-labelledby="rejection-notice-title"
>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#FFF0F2] text-[#F2465F]">
<IoAlertCircle className="h-6 w-6" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<h2
id="rejection-notice-title"
className="text-[16px] font-bold leading-[1.3] text-[#171717]"
>
{t["Your request was rejected"]}
</h2>
<p className="mt-1.5 text-[13px] font-semibold leading-[1.5] text-[#747474]">
{
t[
"Your request was rejected by the lady. You will be introduced to other candidates in the future."
]
}
</p>
</div>
</div>
<div className="mt-4 w-full">
<Button
isLoading={isMarkingSeen}
onClick={() => {
markRejectionSeen({
rejection_id: unseenRejection.id,
});
}}
>
{t["Got it"]}
</Button>
</div>
</aside>
)}{" "}
</section>
<AdvisorActionsCard
title={copy.advisorTitle}
description={copy.advisorDescription}
avatars={advisorAvatars}
extraCount={7}
getAdvisorLabel={copy.getAdvisor}
getAdvisorHref="/marriage-advisors"
/>
<FixToTheEnd>
{profile?.can_edit_profile === false ? (
<div
className="inline-flex w-full items-center justify-center gap-2 rounded-[9px] border-none bg-[#D1D1D6] dark:bg-[#3D3E42] px-4 py-[17px] text-center text-[#747474] dark:text-[#A1A1A1] shadow-none"
aria-live="polite"
>
<FaLock aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
<span className="group-16 leading-none font-semibold">
{t["Profile is locked"]}
</span>
</div>
) : (
<Button variant="dark" href="/questions-list">
<FaPen aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
<span>{copy.editProfile}</span>
</Button>
)}
</FixToTheEnd>
</main>
</>
<HydrationBoundary state={dehydrate(queryClient)}>
<FindingMatchClient />
</HydrationBoundary>
); );
} }

94
src/app/globals.css

@ -149,6 +149,34 @@ html {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
overscroll-behavior: none; overscroll-behavior: none;
-webkit-tap-highlight-color: transparent;
}
* {
-webkit-tap-highlight-color: transparent;
}
button,
input,
select,
textarea,
a,
label,
[role="button"] {
touch-action: manipulation;
}
button,
[role="button"] {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
input,
textarea {
-webkit-user-select: text;
user-select: text;
} }
body { body {
@ -229,10 +257,74 @@ body[data-page-background="custom"] .app-shell {
background-image: var(--page-background-image); background-image: var(--page-background-image);
} }
body.dropdown-open .app-shell {
body.dropdown-open .app-shell,
body.section-overlay-open .app-shell {
overflow-y: hidden; overflow-y: hidden;
} }
/* ── Section Overlay Slide-in Panel (Exact Flutter Najm Matching: 350ms in, 200ms out, easeInOut) ── */
.section-overlay {
position: fixed;
inset-block: 0;
inset-inline: 0;
margin-inline: auto;
width: 100%;
height: 100%;
height: 100dvh;
z-index: 50;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: none;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
padding-inline: 17px;
padding-bottom: var(--safe-bottom, 0px);
box-sizing: border-box;
background-color: var(--background);
background-image: var(--default-page-background-image);
background-position: top;
background-repeat: no-repeat;
background-size: cover;
transform: translate3d(100%, 0, 0);
box-shadow: -18px 0 50px rgba(0, 0, 0, 0.18);
transition: transform 350ms cubic-bezier(0.42, 0, 0.58, 1);
will-change: transform;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
contain: paint layout;
}
@media (min-width: 640px) {
.section-overlay {
width: 375px;
}
}
/* RTL: slide in from left instead of right */
[dir="rtl"] .section-overlay,
.section-overlay[data-dir="rtl"],
[dir="rtl"] .section-overlay[data-state="closed"],
[dir="rtl"] .section-overlay[data-state="closing"],
.section-overlay[data-dir="rtl"][data-state="closed"],
.section-overlay[data-dir="rtl"][data-state="closing"] {
transform: translate3d(-100%, 0, 0);
box-shadow: 18px 0 50px rgba(0, 0, 0, 0.18);
}
.section-overlay[data-state="open"] {
transform: translate3d(0, 0, 0) !important;
}
.section-overlay[data-state="closing"] {
transition: transform 200ms cubic-bezier(0.42, 0, 0.58, 1) !important;
}
@media (prefers-reduced-motion: reduce) {
.section-overlay {
transition: none !important;
}
}
.question-detail-header { .question-detail-header {
max-height: 120px; max-height: 120px;
overflow: hidden; overflow: hidden;

197
src/app/intro/intro-client.tsx

@ -0,0 +1,197 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import Button from "@/components/Componentes/button";
import NetworkImage from "@/components/Componentes/network-image";
import PageHeader from "@/components/Componentes/page-header";
import ReportActionsSheet from "@/components/Componentes/report-actions-sheet";
import VideoPlayer from "@/components/Componentes/video-player";
import { useMarriageConfigQuery } from "@/hooks/marriage/use-marriage-config";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { authBridge } from "@/lib/auth-bridge";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
export default function IntroClient() {
const router = useRouter();
const { dictionary: t, locale } = useI18n();
const { data: profile, refetch } = useMarriageProfileQuery({
enabled: false,
retry: false,
});
const [isReportSheetOpen, setIsReportSheetOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
const { data: config } = useMarriageConfigQuery();
// Signal Flutter that the Intro UI is ready. Config has a local fallback
// image so we don't need to wait for it — announce immediately on mount.
useHabibWebReady(true);
const handleSubmit = async () => {
if (isSubmitting) {
return;
}
setIsSubmitting(true);
try {
if (!authBridge.isAuthenticated()) {
const token = await authBridge.ensureToken();
if (!token) {
console.warn("No token from bridge – login was not completed");
return;
}
}
let profileResponse = profile;
try {
const { data: freshProfile } = await refetch();
profileResponse = freshProfile ?? profile;
} catch (refetchError) {
console.warn(
"Could not refetch profile data – using fallback",
refetchError,
);
}
const submitPath = getSubmitPath(profileResponse);
const nextPath = localizePath(
submitPath === "/intro" ? "/terms" : submitPath,
locale,
);
router.push(nextPath);
} catch (error) {
console.error("Submission/redirect failed", error);
router.push(localizePath("/terms", locale));
} finally {
setIsSubmitting(false);
}
};
return (
<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>
);
}

222
src/app/intro/page.tsx

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

104
src/app/layout.tsx

@ -1,5 +1,4 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Amiri } from "next/font/google";
import localFont from "next/font/local"; import localFont from "next/font/local";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import Providers from "./providers"; import Providers from "./providers";
@ -20,12 +19,29 @@ const faminela = localFont({
fallback: ["Arial", "sans-serif"], fallback: ["Arial", "sans-serif"],
}); });
// Amiri has no UI consumer above the fold (arabic fonts are mapped to Segoe
// UI in globals.css), so preloading it only competes with critical resources
// on a cold start. `display: swap` lets it load lazily if a consumer appears.
const amiri = Amiri({
weight: ["400", "700"],
subsets: ["arabic"],
const amiri = localFont({
src: [
{
path: "../../public/fonts/Amiri/Amiri-Regular.ttf",
weight: "400",
style: "normal",
},
{
path: "../../public/fonts/Amiri/Amiri-Italic.ttf",
weight: "400",
style: "italic",
},
{
path: "../../public/fonts/Amiri/Amiri-Bold.ttf",
weight: "700",
style: "normal",
},
{
path: "../../public/fonts/Amiri/Amiri-BoldItalic.ttf",
weight: "700",
style: "italic",
},
],
variable: "--font-amiri", variable: "--font-amiri",
display: "swap", display: "swap",
preload: false, preload: false,
@ -236,34 +252,86 @@ export default async function RootLayout({
root.dataset.webBootstrap = 'pending'; root.dataset.webBootstrap = 'pending';
} }
// 4. Instant web_ready Announcement
function announce() {
if (window.__habibWebReadySent || !window.HabibApp || !window.HabibApp.postMessage) return false;
// 4. Queued web_ready Delivery Protocol
//
// Pages call window.__announceHabibWebReady() when their
// destination UI is ready. This only sets a "readyRequested"
// flag and attempts delivery. If HabibApp is not yet injected,
// the request stays pending and is retried when HabibApp
// appears. The 3-second watchdog requests readiness as a
// safety net but does NOT cancel pending delivery attempts.
//
// Contract:
// readyRequested = a destination page says "my UI is ready"
// __habibWebReadySent = postMessage was actually executed
//
var readyRequested = false;
function deliverReadyIfPossible() {
if (window.__habibWebReadySent) return true;
if (!readyRequested) return false;
if (!window.HabibApp || !window.HabibApp.postMessage) return false;
window.__habibWebReadySent = true; window.__habibWebReadySent = true;
if (!configApplied) { if (!configApplied) {
root.dataset.webBootstrap = 'pending'; root.dataset.webBootstrap = 'pending';
} }
window.HabibApp.postMessage(JSON.stringify({ action: 'web_ready' })); window.HabibApp.postMessage(JSON.stringify({ action: 'web_ready' }));
return true;
}
function tryAnnounce() {
if (!announce()) return false;
// Safety: release bootstrap-pending if initial_config never arrives
setTimeout(function() { setTimeout(function() {
if (root.dataset.webBootstrap === 'pending') { if (root.dataset.webBootstrap === 'pending') {
root.dataset.webBootstrap = 'ready'; root.dataset.webBootstrap = 'ready';
} }
}, 3000);
}, 2500);
return true; return true;
} }
if (!tryAnnounce()) {
function requestWebReady() {
readyRequested = true;
deliverReadyIfPossible();
}
window.__announceHabibWebReady = requestWebReady;
// Safety fallback: auto-request readiness after 3s if no page
// called __announceHabibWebReady(). This ensures the cover is
// never stuck forever, even for pages that forgot the call.
var _habibAutoAnnounceTimer = setTimeout(function() {
requestWebReady();
}, 3000);
// Poll for HabibApp if it wasn't available at parse time.
// When HabibApp appears, attempt delivery of any pending
// ready request. This closes the race where a page requests
// readiness before the bridge is injected.
if (!window.HabibApp || !window.HabibApp.postMessage) {
var attempts = 0; var attempts = 0;
var timer = setInterval(function() {
var bridgePollTimer = setInterval(function() {
attempts += 1; attempts += 1;
if (tryAnnounce() || attempts >= 40) clearInterval(timer);
if (window.HabibApp && window.HabibApp.postMessage) {
clearInterval(bridgePollTimer);
deliverReadyIfPossible();
} else if (attempts >= 100) {
clearInterval(bridgePollTimer);
}
}, 50); }, 50);
} }
// 5. Document Boot ID — reload detection instrumentation.
// If this ID changes across a back navigation, a hard reload
// or WebView recreation happened (not SPA navigation).
window.__habibDocumentBootId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
// 6. Hardware Back contract stub.
// Flutter should call: window.__habibHandleHardwareBack()
// The real handler stack is set up by React (use-hardware-back-handler.ts).
// This stub ensures the function exists even before React hydrates.
if (!window.__habibHandleHardwareBack) {
window.__habibHandleHardwareBack = function() {
return Promise.resolve({ handled: false });
};
}
})(); })();
`, `,
}} }}

13
src/app/marriage-advisors/page.tsx

@ -17,7 +17,11 @@ import { postActionToFlutter } from "@/lib/webview-actions";
const FALLBACK_AVATAR = "/assets/images/Avatar Image.png"; const FALLBACK_AVATAR = "/assets/images/Avatar Image.png";
export default function MarriageAdvisorsPage() {
type MarriageAdvisorsPageProps = {
onClose?: () => void;
};
export default function MarriageAdvisorsPage({ onClose }: MarriageAdvisorsPageProps = {}) {
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const [isSupportOpen, setIsSupportOpen] = useState(false); const [isSupportOpen, setIsSupportOpen] = useState(false);
@ -42,15 +46,16 @@ export default function MarriageAdvisorsPage() {
<> <>
<PageBackground /> <PageBackground />
<main <main
className="-mx-[17px] flex min-h-screen flex-col pb-10"
className="-mx-[17px] flex h-dvh flex-col overflow-hidden pb-10"
style={mainStyle} style={mainStyle}
> >
<StickyHeader>
<StickyHeader sticky={false} className="shrink-0">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<NavigationButton <NavigationButton
variant="transparent" variant="transparent"
icon="back" icon="back"
iconLabel={t["Go back"]} iconLabel={t["Go back"]}
onClick={onClose}
/> />
<h1 <h1
className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white" className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white"
@ -62,7 +67,7 @@ export default function MarriageAdvisorsPage() {
</div> </div>
</StickyHeader> </StickyHeader>
<section className="px-[17px] pb-32 pt-5 flex flex-col items-center gap-4">
<section className="flex-1 overflow-y-auto overflow-x-hidden px-[17px] pb-32 pt-5 flex flex-col items-center gap-4">
{isLoading && <AdvisorsSkeleton />} {isLoading && <AdvisorsSkeleton />}
{isError && ( {isError && (

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

@ -0,0 +1,916 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { FaLock } from "react-icons/fa6";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import MarriageAdvisorsOverlay, {
useMarriageAdvisorsOverlay,
} from "@/components/Componentes/marriage-advisors-overlay";
import MatchProfileOverlay, {
useMatchProfileOverlay,
} from "@/components/Componentes/match-profile-overlay";
import PageHeader from "@/components/Componentes/page-header";
import { PageBackground } from "@/components/Componentes/page-background";
import InformationSheet from "@/components/Componentes/information-sheet";
import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton";
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot";
import { DiscountWidget } from "@/components/Componentes/discount-widget";
import type { CheckDiscountResult } from "@/hooks/marriage/use-validate-discount";
import { useHabcoinPaymentMutation } from "@/hooks/marriage/use-habcoin-payment";
import { useHabcoinInventoryQuery } from "@/hooks/marriage/use-habcoin-inventory";
import { useRespondToMarriageCaseMutation } from "@/hooks/marriage/use-case-respond";
import type {
MarriageField,
MarriageFieldValue,
MarriageMatchSummary,
MarriagePhoneFieldValue,
} from "@/hooks/marriage/types";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useViewPaddings } from "@/hooks/use-view-paddings";
import { getSubmitPath } from "@/lib/get-submit-path";
import {
buyHabibCoinPackages,
isInFlutterWebView,
} from "@/lib/webview-actions";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" },
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" },
];
const fieldCandidateMatchers = {
name: [
"name",
"full_name",
"fullname",
"first_name",
"last_name",
"display_name",
],
occupation: [
"job_title",
"occupation",
"job",
"profession",
"career",
"work",
"highest_level_of_education",
"field_of_study",
"employment_status",
],
age: ["age", "date_of_birth", "birth_date", "dob"],
city: [
"current_residence",
"city",
"current_city",
"residence_city",
"location",
"residence",
"birthplace",
"birth_city",
],
maritalStatus: [
"current_marital_status",
"marital_status",
"maritalstatus",
"relationship_status",
],
cityPreference: [
"willingness_to_relocate",
"city_preference",
"citypreference",
"preferred_city",
"preferred_location",
"future_residence",
"residence_preference_after_marriage",
],
} as const;
type DisplayField = {
id: string;
label: string;
value: string;
};
function normalizeFieldName(value: string) {
return value
.toLowerCase()
.replace(/^q\d+[_-]?/, "")
.replace(/[^a-z0-9]/g, "");
}
function matchesCandidate(
field: MarriageField,
candidates: readonly string[],
): boolean {
const rawKey = (field.key || "").toLowerCase();
const keyParts = rawKey.split(".");
const suffix = keyParts[keyParts.length - 1];
const normalizedKey = rawKey.replace(/[^a-z0-9]/g, "");
const normalizedSuffix = suffix.replace(/[^a-z0-9]/g, "");
for (const c of candidates) {
const normC = c.toLowerCase().replace(/[^a-z0-9]/g, "");
if (
normalizedSuffix === normC ||
normalizedKey.endsWith(normC) ||
rawKey === c.toLowerCase() ||
suffix === c.toLowerCase()
) {
return true;
}
}
return false;
}
function calculateAgeFromDob(dobString: string): number | null {
if (!dobString) return null;
const match = dobString.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
if (!match) return null;
const year = parseInt(match[1], 10);
const month = parseInt(match[2], 10) - 1;
const day = parseInt(match[3], 10);
const birthDate = new Date(year, month, day);
if (isNaN(birthDate.getTime())) return null;
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const m = today.getMonth() - birthDate.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age > 0 && age < 120 ? age : null;
}
function formatOptionValue(
value: MarriageFieldValue,
dictionary?: Record<string, 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} />
</>
);
}

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

@ -1,668 +1,39 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { FaLock } from "react-icons/fa6";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import PageHeader from "@/components/Componentes/page-header";
import { PageBackground } from "@/components/Componentes/page-background";
import { IoClose } from "react-icons/io5";
import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton";
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot";
import { useHabcoinPaymentMutation } from "@/hooks/marriage/use-habcoin-payment";
import { useRespondToMarriageCaseMutation } from "@/hooks/marriage/use-case-respond";
import type {
MarriageField,
MarriageFieldValue,
MarriageMatchSummary,
MarriagePhoneFieldValue,
} from "@/hooks/marriage/types";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useViewPaddings } from "@/hooks/use-view-paddings";
import { getSubmitPath } from "@/lib/get-submit-path";
import { cookies } from "next/headers";
import { import {
buyHabibCoinPackages,
isInFlutterWebView,
} from "@/lib/webview-actions";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" },
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" },
];
const fieldCandidates = {
name: ["name", "full_name", "fullname", "first_name", "display_name"],
occupation: [
"occupation",
"job",
"profession",
"career",
"work",
"education",
"highest_level_of_education",
],
age: ["age"],
city: [
"city",
"current_city",
"residence_city",
"location",
"residence",
"birth_city",
],
maritalStatus: [
"marital_status",
"maritalstatus",
"relationship_status",
"current_marital_status",
],
cityPreference: [
"city_preference",
"citypreference",
"preferred_city",
"preferred_location",
"future_residence",
],
} as const;
type DisplayField = {
id: string;
label: string;
value: string;
};
function normalizeFieldName(value: string) {
return value
.toLowerCase()
.replace(/^q\d+[_-]?/, "")
.replace(/[^a-z0-9]/g, "");
}
function formatFieldValue(value: MarriageFieldValue) {
if (value === null || value === "") {
return null;
}
if (isMarriagePhoneFieldValue(value)) {
return `+${value.countryCode}${value.phoneNumber}`;
}
if (typeof value === "boolean") {
return value ? "Yes" : "No";
}
return String(value);
}
function isMarriagePhoneFieldValue(
value: unknown,
): value is MarriagePhoneFieldValue {
if (!value || typeof value !== "object") {
return false;
}
const phoneValue = value as Partial<MarriagePhoneFieldValue>;
return (
typeof phoneValue.countryCode === "string" &&
typeof phoneValue.phoneNumber === "string"
);
}
function titleFromKey(key: string) {
return key
.replace(/^q\d+[_-]?/i, "")
.replace(/[_-]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function toDisplayField(field: MarriageField): DisplayField | null {
const value = formatFieldValue(field.value);
if (!value) {
return null;
}
return {
id: field.key || field.label || value,
label: field.label || titleFromKey(field.key),
value,
};
}
function pickField(
fields: MarriageField[],
candidates: readonly string[],
usedIndexes: Set<number>,
) {
const candidateSet = new Set(candidates.map(normalizeFieldName));
for (const [fieldIndex, field] of fields.entries()) {
if (usedIndexes.has(fieldIndex)) {
continue;
}
const displayField = toDisplayField(field);
if (
displayField &&
[field.key, field.label].some((value) =>
candidateSet.has(normalizeFieldName(value)),
)
) {
usedIndexes.add(fieldIndex);
return displayField;
}
}
return null;
}
function useMatchSummaryDisplay(matchSummary: MarriageMatchSummary | null) {
return useMemo(() => {
const fields = matchSummary?.public_info ?? [];
const usedIndexes = new Set<number>();
const name = pickField(fields, fieldCandidates.name, usedIndexes);
const occupation = pickField(
fields,
fieldCandidates.occupation,
usedIndexes,
);
const age = pickField(fields, fieldCandidates.age, usedIndexes);
const city = pickField(fields, fieldCandidates.city, usedIndexes);
const maritalStatus = pickField(
fields,
fieldCandidates.maritalStatus,
usedIndexes,
);
const cityPreference = pickField(
fields,
fieldCandidates.cityPreference,
usedIndexes,
);
const extraFields = fields
.filter((_, index) => !usedIndexes.has(index))
.map(toDisplayField)
.filter((field): field is DisplayField => Boolean(field))
.slice(0, 4);
return {
age,
city,
cityPreference,
extraFields,
maritalStatus,
name:
name?.value ??
(matchSummary?.id ? `Profile #${matchSummary.id}` : null),
occupation,
};
}, [matchSummary]);
}
function FieldLine({ field }: { field: DisplayField }) {
return (
<p className="break-words text-[10px] leading-[1.85] font-medium text-white">
<span>{field.label}: </span>
<span>{field.value}</span>
</p>
);
}
export default function NewMatchPage() {
const router = useRouter();
const { dictionary: t, locale } = useI18n();
const { top, bottom } = useViewPaddings();
const { data: profile, isError, isLoading } = useMarriageProfileQuery();
const [isPaymentSheetOpen, setIsPaymentSheetOpen] = useState(false);
const [paymentError, setPaymentError] = useState<string | null>(null);
const [isInsufficientCoins, setIsInsufficientCoins] = useState(false);
const paymentMutation = useHabcoinPaymentMutation();
const caseId = profile?.active_case?.case_id;
const respondMutation = useRespondToMarriageCaseMutation(caseId ?? "", {
onSuccess: () => {
router.replace(localizePath("/finding-match", locale));
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { isAuthenticatedToken } from "@/lib/entry-route-cache";
import { fetchProfileSSR } from "@/lib/ssr-fetch";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import NewMatchClient from "./new-match-client";
export const dynamic = "force-dynamic";
export default async function NewMatchPage() {
const cookieStore = await cookies();
const token =
cookieStore.get("HABIB_TOKEN")?.value ??
cookieStore.get("habib_token")?.value;
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 10 * 1000 },
}, },
}); });
const handlePayment = async () => {
const recommendedPlanId = profile?.recommended_plan?.id;
if (!recommendedPlanId) return;
try {
setPaymentError(null);
setIsInsufficientCoins(false);
await paymentMutation.mutateAsync(recommendedPlanId);
setIsPaymentSheetOpen(false);
router.push(localizePath("/new-match/profile", locale));
} catch (err: any) {
console.error("Payment failed", err);
const msg =
err?.response?.data?.error || err?.message || "Payment failed";
const modalT = (t as any).paymentModal || {};
if (msg === "Not enough coins") {
setIsInsufficientCoins(true);
setPaymentError(
modalT.insufficientCoins ||
"Insufficient coin balance. Please recharge your account.",
);
} else {
setPaymentError(msg);
}
}
};
const handleDecline = async () => {
if (!caseId) return;
try {
await respondMutation.mutateAsync({ action: "reject" });
} catch (err) {
console.error("Decline failed", err);
}
};
useEffect(() => {
if (!profile) {
return;
}
const targetPath = getSubmitPath(profile);
if (targetPath !== "/new-match") {
router.replace(localizePath(targetPath, locale));
}
}, [profile, locale, router]);
const isRedirecting = useMemo(() => {
if (!profile) return false;
return getSubmitPath(profile) !== "/new-match";
}, [profile]);
const matchSummary = profile?.match_summary ?? null;
const matchDisplay = useMatchSummaryDisplay(matchSummary);
if (isLoading || isRedirecting) {
return (
<>
<PageBackground />
<main
style={{
paddingBottom: `${bottom + 20}px`,
paddingTop: `${Math.max(12, top + 4)}px`,
}}
className="-mx-[17px] min-h-screen h-full text-center px-4"
>
<PageHeader />
<div>
{/* Header Section Skeleton */}
<section className="flex flex-col items-center mt-9">
<LoadingSkeleton className="h-[60px] w-[60px] rounded-full" />
<LoadingSkeleton className="h-6 w-[220px] mt-4" />
<LoadingSkeleton className="h-4 w-[280px] mt-3" />
<LoadingSkeleton className="h-4 w-[240px] mt-1.5" />
</section>
<div className="flex h-full flex-col justify-between">
{/* Match Card Skeleton */}
<section className="mt-[36px] rounded-[15px] border border-white/80 bg-white px-[17px] pt-[18px] pb-[17px] shadow-[0_18px_45px_rgba(15,23,42,0.06)] flex flex-col items-center">
{/* Name line */}
<LoadingSkeleton className="h-5 w-[140px]" />
{/* Subtitle / Details lines */}
<div className="mt-3 w-full flex flex-col items-center gap-2 min-h-[48px]">
<LoadingSkeleton className="h-3 w-[180px]" />
<LoadingSkeleton className="h-3 w-[120px]" />
<LoadingSkeleton className="h-3 w-[150px]" />
</div>
{/* Button skeleton */}
<LoadingSkeleton className="mt-[15px] h-[40px] w-full rounded-[10px]" />
</section>
{/* Advisor Card Skeleton */}
<section className="mt-[36px]">
<div className="rounded-[13px] border border-white/80 bg-white px-3 py-3.5 text-left shadow-[0_18px_45px_rgba(15,23,42,0.06)] backdrop-blur-sm">
<LoadingSkeleton className="h-[16px] w-[140px]" />
<div className="mt-2 space-y-1">
<LoadingSkeleton className="h-[10px] w-[260px]" />
<LoadingSkeleton className="h-[10px] w-[180px]" />
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<div className="flex items-center pl-1">
<LoadingSkeleton className="h-[30px] w-[30px] rounded-full border-2 border-white" />
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" />
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" />
<LoadingSkeleton className="-ml-1.5 h-[30px] w-[30px] rounded-full border-2 border-white" />
</div>
<LoadingSkeleton className="h-[38px] w-[120px] rounded-[9px]" />
</div>
</div>
</section>
</div>
</div>
</main>
</>
);
if (isAuthenticatedToken(token)) {
await queryClient.prefetchQuery({
queryKey: marriageQueryKeys.profile(),
queryFn: () => fetchProfileSSR(token!),
});
} }
const pairedFields = [matchDisplay.age, matchDisplay.city].filter(
(field): field is DisplayField => Boolean(field),
);
const isFemaleProfile = profile?.gender === "female";
const matchHeadingTitle = isFemaleProfile
? t["New Marriage Proposal"]
: t["YOU HAVE A NEW MATCH!"];
const matchHeadingDescription = isFemaleProfile
? t[
"A suitable match has been found for you. If approved, your profile will be evaluated to proceed with the introduction process."
]
: t[
"If you proceed, we will notify the other party, and upon their approval, you can view each other's contact information."
];
const isMale = profile?.gender === "male";
const hasActiveSub = !!profile?.active_subscription;
const isMatchAvailable = !!profile?.match_summary;
return ( return (
<>
<PageBackground />
<main
style={{
paddingBottom: `${profile?.can_edit_profile === false ? 100 + bottom : 20 + bottom}px`,
paddingTop: `${Math.max(12, top + 4)}px`,
}}
className="-mx-[17px] min-h-screen h-full text-center px-4"
>
<PageHeader />
<div className="">
<section className="flex flex-col items-center mt-9">
<div
aria-hidden="true"
className="relative flex h-[60px] w-[60px] items-center justify-center rounded-full bg-[#FF4E67] shadow-[0_12px_28px_rgba(240,68,91,0.22)]"
>
<Image
src={"/assets/images/Ellipse 1210.svg"}
width={70}
height={70}
alt="notification"
/>
</div>
<h2 className="text-[22px] font-bold mt-3.5">
{matchHeadingTitle}
</h2>
<p className="mt-[11px] max-w-[322px] text-[12px] leading-[1.35] font-semibold text-[#7C7C7C]">
{matchHeadingDescription}
</p>
</section>
<div className="flex h-full flex-col justify-between">
{isLoading ? (
<section className="mt-[36px] rounded-[15px] border border-white/80 bg-white px-[17px] pt-[18px] pb-[17px] shadow-[0_18px_45px_rgba(15,23,42,0.06)]">
<div className="flex flex-col items-center py-2">
{/* Name line */}
<LoadingSkeleton className="h-5 w-[140px]" />
{/* Subtitle / Details lines */}
<div className="mt-3 w-full flex flex-col items-center gap-2 min-h-[48px]">
<LoadingSkeleton className="h-3 w-[180px]" />
<LoadingSkeleton className="h-3 w-[120px]" />
<LoadingSkeleton className="h-3 w-[150px]" />
</div>
{/* Button skeleton */}
<LoadingSkeleton className="mt-[15px] h-[40px] w-full rounded-[10px]" />
</div>
</section>
) : (
<section className="mt-[36px] rounded-[15px] bg-[linear-gradient(180deg,#F0445B_0%,#F4556E_100%)] px-[17px] pt-[18px] pb-[17px] text-white shadow-[0_18px_38px_rgba(240,68,91,0.25)]">
{isError ? (
<p className="py-8 text-[13px] font-semibold">
Unable to load match summary.
</p>
) : matchSummary ? (
<>
<h2 className="break-words text-[13px] leading-[1.4] font-bold">
<span>Name: </span>
<span>{matchDisplay.name}</span>
</h2>
<div className="mt-[3px] min-h-[48px]">
{matchDisplay.occupation ? (
<FieldLine field={matchDisplay.occupation} />
) : null}
{pairedFields.length ? (
<p className="break-words text-[10px] leading-[1.85] font-medium text-white">
{pairedFields.map((field, index) => (
<span key={field.id}>
{index > 0 ? <span> | </span> : null}
<span>
{field.label}: {field.value}
</span>
</span>
))}
</p>
) : null}
{matchDisplay.maritalStatus ? (
<FieldLine field={matchDisplay.maritalStatus} />
) : null}
{matchDisplay.cityPreference ? (
<FieldLine field={matchDisplay.cityPreference} />
) : null}
</div>
<button
type="button"
onClick={() => {
if (isMale && !hasActiveSub) {
setIsPaymentSheetOpen(true);
} else {
router.push(
localizePath("/new-match/profile", locale),
);
}
}}
className="mt-[15px] inline-flex w-full items-center justify-center rounded-[10px] border-none bg-white h-[40px] text-[16px] font-semibold text-[#F0445B] no-underline shadow-none hover:bg-white/95 transition-colors cursor-pointer"
>
{t["View more details"]}
</button>
</>
) : (
<p className="py-8 text-[13px] font-semibold">
No match summary is available yet.
</p>
)}
</section>
)}
<div className="w-full space-y-3">
<AdvisorActionsCard
className="mt-[36px]"
title={t["Get an advisor"]}
description={
t[
"Not sure what to do next? Our psychology section is here to guide you at every step."
]
}
avatars={advisorAvatars}
extraCount={7}
getAdvisorLabel={t["Get Advisor"]}
getAdvisorHref="/marriage-advisors"
/>
</div>
</div>
</div>
</main>
{profile?.can_edit_profile === false && (
<div
style={{ paddingBottom: `${16 + bottom}px` }}
className="fixed inset-x-0 bottom-0 z-20 mx-auto w-full sm:max-w-[375px] bg-background/95 px-[17px] pt-3 pb-[16px] backdrop-blur-md"
>
<div
className="inline-flex w-full items-center justify-center gap-2 rounded-[9px] border-none bg-[#D1D1D6] px-4 h-[52px] text-center text-[#747474] shadow-none"
role="status"
>
<FaLock aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
<span className="group-16 leading-none font-semibold">
{t["Profile is locked"]}
</span>
</div>
</div>
)}
{isPaymentSheetOpen && (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 backdrop-blur-xs p-4 animate-in fade-in duration-200">
<div
style={{ paddingBottom: `calc(20px + ${bottom}px)` }}
className="relative w-full max-w-[375px] rounded-[24px] bg-white px-5 pt-6 shadow-[0_-8px_30px_rgba(0,0,0,0.12)] border border-slate-100 animate-in slide-in-from-bottom duration-300 text-center"
>
<button
type="button"
className="absolute top-4 right-4 text-[#8F8F8F] hover:text-[#5F5F5F] transition-colors"
onClick={() => {
setIsPaymentSheetOpen(false);
setPaymentError(null);
setIsInsufficientCoins(false);
}}
>
<IoClose className="text-[22px]" />
</button>
<div className="flex flex-col items-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#F0445B] text-white mb-4">
<Image
src="/assets/images/noun-wedding-rings-6540466 1.svg"
alt=""
width={28}
height={28}
className="text-white"
/>
</div>
<h3 className="group-16 font-bold text-gray-900 mb-2">
{t["Verification & Subscription Activation"] ||
"Verification & Subscription Activation"}
</h3>
<p className="text-xs text-gray-500 leading-relaxed mb-4 text-center">
{t[
"This payment acts as a verification process for your account and activates a 3-month subscription to receive new case introductions. The cost of this subscription is 50 Habib Coins."
] ||
"This payment acts as a verification process for your account and activates a 3-month subscription to receive new case introductions. The cost of this subscription is 50 Habib Coins."}
</p>
<div className="w-full bg-gray-50 rounded-xl p-3 mb-4 text-center">
<span className="text-[10px] text-gray-400 block mb-0.5">
{t["Valid for 3 months"] || "Valid for 3 months"}
</span>
<span className="text-base font-bold text-[#FF4E67]">
{t["50 Coins"] || "50 Habib Coins"}
</span>
</div>
<p className="text-[10px] text-gray-400 leading-normal mb-6 text-center">
{t[
"Please note that there is no guarantee for a specific number of cases, and the volume of incoming cases is solely subject to profile compatibility with other users."
] ||
"Please note that there is no guarantee for a specific number of cases, and the volume of incoming cases is solely subject to profile compatibility with other users."}
</p>
{paymentError && (
<div className="w-full bg-[#FEF2F2] text-[#B91C1C] text-xs p-3 rounded-xl mb-4 text-center font-medium">
{paymentError}
</div>
)}
{isInsufficientCoins && isInFlutterWebView() && (
<button
type="button"
className="appearance-none border-0 bg-transparent p-0 text-left w-full mb-4"
onClick={() => buyHabibCoinPackages()}
>
<div className="inline-flex w-full items-center justify-center gap-2 rounded-[18px] bg-[#00AC78] px-4 h-[52px] text-[16px] font-semibold text-white shadow-[0_10px_18px_rgba(0,172,120,0.28)] transition-opacity active:opacity-90">
<Image
src="/assets/images/Inner Plugdsain Iframe.svg"
alt=""
aria-hidden="true"
width={18}
height={18}
className="shrink-0"
/>
<span className="truncate">
{t["Buy Habib Coins"] || "Buy Habib Coins"}
</span>
</div>
</button>
)}
<div className="grid w-full grid-cols-[33fr_67fr] gap-3">
<button
type="button"
disabled={
paymentMutation.isPending || respondMutation.isPending
}
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
onClick={handleDecline}
>
<div className="inline-flex w-full items-center justify-center rounded-[18px] border border-[#9A9A9A] bg-[#F7F7F7] px-2 h-[52px] text-[16px] font-bold text-[#8B8B8B] shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] transition-opacity active:opacity-90 min-w-0">
{respondMutation.isPending ? (
<LoadingThreeDot />
) : (
<span className="truncate">
{t["Decline"] || "Decline"}
</span>
)}
</div>
</button>
<button
type="button"
disabled={
paymentMutation.isPending || respondMutation.isPending
}
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
onClick={handlePayment}
>
<div className="inline-flex w-full items-center justify-center gap-3 rounded-[18px] bg-[#F0445B] px-4 h-[52px] text-[16px] font-semibold text-white shadow-[0_10px_18px_rgba(240,68,91,0.28)] transition-opacity active:opacity-90 min-w-0">
{paymentMutation.isPending ? (
<LoadingThreeDot />
) : (
<>
<span className="truncate min-w-0">
{t["Pay"] || "Pay"}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-[#E43B51] p-1.5 text-xs font-semibold leading-none text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.12)] shrink-0 min-w-0 whitespace-nowrap">
<Image
src="/assets/images/Inner Plugdsain Iframe.svg"
alt=""
aria-hidden="true"
width={18}
height={18}
className="shrink-0"
/>
<span className="truncate">50</span>
</span>
</>
)}
</div>
</button>
</div>
</div>
</div>
</div>
)}
</>
<HydrationBoundary state={dehydrate(queryClient)}>
<NewMatchClient />
</HydrationBoundary>
); );
} }

162
src/app/new-match/profile/page.tsx

@ -185,34 +185,40 @@ function MatchPublicProfileFields({
function NewMatchProfileSkeleton({ function NewMatchProfileSkeleton({
hideBackButton = false, hideBackButton = false,
onClose,
}: { }: {
hideBackButton?: boolean; hideBackButton?: boolean;
onClose?: () => void;
}) { }) {
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
return ( return (
<> <>
<PageBackground /> <PageBackground />
<main className="-mx-[17px] flex min-h-screen flex-col bg-[#F5F5F5] pb-10">
<StickyHeader>
<main className="-mx-[17px] flex h-dvh flex-col overflow-hidden bg-[#F5F5F5]">
<StickyHeader sticky={false} className="shrink-0">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
{!hideBackButton ? ( {!hideBackButton ? (
<NavigationButton <NavigationButton
variant="transparent" variant="transparent"
icon="close" icon="close"
iconLabel={t["Go back"]} iconLabel={t["Go back"]}
onClick={onClose}
/> />
) : ( ) : (
<div className="size-10 shrink-0" /> <div className="size-10 shrink-0" />
)} )}
<h1 className="min-w-0 flex-1 text-center group-16 font-bold text-white">
{t["New Match"]}
<h1
className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white"
style={{ fontFamily: "'Segoe UI', sans-serif" }}
>
{t["More detail"]}
</h1> </h1>
<div className="size-10 shrink-0" /> <div className="size-10 shrink-0" />
</div> </div>
</StickyHeader> </StickyHeader>
<section className="px-[17px] pb-32 pt-5">
<section className="flex-1 overflow-y-auto overflow-x-hidden px-[17px] pb-6 pt-5">
<div className="flex flex-col items-center justify-center w-full"> <div className="flex flex-col items-center justify-center w-full">
<LoadingSkeleton className="size-[90px] rounded-full shadow-sm" /> <LoadingSkeleton className="size-[90px] rounded-full shadow-sm" />
<LoadingSkeleton className="mt-3.5 h-7 w-44 rounded-xl shadow-xs" /> <LoadingSkeleton className="mt-3.5 h-7 w-44 rounded-xl shadow-xs" />
@ -246,14 +252,12 @@ function NewMatchProfileSkeleton({
</section> </section>
<div <div
style={{ paddingBottom: `var(--safe-bottom)` }}
className="fixed inset-x-0 bottom-0 z-30 px-[17px]"
style={{ paddingBottom: `calc(16px + var(--safe-bottom, 0px))` }}
className="shrink-0 z-30 w-full rounded-t-[24px] bg-white px-[17px] pt-4 shadow-[0_-4px_24px_rgba(0,0,0,0.08)]"
> >
<div className="mx-auto w-full sm:max-w-[375px] rounded-t-[24px] bg-white px-4 py-4 shadow-[0_12px_30px_rgba(0,0,0,0.14)]">
<div className="flex gap-3">
<LoadingSkeleton className="w-1/3 h-[46px] rounded-[12px]" />
<LoadingSkeleton className="w-2/3 h-[46px] rounded-[12px]" />
</div>
<div className="flex gap-3">
<LoadingSkeleton className="w-1/3 h-[52px] rounded-[12px]" />
<LoadingSkeleton className="w-2/3 h-[52px] rounded-[12px]" />
</div> </div>
</div> </div>
</main> </main>
@ -276,7 +280,13 @@ function formatBoldText(text: string) {
}); });
} }
export default function NewMatchProfilePage() {
type NewMatchProfilePageProps = {
onClose?: () => void;
};
export default function NewMatchProfilePage({
onClose,
}: NewMatchProfilePageProps = {}) {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const router = useRouter(); const router = useRouter();
const [isRequestSheetOpen, setIsRequestSheetOpen] = useState(false); const [isRequestSheetOpen, setIsRequestSheetOpen] = useState(false);
@ -371,7 +381,12 @@ export default function NewMatchProfilePage() {
const isSubmitting = respondMutation.isPending; const isSubmitting = respondMutation.isPending;
if (isLoading || !profile || isRedirecting || isSubmitting) { if (isLoading || !profile || isRedirecting || isSubmitting) {
return <NewMatchProfileSkeleton hideBackButton={isSubmitting} />;
return (
<NewMatchProfileSkeleton
hideBackButton={isSubmitting}
onClose={onClose}
/>
);
} }
const isAcceptProfileEnabled = const isAcceptProfileEnabled =
Boolean(caseId) && Boolean(caseId) &&
@ -383,7 +398,7 @@ export default function NewMatchProfilePage() {
const _firstName = nameParts[0] || ""; const _firstName = nameParts[0] || "";
const _lastName = nameParts.slice(1).join(" ") || ""; const _lastName = nameParts.slice(1).join(" ") || "";
const mainClass = "-mx-[17px] flex min-h-screen flex-col pb-10";
const mainClass = "-mx-[17px] flex h-dvh flex-col overflow-hidden";
const mainStyle = { const mainStyle = {
backgroundImage: `url("/assets/images/islamic_pattern_2_2892_3864.svg")`, backgroundImage: `url("/assets/images/islamic_pattern_2_2892_3864.svg")`,
@ -583,12 +598,13 @@ export default function NewMatchProfilePage() {
) : null} ) : null}
<main className={mainClass} style={mainStyle}> <main className={mainClass} style={mainStyle}>
<StickyHeader>
<StickyHeader sticky={false} className="shrink-0">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<NavigationButton <NavigationButton
variant="transparent" variant="transparent"
icon="close" icon="close"
iconLabel={t["Go back"]} iconLabel={t["Go back"]}
onClick={onClose}
/> />
<h1 <h1
className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white" className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white"
@ -600,7 +616,7 @@ export default function NewMatchProfilePage() {
</div> </div>
</StickyHeader> </StickyHeader>
<section className="px-[17px] pb-32 pt-5">
<section className="flex-1 overflow-y-auto overflow-x-hidden px-[17px] pb-6 pt-5">
<div <div
className="mx-auto flex flex-col items-center justify-center" className="mx-auto flex flex-col items-center justify-center"
style={{ style={{
@ -656,70 +672,68 @@ export default function NewMatchProfilePage() {
</section> </section>
<div <div
style={{ paddingBottom: `var(--safe-bottom)` }}
className="fixed inset-x-0 bottom-0 z-30 px-[17px]"
style={{ paddingBottom: `calc(16px + var(--safe-bottom, 0px))` }}
className="shrink-0 z-30 w-full rounded-t-[24px] bg-white px-[17px] pt-4 shadow-[0_-4px_24px_rgba(0,0,0,0.08)]"
> >
<div className="mx-auto w-full sm:max-w-[375px] rounded-t-[24px] bg-white px-4 py-4 shadow-[0_12px_30px_rgba(0,0,0,0.14)]">
{caseStatus === "payment_done" ||
caseStatus === "contacted" ||
caseStatus === "finalized" ||
profile?.status === "matched" ? (
{caseStatus === "payment_done" ||
caseStatus === "contacted" ||
caseStatus === "finalized" ||
profile?.status === "matched" ? (
<button
type="button"
onClick={() => (onClose ? onClose() : router.back())}
className="w-full h-[52px] flex items-center justify-center rounded-[12px] bg-[#F5F5F5] text-[#36363C] font-semibold text-[16px] transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#EBEBEB]"
>
{t.Back}
</button>
) : (
<div className="flex gap-3">
<button <button
type="button" type="button"
onClick={() => router.back()}
className="w-full h-[52px] flex items-center justify-center rounded-[12px] bg-[#F5F5F5] text-[#36363C] font-semibold text-[16px] transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#EBEBEB]"
disabled={!isRejectProfileEnabled || isSubmitting}
onClick={() => {
if (isFemaleProfile) {
setIsRejectSheetOpen(true);
} else {
setIsMaleRejectWarningOpen(true);
}
}}
className="inline-flex w-1/3 h-[52px] items-center justify-center rounded-[12px] border border-[#BFBFBF] bg-white px-4 text-[16px] font-semibold text-[#9A9A9A] disabled:cursor-not-allowed disabled:opacity-50"
> >
{t.Back}
{t.Reject}
</button> </button>
) : (
<div className="flex gap-3">
<button
type="button"
disabled={!isRejectProfileEnabled || isSubmitting}
onClick={() => {
if (isFemaleProfile) {
setIsRejectSheetOpen(true);
} else {
setIsMaleRejectWarningOpen(true);
}
}}
className="inline-flex w-1/3 h-[52px] items-center justify-center rounded-[12px] border border-[#BFBFBF] bg-white px-4 text-[16px] font-semibold text-[#9A9A9A] disabled:cursor-not-allowed disabled:opacity-50"
>
{t.Reject}
</button>
<button
type="button"
disabled={!isAcceptProfileEnabled || isSubmitting}
onClick={() => {
if (!isAcceptProfileEnabled) {
return;
}
<button
type="button"
disabled={!isAcceptProfileEnabled || isSubmitting}
onClick={() => {
if (!isAcceptProfileEnabled) {
return;
}
if (isFemaleProfile) {
setIsFemaleConsentChecked(false);
}
if (isFemaleProfile) {
setIsFemaleConsentChecked(false);
}
setIsRequestSheetOpen(true);
}}
className="inline-flex w-2/3 h-[52px] whitespace-nowrap items-center justify-center gap-1 rounded-[12px] bg-[#F0445B] px-4 text-[16px] font-semibold text-white shadow-[0_8px_16px_rgba(240,68,91,0.24)] disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? (
<LoadingThreeDot />
) : (
<>
<Image
src="/assets/images/Icfdason.svg"
alt=""
width={28}
height={28}
/>
<span>{t["Accept Profile"]}</span>
</>
)}
</button>
</div>
)}
</div>
setIsRequestSheetOpen(true);
}}
className="inline-flex w-2/3 h-[52px] whitespace-nowrap items-center justify-center gap-1 rounded-[12px] bg-[#F0445B] px-4 text-[16px] font-semibold text-white shadow-[0_8px_16px_rgba(240,68,91,0.24)] disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? (
<LoadingThreeDot />
) : (
<>
<Image
src="/assets/images/Icfdason.svg"
alt=""
width={28}
height={28}
/>
<span>{t["Accept Profile"]}</span>
</>
)}
</button>
</div>
)}
</div> </div>
</main> </main>
</> </>

30
src/app/page.tsx

@ -2,8 +2,14 @@ import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { import {
getAuthenticatedCachedEntryPath, getAuthenticatedCachedEntryPath,
isAuthenticatedToken,
MARRIAGE_ENTRY_PATH_COOKIE, MARRIAGE_ENTRY_PATH_COOKIE,
} from "@/lib/entry-route-cache"; } from "@/lib/entry-route-cache";
import {
getSubmitPath,
hasCompletedMarriageProfileBasics,
} from "@/lib/get-submit-path";
import { fetchProfileSSR } from "@/lib/ssr-fetch";
import { import {
defaultLocale, defaultLocale,
isLocale, isLocale,
@ -36,14 +42,28 @@ export default async function RootPage() {
const token = const token =
cookieStore.get("HABIB_TOKEN")?.value ?? cookieStore.get("HABIB_TOKEN")?.value ??
cookieStore.get("habib_token")?.value; cookieStore.get("habib_token")?.value;
const hasToken = isAuthenticatedToken(token);
if (!hasToken) {
redirect(`/${targetLocale}`);
}
const cachedEntryPath = getAuthenticatedCachedEntryPath( const cachedEntryPath = getAuthenticatedCachedEntryPath(
token, token,
cookieStore.get(MARRIAGE_ENTRY_PATH_COOKIE)?.value, cookieStore.get(MARRIAGE_ENTRY_PATH_COOKIE)?.value,
); );
redirect(
cachedEntryPath
? localizePath(cachedEntryPath, targetLocale)
: `/${targetLocale}`,
);
if (cachedEntryPath) {
redirect(localizePath(cachedEntryPath, targetLocale));
}
const profile = await fetchProfileSSR(token);
if (profile) {
const targetPath = hasCompletedMarriageProfileBasics(profile)
? getSubmitPath(profile)
: "/intro";
redirect(localizePath(targetPath, targetLocale));
}
redirect(`/${targetLocale}`);
} }

11
src/app/providers.tsx

@ -5,7 +5,9 @@ import {
QueryClientProvider, QueryClientProvider,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
import AuthDataBoundary from "@/components/Componentes/auth-data-boundary";
import FlutterLocaleSync from "@/components/Componentes/flutter-locale-sync"; import FlutterLocaleSync from "@/components/Componentes/flutter-locale-sync";
import HardwareBackBridge from "@/components/Componentes/hardware-back-bridge";
import SilentReloader from "@/components/Componentes/silent-reloader"; import SilentReloader from "@/components/Componentes/silent-reloader";
import { ViewPaddingsProvider } from "@/components/Componentes/view-paddings-provider"; import { ViewPaddingsProvider } from "@/components/Componentes/view-paddings-provider";
@ -42,9 +44,12 @@ export default function Providers({ children }: ProvidersProps) {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<SilentReloader> <SilentReloader>
<ViewPaddingsProvider />
<FlutterLocaleSync />
{children}
<AuthDataBoundary>
<ViewPaddingsProvider />
<FlutterLocaleSync />
<HardwareBackBridge />
{children}
</AuthDataBoundary>
</SilentReloader> </SilentReloader>
</QueryClientProvider> </QueryClientProvider>
); );

21
src/app/questions-list/[slug]/loading.tsx

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

109
src/app/questions-list/[slug]/question-detail-client.tsx

@ -1,7 +1,9 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useSectionOverlay } from "@/components/Componentes/section-overlay-host";
import Button from "@/components/Componentes/button"; import Button from "@/components/Componentes/button";
import DataErrorState from "@/components/Componentes/data-error-state"; import DataErrorState from "@/components/Componentes/data-error-state";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
@ -46,6 +48,12 @@ import { isQuestionVisible, isQuestionRequired } from "@/lib/conditional-rules";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { defaultLocale, type Locale } from "@/translations/config"; import { defaultLocale, type Locale } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useCurrentProfileId } from "@/hooks/use-current-profile-id";
import {
getScopedAssessmentDraftKey,
readScopedAssessmentDraft,
removeScopedAssessmentDraft,
} from "@/lib/user-scoped-storage";
type QuestionDetailClientProps = { type QuestionDetailClientProps = {
closeLabel: string; closeLabel: string;
@ -56,6 +64,7 @@ type QuestionDetailClientProps = {
locale?: Locale; locale?: Locale;
questionsListHref: string; questionsListHref: string;
title: string; title: string;
onClose?: () => void;
}; };
type StoredQuestionField = { type StoredQuestionField = {
@ -69,12 +78,14 @@ type StoredAnswers = {
fields?: StoredQuestionField[]; fields?: StoredQuestionField[];
}; };
function getTestDraftStorageKey(slug: string) {
return `marriage:tests:${slug}:draft`;
function getTestDraftStorageKey(slug: string, profileId: number | null) {
if (!profileId) return null;
return getScopedAssessmentDraftKey(profileId, slug);
} }
function getQuestionStorageKey(slug: string) {
return `marriage:sections:${slug}:answers`;
function getQuestionStorageKey(slug: string, profileId: number | null) {
if (!profileId) return null;
return `marriage:user:${profileId}:sections:${slug}:completed`;
} }
function QuestionFlowWrapper({ function QuestionFlowWrapper({
@ -82,11 +93,13 @@ function QuestionFlowWrapper({
itemSlug, itemSlug,
continueLabel, continueLabel,
questionsListHref, questionsListHref,
onExit,
}: { }: {
questions: QuestionField[]; questions: QuestionField[];
itemSlug: string; itemSlug: string;
continueLabel: string; continueLabel: string;
questionsListHref: string; questionsListHref: string;
onExit?: () => void;
}) { }) {
const { getAnswerValue, answers } = useQuestionAnswers(); const { getAnswerValue, answers } = useQuestionAnswers();
const { data: profile } = useMarriageProfileQuery(); const { data: profile } = useMarriageProfileQuery();
@ -132,6 +145,7 @@ function QuestionFlowWrapper({
total={requiredCount} total={requiredCount}
continueLabel={continueLabel} continueLabel={continueLabel}
exitHref={questionsListHref} exitHref={questionsListHref}
onExit={onExit}
optionalQuestionIndexes={dynamicQuestions.flatMap((question, index) => optionalQuestionIndexes={dynamicQuestions.flatMap((question, index) =>
question.required ? [] : [index], question.required ? [] : [index],
)} )}
@ -186,34 +200,48 @@ export default function QuestionDetailClient({
locale = defaultLocale, locale = defaultLocale,
questionsListHref, questionsListHref,
title, title,
onClose,
}: QuestionDetailClientProps) { }: QuestionDetailClientProps) {
const router = useRouter(); const router = useRouter();
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const [isTestStarted, setIsTestStarted] = useState(false); const [isTestStarted, setIsTestStarted] = useState(false);
const [hasTestProgress, setHasTestProgress] = useState(false); const [hasTestProgress, setHasTestProgress] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const profileId = useCurrentProfileId();
const handleExit = useCallback(() => {
if (onClose) {
onClose();
return;
}
router.replace(questionsListHref);
}, [onClose, questionsListHref, router]);
// Hardware back in the detail page = navigate back to questions list.
// QuestionAnswersProvider's pagehide/unmount safety net will flush
// any pending answers automatically when the component unmounts.
const handleHardwareBack = useCallback(async () => {
handleExit();
return true; // handled — keep WebView open
}, [handleExit]);
useHardwareBackHandler(handleHardwareBack);
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") {
const draftKey = `marriage:tests:${itemSlug}:draft`;
const draftRaw = window.localStorage.getItem(draftKey);
if (draftRaw) {
try {
const parsed = JSON.parse(draftRaw);
if (
parsed &&
typeof parsed.answers === "object" &&
parsed.answers !== null &&
Object.keys(parsed.answers).length > 0
) {
setHasTestProgress(true);
return;
}
} catch {}
if (typeof window !== "undefined" && profileId) {
const draft = readScopedAssessmentDraft(profileId, itemSlug);
if (
draft &&
typeof draft.answers === "object" &&
draft.answers !== null &&
Object.keys(draft.answers).length > 0
) {
setHasTestProgress(true);
return;
} }
setHasTestProgress(false); setHasTestProgress(false);
} }
}, [itemSlug, isTestStarted]);
}, [itemSlug, isTestStarted, profileId]);
const isCattellSlug = itemSlug === "personality_test"; const isCattellSlug = itemSlug === "personality_test";
const isGlasserSlug = itemSlug === "glasser_5_needs_test"; const isGlasserSlug = itemSlug === "glasser_5_needs_test";
@ -349,9 +377,9 @@ export default function QuestionDetailClient({
useEffect(() => { useEffect(() => {
if (!isSchemaLoading && !isSchemaError && !item) { if (!isSchemaLoading && !isSchemaError && !item) {
router.replace(questionsListHref);
handleExit();
} }
}, [isSchemaLoading, isSchemaError, item, questionsListHref, router]);
}, [isSchemaLoading, isSchemaError, item, handleExit]);
if (isSchemaLoading) { if (isSchemaLoading) {
if (!isAssessment) { if (!isAssessment) {
@ -371,7 +399,7 @@ export default function QuestionDetailClient({
variant="transparent" variant="transparent"
icon="close" icon="close"
iconLabel={closeLabel} iconLabel={closeLabel}
onClick={() => router.push(questionsListHref)}
onClick={handleExit}
/> />
<h1 className="min-w-0 flex-1 truncate text-center text-[14px] font-semibold text-white"> <h1 className="min-w-0 flex-1 truncate text-center text-[14px] font-semibold text-white">
{loadingTitle} {loadingTitle}
@ -423,7 +451,7 @@ export default function QuestionDetailClient({
variant="transparent" variant="transparent"
icon="close" icon="close"
iconLabel={closeLabel} iconLabel={closeLabel}
onClick={() => router.push(questionsListHref)}
onClick={handleExit}
/> />
<h1 className="min-w-0 flex-1 truncate text-center text-[14px] font-semibold text-white"> <h1 className="min-w-0 flex-1 truncate text-center text-[14px] font-semibold text-white">
{errorTitle} {errorTitle}
@ -466,7 +494,7 @@ export default function QuestionDetailClient({
variant="transparent" variant="transparent"
icon="close" icon="close"
iconLabel={closeLabel} iconLabel={closeLabel}
onClick={() => router.push(questionsListHref)}
onClick={handleExit}
/> />
<h1 className="min-w-0 flex-1 truncate text-center text-[14px] font-semibold text-white"> <h1 className="min-w-0 flex-1 truncate text-center text-[14px] font-semibold text-white">
{errorTitle} {errorTitle}
@ -570,10 +598,13 @@ export default function QuestionDetailClient({
})); }));
await submitCattellMutation.mutateAsync({ responses }); await submitCattellMutation.mutateAsync({ responses });
try { try {
window.localStorage.setItem(
getQuestionStorageKey(item.slug),
JSON.stringify({ completed: true }),
);
const completionKey = getQuestionStorageKey(item.slug, profileId);
if (completionKey) {
window.localStorage.setItem(
completionKey,
JSON.stringify({ completed: true }),
);
}
} catch {} } catch {}
} else if (isGlasserSlug) { } else if (isGlasserSlug) {
const responses = Object.entries(answers).map(([qNum, score]) => ({ const responses = Object.entries(answers).map(([qNum, score]) => ({
@ -582,10 +613,13 @@ export default function QuestionDetailClient({
})); }));
await submitGlasserMutation.mutateAsync({ responses }); await submitGlasserMutation.mutateAsync({ responses });
try { try {
window.localStorage.setItem(
getQuestionStorageKey(item.slug),
JSON.stringify({ completed: true }),
);
const completionKey = getQuestionStorageKey(item.slug, profileId);
if (completionKey) {
window.localStorage.setItem(
completionKey,
JSON.stringify({ completed: true }),
);
}
} catch {} } catch {}
} }
}; };
@ -598,7 +632,7 @@ export default function QuestionDetailClient({
informationLabel={informationLabel} informationLabel={informationLabel}
onClose={() => setIsTestStarted(false)} onClose={() => setIsTestStarted(false)}
onFinish={handleTestFinish} onFinish={handleTestFinish}
draftStorageKey={getTestDraftStorageKey(item.slug)}
draftStorageKey={getTestDraftStorageKey(item.slug, profileId)}
/> />
); );
} }
@ -644,6 +678,7 @@ export default function QuestionDetailClient({
variant="transparent" variant="transparent"
icon="close" icon="close"
iconLabel={closeLabel} iconLabel={closeLabel}
onClick={handleExit}
/> />
<h1 className="min-w-0 flex-1 text-center text-[14px] font-semibold text-white truncate"> <h1 className="min-w-0 flex-1 text-center text-[14px] font-semibold text-white truncate">
{item.title} {item.title}
@ -813,6 +848,7 @@ export default function QuestionDetailClient({
icon="close" icon="close"
iconLabel={closeLabel} iconLabel={closeLabel}
exitHref={questionsListHref} exitHref={questionsListHref}
onExit={handleExit}
/> />
<h1 className="min-w-0 flex-1 text-center text-[14px] font-semibold text-white truncate"> <h1 className="min-w-0 flex-1 text-center text-[14px] font-semibold text-white truncate">
{item.title} {item.title}
@ -834,6 +870,7 @@ export default function QuestionDetailClient({
itemSlug={item.slug} itemSlug={item.slug}
continueLabel={continueLabel} continueLabel={continueLabel}
questionsListHref={questionsListHref} questionsListHref={questionsListHref}
onExit={handleExit}
/> />
</div> </div>
</main> </main>

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

@ -1,789 +1,49 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IoClose } from "react-icons/io5";
import Button from "@/components/Componentes/button";
import DataErrorState from "@/components/Componentes/data-error-state";
import ErrorToast from "@/components/Componentes/error-toast";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import InformationSheet from "@/components/Componentes/information-sheet";
import NavigationButton from "@/components/Componentes/navigation-button";
import { PageBackground } from "@/components/Componentes/page-background";
import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage";
import QuestionCard from "@/components/Componentes/question-card";
import RequiredStepsCard from "@/components/Componentes/required-steps-card";
import type { MarriageField } from "@/hooks/marriage/types";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import {
getFormSection,
useFormOverviewQuery,
} from "@/hooks/marriage/use-form-schema";
import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import {
applyProfilePatchResultToCache,
updateMarriageSectionData,
} from "@/hooks/marriage/use-section-data";
import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back";
import { getAssessmentLocalProgress } from "@/lib/assessment-progress";
import {
getSubmitPath,
hasCompletedMarriageProfileBasics,
} from "@/lib/get-submit-path";
import { REQUIRED_PROFILE_SECTION_COUNT } from "@/lib/marriage-profile-contract";
import { cookies } from "next/headers";
import { 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 ( 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>
); );
} }

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

@ -0,0 +1,899 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IoClose } from "react-icons/io5";
import Button from "@/components/Componentes/button";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import DataErrorState from "@/components/Componentes/data-error-state";
import ErrorToast from "@/components/Componentes/error-toast";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import InformationSheet from "@/components/Componentes/information-sheet";
import NavigationButton from "@/components/Componentes/navigation-button";
import { PageBackground } from "@/components/Componentes/page-background";
import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage";
import QuestionCard from "@/components/Componentes/question-card";
import RequiredStepsCard from "@/components/Componentes/required-steps-card";
import type { MarriageField } from "@/hooks/marriage/types";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import { getCattellQuestions } from "@/hooks/marriage/use-cattell";
import { getGlasserQuestions } from "@/hooks/marriage/use-glasser";
import {
getFormSection,
useFormOverviewQuery,
} from "@/hooks/marriage/use-form-schema";
import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import {
applyProfilePatchResultToCache,
updateMarriageSectionData,
} from "@/hooks/marriage/use-section-data";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { getAssessmentLocalProgress } from "@/lib/assessment-progress";
import {
readScopedAssessmentDraft,
readScopedSectionDraft,
removeScopedSectionDraft,
} from "@/lib/user-scoped-storage";
import {
getSubmitPath,
hasCompletedMarriageProfileBasics,
} from "@/lib/get-submit-path";
import { REQUIRED_PROFILE_SECTION_COUNT } from "@/lib/marriage-profile-contract";
import {
clearMatchStartGrace,
markMatchStarted,
} from "@/lib/match-start-grace";
import type { QuestionListItem } from "@/lib/schema-adapter";
import { convertOverviewToFrontendItems } from "@/lib/schema-adapter";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch";
import DevTapInstrumentation from "@/components/Componentes/dev-tap-instrumentation";
import SectionsRequest from "./sections-request";
import SectionOverlayHost from "@/components/Componentes/section-overlay-host";
import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client";
export default function QuestionsListClient() {
// Hardware back on the root questions list = close the Flutter service.
// Unlike the old useCloseServiceOnBack, this does NOT push fake history
// entries. Flutter calls __habibHandleHardwareBack() and we return false
// (meaning "I didn't handle it — you should close").
useHardwareBackHandler(() => {
if (activeSectionSlug) {
handleCloseSection();
return true; // Handled: closed the section sheet, do not close WebView
}
if (typeof window !== "undefined" && (window as any).HabibApp?.postMessage) {
(window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" }),
);
}
return false; // Tell Flutter to close the WebView screen
});
const { dictionary: t, locale } = useI18n();
const router = useRouter();
const queryClient = useQueryClient();
const { data: profile, isLoading: isProfileLoading, isError: isProfileError, refetch: refetchProfile } =
useMarriageProfileQuery();
const { data: overview, isLoading: isSchemaLoading, isError: isSchemaError, refetch: refetchOverview } = useFormOverviewQuery(
"profile",
locale,
);
const isSectionsLoading = false;
const profileTargetPath = useMemo(
() => (profile ? getSubmitPath(profile) : null),
[profile],
);
const isProfileRedirecting =
profileTargetPath !== null &&
profileTargetPath !== "/questions-list" &&
(!hasCompletedMarriageProfileBasics(profile) ||
profile?.can_edit_profile === false);
useEffect(() => {
if (isProfileRedirecting && profileTargetPath) {
router.replace(localizePath(profileTargetPath, locale));
}
}, [isProfileRedirecting, locale, profileTargetPath, router]);
// Signal Flutter to lift its loading cover once the profile is available
// (either from SSR hydration or client-side fetch).
useHabibWebReady(!!profile && !isProfileLoading);
const startMatchMutation = useStartMarriageMatchMutation({
onSuccess: () => {
markMatchStarted();
router.push(localizePath("/finding-match", locale));
},
onError: () => {
// Never pretend the request went through – the user stays here and can
// retry instead of being parked on the waiting screen forever.
clearMatchStartGrace();
},
});
const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false);
const [selectedSection, setSelectedSection] =
useState<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>
</>
);
}

87
src/app/questions-list/section-prefetch-race.test.ts

@ -0,0 +1,87 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
/**
* Regression test for P0-1: prefetchQueueStarted race condition.
*
* The bug: prefetchQueueStarted.current was set to `true` before
* requestIdleCallback fired. If the effect re-ran (e.g. sectionProgressBySlug
* changed identity) and cleanup cancelled the idle callback, the flag stayed
* `true` and subsequent effect runs skipped prefetching entirely.
*
* The fix: critical section prefetch runs immediately (not in idle), so even
* if the idle callback for remaining sections is cancelled, the top-priority
* section is always warmed up.
*/
describe("prefetch queue race condition (P0-1)", () => {
let idleCallbacks: Map<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);
});
});

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

@ -1,753 +1,39 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import CallResultSheet from "@/components/Componentes/call-result-sheet";
import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet";
import FemaleConsentSheet from "@/components/Componentes/female-consent-sheet";
import FemaleOutcomeSheet from "@/components/Componentes/female-outcome-sheet";
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot";
import OutcomeSelectionSheet from "@/components/Componentes/outcome-selection-sheet";
import { PageBackground } from "@/components/Componentes/page-background";
import PageHeader from "@/components/Componentes/page-header";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import SubscriptionRequiredSheet from "@/components/Componentes/subscription-required-sheet";
import SwipeButton from "@/components/Componentes/swipe-button";
import type {
MarriageField,
MarriagePhoneFieldValue,
} from "@/hooks/marriage/types";
import { useMarriageContactInfoQuery } from "@/hooks/marriage/use-contact-info";
import {
useSubmitMarriageContactStatusMutation,
useSubmitMarriageOutcomeMutation,
} from "@/hooks/marriage/use-contact-status";
import { cookies } from "next/headers";
import { import {
extractHabcoinPaymentUrl,
useHabcoinPaymentMutation,
} from "@/hooks/marriage/use-habcoin-payment";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" },
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" },
];
type ContactInfoPhoneItem = {
key: string;
label: string;
phoneNumber: string;
};
function sanitizePhoneNumber(value: MarriageField["value"]) {
if (value === null || value === "") {
return null;
}
if (isMarriagePhoneFieldValue(value)) {
const digits = value.phoneNumber.replace(/\D/g, "");
return digits ? `+${value.countryCode}${digits}` : null;
}
const trimmedValue = String(value).trim();
if (!trimmedValue) {
return null;
}
const digits = trimmedValue.replace(/\D/g, "");
if (!digits) {
return null;
}
return trimmedValue.startsWith("+") ? `+${digits}` : digits;
}
function isMarriagePhoneFieldValue(
value: unknown,
): value is MarriagePhoneFieldValue {
if (!value || typeof value !== "object") {
return false;
}
const phoneValue = value as Partial<MarriagePhoneFieldValue>;
return (
typeof phoneValue.countryCode === "string" &&
typeof phoneValue.phoneNumber === "string"
);
}
function getContactInfoPhoneItems(
contactInfoFields: MarriageField[] | null | undefined,
): ContactInfoPhoneItem[] {
if (!contactInfoFields) {
return [];
}
return contactInfoFields
.map((field) => {
const phoneNumber = sanitizePhoneNumber(field.value);
if (!phoneNumber) {
return null;
}
const rawLabel = field.label || field.key;
const label = rawLabel
.replace(/\s+with\s+Country\s+Code/gi, "")
.replace(/\s+با\s+کد\s+کشور/g, "")
.trim();
return {
key: field.key,
label,
phoneNumber,
};
})
.filter((item): item is ContactInfoPhoneItem => item !== null);
}
function ContactInfoPhoneCard({ item }: { item: ContactInfoPhoneItem }) {
return (
<div className="flex items-center justify-between rounded-[15px] border border-[#E4E4E4] bg-[#FBFBFB] px-4 py-3.5 shadow-[0_4px_12px_rgba(0,0,0,0.03)] gap-4">
<div className="text-left min-w-0">
<p className="text-xs font-semibold text-[#8F8F8F] break-words">
{item.label}
</p>
<p className="text-base font-bold text-[#1F1F1F] dir-ltr">
{item.phoneNumber}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<a
href={`tel:${item.phoneNumber}`}
onClick={(e) => {
if (typeof window !== "undefined" && "HabibApp" in window) {
e.preventDefault();
try {
const app = (
window as Window & {
HabibApp?: { postMessage: (msg: string) => void };
}
).HabibApp;
app?.postMessage(
JSON.stringify({
action: "open_external_url",
data: {
url: `tel:${item.phoneNumber}`,
mode: "externalApplication",
title: "phone_call",
},
}),
);
} catch (err) {
console.error("Error calling HabibApp bridge", err);
}
}
}}
className="inline-flex p-3 items-center justify-center rounded-[10px] bg-[#F0445B] text-white shrink-0"
>
<Image
src={"/assets/images/Vecfdastor.svg"}
width={16}
height={16}
alt="call"
/>
</a>
</div>
</div>
);
}
export default function RequestAcceptedPage() {
const { dictionary: t, locale } = useI18n();
const router = useRouter();
const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false);
const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] =
useState(false);
const [isContactInfoSheetOpen, setIsContactInfoSheetOpen] = useState(false);
const [isSubscriptionSheetOpen, setIsSubscriptionSheetOpen] = useState(false);
const [paymentError, setPaymentError] = useState<string | null>(null);
const [isInsufficientCoins, setIsInsufficientCoins] = useState(false);
const [isOutcomeSheetOpen, setIsOutcomeSheetOpen] = useState(false);
const [isNoContactConfirmOpen, setIsNoContactConfirmOpen] = useState(false);
const [isContactReceivedConfirmOpen, setIsContactReceivedConfirmOpen] =
useState(false);
const [noContactReportedSuccess, setNoContactReportedSuccess] =
useState(false);
const [hasConfirmedFemaleContact, setHasConfirmedFemaleContact] =
useState(false);
const profileHref = localizePath("/new-match/profile", locale);
const { data: profile, isLoading } = useMarriageProfileQuery({
refetchInterval: 3000,
});
const isFemaleProfile = profile?.gender === "female";
const contactSharedAtStr = profile?.active_case?.contact_shared_at;
useEffect(() => {
if (!profile || noContactReportedSuccess) {
return;
}
const targetPath = getSubmitPath(profile);
if (targetPath !== "/request-accepted") {
router.replace(localizePath(targetPath, locale));
}
}, [profile, router, locale, noContactReportedSuccess]);
const isRedirecting = useMemo(() => {
if (!profile) return false;
return getSubmitPath(profile) !== "/request-accepted";
}, [profile]);
const caseId = profile?.active_case?.case_id;
const caseStatus = profile?.active_case?.status;
const isFemaleContactConfirmed =
isFemaleProfile &&
(caseStatus === "contacted" || hasConfirmedFemaleContact);
const recommendedPlanId = profile?.recommended_plan?.id;
const paymentMutation = useHabcoinPaymentMutation();
const outcomeMutation = useSubmitMarriageOutcomeMutation(caseId ?? "");
const contactStatusMutation = useSubmitMarriageContactStatusMutation(
caseId ?? "",
{
onSuccess: (_data, variables) => {
if (variables?.action === "no_contact") {
setNoContactReportedSuccess(true);
} else {
if (!isFemaleProfile) {
router.push(localizePath("/finding-match", locale));
}
}
},
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { isAuthenticatedToken } from "@/lib/entry-route-cache";
import { fetchProfileSSR } from "@/lib/ssr-fetch";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import RequestAcceptedClient from "./request-accepted-client";
export const dynamic = "force-dynamic";
export default async function RequestAcceptedPage() {
const cookieStore = await cookies();
const token =
cookieStore.get("HABIB_TOKEN")?.value ??
cookieStore.get("habib_token")?.value;
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 10 * 1000 },
}, },
);
const contactInfoQuery = useMarriageContactInfoQuery(caseId, {
enabled: false,
}); });
if (isLoading || isRedirecting) {
return <PageLoadingSkeleton />;
}
const titleText = isFemaleProfile
? t["Request Approved"]
: caseStatus === "payment_done" || caseStatus === "contacted"
? t["Contact info released"]
: t["Request approved!"];
const primaryActionText = isFemaleProfile
? t["No Contact Received"]
: t["View profile"];
const secondaryActionText = isFemaleProfile
? t["Contact Received"]
: caseStatus === "payment_done" || caseStatus === "contacted"
? t["View contact number"]
: t["Pay and get contact"];
const contactInfoPhoneItems = getContactInfoPhoneItems(
contactInfoQuery.data?.contact_info,
);
const handleSecondaryAction = async () => {
if (isFemaleProfile) {
setIsContactReceivedConfirmOpen(true);
return;
}
if (caseStatus === "female_accepted" || caseStatus === "payment_pending") {
setIsSubscriptionSheetOpen(true);
return;
}
if (caseStatus === "payment_done" || caseStatus === "contacted") {
if (!caseId) {
return;
}
if (!contactInfoQuery.data) {
await contactInfoQuery.refetch();
}
setIsContactInfoSheetOpen(true);
}
};
const handlePayment = async () => {
if (!recommendedPlanId || paymentMutation.isPending) {
return;
}
try {
setPaymentError(null);
setIsInsufficientCoins(false);
const paymentResponse =
await paymentMutation.mutateAsync(recommendedPlanId);
const paymentUrl = extractHabcoinPaymentUrl(paymentResponse);
if (paymentUrl) {
window.location.assign(paymentUrl);
return;
}
setIsSubscriptionSheetOpen(false);
if (caseId) {
await contactInfoQuery.refetch();
setIsContactInfoSheetOpen(true);
}
} catch (err: any) {
console.error("Habcoin payment request failed", err);
const msg =
err?.response?.data?.error || err?.message || "Payment failed";
if (msg === "Not enough coins") {
setIsInsufficientCoins(true);
setPaymentError(
t["Insufficient coin balance. Please recharge your account."] ||
"Insufficient coin balance. Please recharge your account.",
);
} else {
setPaymentError(msg);
}
}
};
const handleNoContactReport = async () => {
if (!caseId || contactStatusMutation.isPending) return;
await contactStatusMutation.mutateAsync({
action: "no_contact",
custom_note:
"No contact reported by female candidate after decision window",
if (isAuthenticatedToken(token)) {
await queryClient.prefetchQuery({
queryKey: marriageQueryKeys.profile(),
queryFn: () => fetchProfileSSR(token!),
}); });
};
const isFinalized =
caseStatus === "finalized" || profile?.status === "matched";
}
return ( return (
<>
<PageBackground />
{isCallResultSheetOpen ? (
<CallResultSheet
onClose={() => setIsCallResultSheetOpen(false)}
onOtherReasonsClick={() => setIsDismissReasonSheetOpen(true)}
onSubmit={async (value) => {
if (caseId) {
await contactStatusMutation.mutateAsync({
action: "contacted",
custom_note: value,
});
}
}}
/>
) : null}
{isContactReceivedConfirmOpen ? (
<FemaleConsentSheet
title={t.Confirm}
description={
<p className="font-bold text-[#00AC78] text-center my-4 text-base">
{t["Are you sure contact has been made?"]}
</p>
}
buttons={
<SwipeButton
theme="green"
text={t["Confirm"]}
onCancel={() => setIsContactReceivedConfirmOpen(false)}
onSuccess={async () => {
setIsContactReceivedConfirmOpen(false);
setHasConfirmedFemaleContact(true);
if (!caseId) {
return;
}
try {
await contactStatusMutation.mutateAsync({
action: "contacted",
custom_note:
"Contact received confirmed by female candidate",
});
} catch (error) {
// The confirmation screen must advance immediately after a swipe.
// Keep the local state visible while the profile query retries.
console.error("Unable to persist received contact", error);
}
}}
/>
}
onClose={() => setIsContactReceivedConfirmOpen(false)}
closeOnOutside={true}
/>
) : null}
{isDismissReasonSheetOpen ? (
<DismissReasonSheet
onClose={() => setIsDismissReasonSheetOpen(false)}
onSubmit={async (value) => {
if (caseId) {
await outcomeMutation.mutateAsync({
status: "failure",
custom_note: value,
});
}
}}
/>
) : null}
{isOutcomeSheetOpen ? (
isFemaleProfile ? (
<FemaleOutcomeSheet
onClose={() => setIsOutcomeSheetOpen(false)}
onSubmit={async (status, reason) => {
if (status === "success") {
// If they confirm they are in the acquaintance/proposal process and nothing is finalized yet:
// No change is made to the profile, we just close the sheet.
setIsOutcomeSheetOpen(false);
} else {
// If they cancel:
if (caseId) {
await outcomeMutation.mutateAsync({
status: "failure",
custom_note: reason,
});
}
}
}}
/>
) : (
<OutcomeSelectionSheet
onClose={() => setIsOutcomeSheetOpen(false)}
onSubmit={async (status) => {
if (status === "success") {
if (caseId) {
await outcomeMutation.mutateAsync({
status: "success",
});
}
} else {
setIsDismissReasonSheetOpen(true);
}
}}
/>
)
) : null}
{isContactInfoSheetOpen ? (
<FemaleConsentSheet
title={t["Contact Detail"]}
description={
t[
"Please mention during the call that you were introduced by the Habib Marriage app."
]
}
buttons={
contactInfoPhoneItems.length ? (
<div className="space-y-4">
{contactInfoPhoneItems.map((item) => (
<ContactInfoPhoneCard key={item.key} item={item} />
))}
</div>
) : (
<div className="rounded-[12px] bg-[#ECECEC] px-4 py-3 group-12 font-semibold text-[#555]">
{t["Contact information is not available yet."]}
</div>
)
}
onClose={() => setIsContactInfoSheetOpen(false)}
/>
) : null}
{isSubscriptionSheetOpen ? (
<SubscriptionRequiredSheet
onClose={() => {
setIsSubscriptionSheetOpen(false);
setPaymentError(null);
setIsInsufficientCoins(false);
}}
onPayment={handlePayment}
isPaymentPending={!recommendedPlanId || paymentMutation.isPending}
errorMessage={paymentError}
showBuyCoins={isInsufficientCoins}
/>
) : null}
{isNoContactConfirmOpen ? (
<FemaleConsentSheet
title={t.Confirm}
description={
<p className="font-bold text-[#E03950] text-center my-4 text-base">
{
t[
"No contact has been made with you in any way or by any party."
]
}
</p>
}
buttons={
<button
type="button"
onClick={async () => {
setIsNoContactConfirmOpen(false);
await handleNoContactReport();
}}
className="w-full h-[48px] rounded-[15px] bg-[#E03950] text-white font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95"
>
{t.Confirm}
</button>
}
onClose={() => setIsNoContactConfirmOpen(false)}
closeOnOutside={true}
/>
) : null}
<main className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] pb-[calc(20px+var(--safe-bottom))] text-center">
<PageHeader className="-mx-[6px]" />
<div className="flex flex-1 flex-col justify-between gap-6 pt-4">
<section className="flex flex-col items-center">
{isFinalized ? (
<div className="flex flex-col items-center max-w-[320px] text-center my-auto py-12">
<div className="relative flex items-center justify-center text-[70px] animate-bounce">
🎉
</div>
<h1 className="mt-8 text-center text-[24px] leading-[1.25] font-bold text-[#E03950]">
{t["Congratulations! 🎉"]}
</h1>
<p className="mt-6 text-[#4A4A4A] group-14 font-medium leading-relaxed">
{
t[
"Wishing you a lifetime of love, joy, and happiness. Your profile has been successfully closed."
]
}
</p>
</div>
) : (
<>
<div className="relative isolate flex items-center justify-center">
<Image
src="/assets/images/Group 15978804fdasf68.svg"
alt={t["Request accepted"]}
width={131}
height={125}
priority
className="relative z-10"
/>
</div>
<h1 className="mt-11 group-16 leading-none font-black tracking-[0.02em] text-[#171717] uppercase">
{titleText}
</h1>
{caseStatus === "contacted" ||
isFemaleContactConfirmed ||
(isFemaleProfile && contactStatusMutation.isPending) ? (
<div className="w-full border border-[#ECECEC] bg-white rounded-[15px] mt-6 px-4 py-4 text-center shadow-sm max-w-[315px] flex flex-col items-center justify-center min-h-[100px]">
{isFemaleProfile && contactStatusMutation.isPending ? (
<LoadingThreeDot className="text-[#E03950]" />
) : (
<p className="text-[#555555] group-14 font-medium leading-relaxed">
{isFemaleProfile
? t[
"Thank you for your feedback. To complete the process, please submit the final outcome of this introduction/contact so that the final status can be determined. If the final status is not yet determined, you can stay in this state until it is finalized."
]
: t[
"Thank you for giving us feedback, we would be very happy if you also let us know the final result."
]}
</p>
)}
</div>
) : (
<p className="mt-4 max-w-[315px] group-12 leading-[1.45] font-semibold text-[#777777]">
{noContactReportedSuccess
? t[
"Thank you for your feedback. Our support team will investigate the matter and notify you of the result. Please wait patiently during the review; our support will contact you."
]
: isFemaleProfile
? t[
"The selected candidate will contact your family shortly."
]
: t[
"You can now view their family's contact details and arrange further steps."
]}
</p>
)}
<>
{caseStatus === "contacted" ||
isFemaleContactConfirmed ||
(isFemaleProfile && contactStatusMutation.isPending) ? (
<div className="flex mt-8 w-full gap-3 justify-center">
{isFemaleProfile &&
contactStatusMutation.isPending ? null : isFemaleProfile ? (
<button
type="button"
onClick={() => setIsOutcomeSheetOpen(true)}
disabled={outcomeMutation.isPending}
className="w-full max-w-[315px] h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50"
>
{outcomeMutation.isPending ? (
<LoadingThreeDot className="text-white" />
) : (
t["Share Result"]
)}
</button>
) : (
<>
<button
type="button"
onClick={() =>
router.push(
localizePath("/new-match/profile", locale),
)
}
className="flex-1 h-[52px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-transform active:scale-[0.98] cursor-pointer hover:bg-[#F5F5F5]"
>
{t["View Profile"]}
</button>
<button
type="button"
onClick={() => setIsOutcomeSheetOpen(true)}
disabled={outcomeMutation.isPending}
className="flex-1 h-[52px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-lg shadow-[#FE6F82]/30 transition-transform active:scale-[0.98] cursor-pointer hover:opacity-95 disabled:opacity-50"
>
{outcomeMutation.isPending ? (
<LoadingThreeDot className="text-white" />
) : (
t["Submit Final Outcome"]
)}
</button>
</>
)}
</div>
) : (
<div className="flex mt-9 w-full justify-center gap-4 max-w-[315px] mx-auto">
{isFemaleProfile ? (
<button
type="button"
onClick={() => setIsNoContactConfirmOpen(true)}
disabled={
contactStatusMutation.isPending ||
noContactReportedSuccess
}
className="flex-1 h-[44px] rounded-[15px] border border-[#BFBFBF] bg-white text-[#36363C] font-semibold group-14 flex items-center justify-center transition-all cursor-pointer hover:bg-[#F5F5F5] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white"
>
{contactStatusMutation.isPending ? (
<LoadingThreeDot />
) : (
primaryActionText
)}
</button>
) : (
<Link
href={profileHref}
className="max-w-[212px] flex-1"
>
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C] min-h-[38px] flex items-center justify-center">
{primaryActionText}
</div>
</Link>
)}
<button
type="button"
onClick={() => {
void handleSecondaryAction();
}}
disabled={paymentMutation.isPending}
className={
isFemaleProfile
? "flex-1 h-[44px] rounded-[15px] bg-gradient-to-r from-[#FE6F82] to-[#E03950] text-white font-semibold group-14 flex items-center justify-center shadow-md shadow-[#FE6F82]/30 transition-all cursor-pointer hover:opacity-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none"
: "max-w-[212px] flex-1 appearance-none border-0 bg-transparent p-0 text-left"
}
>
{isFemaleProfile ? (
paymentMutation.isPending ? (
<LoadingThreeDot className="text-white" />
) : (
secondaryActionText
)
) : (
<div className="bg-linear-180 from-[#FE6F82] to-[#E03950] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#fff] shadow-md shadow-[#F2596E]/60 flex items-center justify-center min-h-[38px]">
{paymentMutation.isPending ? (
<LoadingThreeDot />
) : (
secondaryActionText
)}
</div>
)}
</button>
</div>
)}
{caseStatus !== "contacted" &&
!isFemaleContactConfirmed &&
!noContactReportedSuccess &&
!(isFemaleProfile && contactStatusMutation.isPending) ? (
<div className="border border-[#F0445B] bg-[#F0445B]/10 rounded-xl mt-4">
<p className="text-[#F0445B] group-12 font-semibold py-2.5 px-3.5 whitespace-pre-line">
{
t[
"Please be informed that from the time of this introduction, you have 48 hours (2 days) to contact the person or their respected family to declare your readiness and begin the acquaintance process. At this stage, merely an initial call to announce your presence is sufficient, and planning for further steps (such as an in-person meeting) depends entirely on your subsequent mutual agreements. Since failing to make contact within the specified time might be considered socially disrespectful, if no action is taken within these 2 days, the introduced match will be removed according to the platform's rules. We also remind you that this issue may lead to restrictions such as delays in future introductions and financial penalties."
]
}
</p>
</div>
) : null}
</>
</>
)}
</section>
<div className="space-y-8 pb-20">
{/* Advisor section */}
<AdvisorActionsCard
title={t["Get an advisor"]}
description={
t[
"Not sure what to do next? Our psychology section is here to guide you at every step."
]
}
avatars={advisorAvatars}
extraCount={7}
getAdvisorLabel={t["Get Advisor"]}
getAdvisorHref="/marriage-advisors"
/>
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]">
<div
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }}
className="pointer-events-auto px-[17px] pt-3"
>
<div className="flex items-center justify-center w-full gap-1 rounded-[11px] bg-[#DBDBDB] py-3.5">
<Image
src="/assets/images/material-symbols_lock.svg"
width={24}
height={24}
alt="lock"
/>
<h2 className="group-14 leading-none font-semibold text-[#747474]">
{t["Profile is locked"]}
</h2>
</div>
</div>
</section>
</div>
</div>
</main>
</>
<HydrationBoundary state={dehydrate(queryClient)}>
<RequestAcceptedClient />
</HydrationBoundary>
); );
} }

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

@ -0,0 +1,773 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import MarriageAdvisorsOverlay, {
useMarriageAdvisorsOverlay,
} from "@/components/Componentes/marriage-advisors-overlay";
import MatchProfileOverlay, {
useMatchProfileOverlay,
} from "@/components/Componentes/match-profile-overlay";
import CallResultSheet from "@/components/Componentes/call-result-sheet";
import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet";
import FemaleConsentSheet from "@/components/Componentes/female-consent-sheet";
import FemaleOutcomeSheet from "@/components/Componentes/female-outcome-sheet";
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot";
import OutcomeSelectionSheet from "@/components/Componentes/outcome-selection-sheet";
import { PageBackground } from "@/components/Componentes/page-background";
import PageHeader from "@/components/Componentes/page-header";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import SubscriptionRequiredSheet from "@/components/Componentes/subscription-required-sheet";
import SwipeButton from "@/components/Componentes/swipe-button";
import type {
MarriageField,
MarriagePhoneFieldValue,
} from "@/hooks/marriage/types";
import { useMarriageContactInfoQuery } from "@/hooks/marriage/use-contact-info";
import {
useSubmitMarriageContactStatusMutation,
useSubmitMarriageOutcomeMutation,
} from "@/hooks/marriage/use-contact-status";
import {
extractHabcoinPaymentUrl,
useHabcoinPaymentMutation,
} from "@/hooks/marriage/use-habcoin-payment";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" },
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" },
];
type ContactInfoPhoneItem = {
key: string;
label: string;
phoneNumber: string;
};
function sanitizePhoneNumber(value: MarriageField["value"]) {
if (value === null || value === "") {
return null;
}
if (isMarriagePhoneFieldValue(value)) {
const digits = value.phoneNumber.replace(/\D/g, "");
return digits ? `+${value.countryCode}${digits}` : null;
}
const trimmedValue = String(value).trim();
if (!trimmedValue) {
return null;
}
const digits = trimmedValue.replace(/\D/g, "");
if (!digits) {
return null;
}
return trimmedValue.startsWith("+") ? `+${digits}` : digits;
}
function isMarriagePhoneFieldValue(
value: unknown,
): value is MarriagePhoneFieldValue {
if (!value || typeof value !== "object") {
return false;
}
const phoneValue = value as Partial<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}
/>
</>
);
}

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

@ -1,140 +1,39 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import PageHeader from "@/components/Componentes/page-header";
import { PageBackground } from "@/components/Componentes/page-background";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" },
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" },
];
export default function RequestSentPage() {
const router = useRouter();
const { dictionary: t, locale } = useI18n();
const { data: profile, isLoading } = useMarriageProfileQuery({
refetchInterval: 3000,
import { cookies } from "next/headers";
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { isAuthenticatedToken } from "@/lib/entry-route-cache";
import { fetchProfileSSR } from "@/lib/ssr-fetch";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import RequestSentClient from "./request-sent-client";
export const dynamic = "force-dynamic";
export default async function RequestSentPage() {
const cookieStore = await cookies();
const token =
cookieStore.get("HABIB_TOKEN")?.value ??
cookieStore.get("habib_token")?.value;
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 10 * 1000 },
},
}); });
useEffect(() => {
if (!profile) {
return;
}
const targetPath = getSubmitPath(profile);
if (targetPath !== "/request-sent") {
router.replace(localizePath(targetPath, locale));
}
}, [profile, locale, router]);
const isRedirecting = useMemo(() => {
if (!profile) return false;
return getSubmitPath(profile) !== "/request-sent";
}, [profile]);
if (isLoading || isRedirecting) {
return <PageLoadingSkeleton />;
if (isAuthenticatedToken(token)) {
await queryClient.prefetchQuery({
queryKey: marriageQueryKeys.profile(),
queryFn: () => fetchProfileSSR(token!),
});
} }
const copy = {
advisorTitle: t["Get an advisor"],
advisorDescription:
t[
"Not sure what to do next? Our psychology section is here to guide you at every step."
],
getAdvisor: t["Get Advisor"],
};
const requestSentCopy = {
title: t["Request Sent"],
description:
t[
"Your request has been sent. Once the lady reviews your request, you will be notified."
],
matchProfile: t["View More Details"],
profileLocked: t["Profile is locked"],
};
return ( return (
<>
<PageBackground />
<main
style={{ paddingBottom: "calc(40px + var(--safe-bottom))" }}
className="-mx-[17px] flex min-h-screen flex-col px-[23px] pt-[max(12px,calc(var(--safe-top)+4px))] text-center"
>
<PageHeader className="-mx-[6px]" />
<div className="flex flex-1 flex-col justify-between gap-20 pt-[109px]">
<section className="flex flex-col items-center">
<div className="relative isolate flex items-center justify-center">
<Image
src="/assets/images/Group 15978804fdasf68.svg"
alt="Request sent"
width={131}
height={125}
priority
className="relative z-10"
/>
</div>
<h1 className="mt-11 group-16 leading-none font-black tracking-[0.02em] text-[#171717] uppercase">
{requestSentCopy.title}
</h1>
<p className="mt-4 max-w-[315px] group-12 leading-[1.45] font-semibold text-[#777777]">
{requestSentCopy.description}
</p>
<Link
href={localizePath("/new-match/profile", locale)}
className="mt-9 w-full max-w-[212px]"
>
<div className="bg-[#F5F5F5] px-4 py-2 rounded-[15px] shadow group-14 text-center font-semibold text-[#36363C]">
{requestSentCopy.matchProfile}
</div>
</Link>
</section>
<div className="space-y-8 pb-20">
<AdvisorActionsCard
title={copy.advisorTitle}
description={copy.advisorDescription}
avatars={advisorAvatars}
extraCount={7}
getAdvisorLabel={copy.getAdvisor}
getAdvisorHref="/marriage-advisors"
/>
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]">
<div
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }}
className="pointer-events-auto px-[17px] pt-3"
>
<div className="flex items-center justify-center w-full gap-1 rounded-[11px] bg-[#DBDBDB] py-3.5">
<Image
src={"/assets/images/material-symbols_lock.svg"}
width={24}
height={24}
alt="lock"
/>
<h2 className="group-14 leading-none font-semibold text-[#747474]">
{requestSentCopy.profileLocked}
</h2>
</div>
</div>
</section>
</div>
</div>
</main>
</>
<HydrationBoundary state={dehydrate(queryClient)}>
<RequestSentClient />
</HydrationBoundary>
); );
} }

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

@ -0,0 +1,165 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import MarriageAdvisorsOverlay, {
useMarriageAdvisorsOverlay,
} from "@/components/Componentes/marriage-advisors-overlay";
import MatchProfileOverlay, {
useMatchProfileOverlay,
} from "@/components/Componentes/match-profile-overlay";
import PageHeader from "@/components/Componentes/page-header";
import { PageBackground } from "@/components/Componentes/page-background";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
const advisorAvatars = [
{ id: "advisor-primary", src: "/assets/images/Avatar Image.png" },
{ id: "advisor-secondary", src: "/assets/images/Ellipse 370.png" },
{ id: "advisor-tertiary", src: "/assets/images/Avatar Image.png" },
];
export default function RequestSentClient() {
const router = useRouter();
const { dictionary: t, locale } = useI18n();
const { isAdvisorOpen, openAdvisors, closeAdvisors } =
useMarriageAdvisorsOverlay();
const { isProfileOpen, openProfile, closeProfile } =
useMatchProfileOverlay();
const { data: profile, isLoading } = useMarriageProfileQuery({
refetchInterval: 3000,
});
useEffect(() => {
if (!profile) {
return;
}
const targetPath = getSubmitPath(profile);
if (targetPath !== "/request-sent") {
router.replace(localizePath(targetPath, locale));
}
}, [profile, locale, router]);
// Signal Flutter to lift its loading cover once the profile is available.
useHabibWebReady(!!profile && !isLoading);
const isRedirecting = useMemo(() => {
if (!profile) return false;
return getSubmitPath(profile) !== "/request-sent";
}, [profile]);
if (isLoading || isRedirecting) {
return <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}
/>
</>
);
}

5
src/app/terms/page.tsx

@ -7,6 +7,7 @@ import {
getSubmitPath, getSubmitPath,
hasCompletedMarriageProfileBasics, hasCompletedMarriageProfileBasics,
} from "@/lib/get-submit-path"; } from "@/lib/get-submit-path";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import SliderPage from "@/components/Componentes/slider-page"; import SliderPage from "@/components/Componentes/slider-page";
import Button from "@/components/Componentes/button"; import Button from "@/components/Componentes/button";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
@ -17,6 +18,10 @@ export default function TermsRoute() {
const router = useRouter(); const router = useRouter();
const { locale, dictionary: t } = useI18n(); const { locale, dictionary: t } = useI18n();
// Signal Flutter when profile data is available (or failed — the page
// has its own loading/error UI so the cover can safely be removed).
useHabibWebReady(!isLoading);
useEffect(() => { useEffect(() => {
if (!isLoading && profile) { if (!isLoading && profile) {
if ( if (

7
src/components/Componentes/advisor-actions-card.tsx

@ -18,7 +18,8 @@ type AdvisorActionsCardProps = {
avatars: AdvisorAvatar[]; avatars: AdvisorAvatar[];
extraCount: number; extraCount: number;
getAdvisorLabel: string; getAdvisorLabel: string;
getAdvisorHref: string;
getAdvisorHref?: string;
onGetAdvisor?: () => void;
className?: string; className?: string;
}; };
@ -29,6 +30,7 @@ export function AdvisorActionsCard({
extraCount: fallbackExtraCount, extraCount: fallbackExtraCount,
getAdvisorLabel, getAdvisorLabel,
getAdvisorHref, getAdvisorHref,
onGetAdvisor,
className, className,
}: AdvisorActionsCardProps) { }: AdvisorActionsCardProps) {
const { data, isLoading } = useMarriageAdvisorsQuery(); const { data, isLoading } = useMarriageAdvisorsQuery();
@ -96,7 +98,8 @@ export function AdvisorActionsCard({
<Button <Button
className="w-auto rounded-[9px] border-none bg-[#EBEDF0] bg-none px-5 py-[13px] text-[#111111]! shadow-none" className="w-auto rounded-[9px] border-none bg-[#EBEDF0] bg-none px-5 py-[13px] text-[#111111]! shadow-none"
href={getAdvisorHref}
href={onGetAdvisor ? undefined : getAdvisorHref}
onClick={onGetAdvisor}
> >
{getAdvisorLabel} {getAdvisorLabel}
</Button> </Button>

77
src/components/Componentes/auth-data-boundary.test.tsx

@ -0,0 +1,77 @@
import { render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi, beforeEach } from "vitest";
import AuthDataBoundary from "./auth-data-boundary";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import { HABIB_AUTH_TOKEN_CHANGED_EVENT } from "@/lib/auth-bridge";
describe("AuthDataBoundary", () => {
let queryClient: QueryClient;
beforeEach(() => {
window.localStorage.clear();
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
});
it("purges user-specific caches on habib:auth-token-changed while preserving config", async () => {
// Populate query cache with user data + config
queryClient.setQueryData(marriageQueryKeys.profile(), { id: 100, can_edit_profile: true });
queryClient.setQueryData(marriageQueryKeys.formOverview("profile", "en"), { form_id: "profile", sections: [] });
queryClient.setQueryData(marriageQueryKeys.formSection("profile", "job", "en"), { form_id: "profile", section: { id: "job" } });
queryClient.setQueryData(marriageQueryKeys.cattellQuestions("en"), { questions: [] });
queryClient.setQueryData(marriageQueryKeys.glasserQuestions("en"), { questions: [] });
queryClient.setQueryData(marriageQueryKeys.config(), { min_age: 18 });
render(
<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();
});
});

99
src/components/Componentes/auth-data-boundary.tsx

@ -0,0 +1,99 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, type ReactNode } from "react";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import { HABIB_AUTH_TOKEN_CHANGED_EVENT } from "@/lib/auth-bridge";
import {
isLegacyMigrationDone,
migrateLegacyStorageKeys,
} from "@/lib/user-scoped-storage";
/**
* Central auth-data boundary that resets user-specific React Query caches
* and localStorage drafts when the authenticated identity changes.
*
* Mount this inside (or near) the QueryClientProvider.
*
* On `habib:auth-token-changed`:
* 1. Cancels all in-flight user queries (profile, sections, assessments)
* 2. Removes previous-user data from the query cache
* 3. Keeps config/public queries intact
*
* This prevents Account A's cached data from being served to Account B
* after a token switch in the same WebView.
*/
export default function AuthDataBoundary({
children,
}: {
children: ReactNode;
}) {
const queryClient = useQueryClient();
const queryClientRef = useRef(queryClient);
queryClientRef.current = queryClient;
// Run V3 legacy migration once on mount
useEffect(() => {
if (typeof window === "undefined") return;
if (!isLegacyMigrationDone()) {
migrateLegacyStorageKeys();
}
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
const handleAuthChange = () => {
const qc = queryClientRef.current;
// 1. Cancel all in-flight user-specific queries
void qc.cancelQueries({ queryKey: marriageQueryKeys.profile() });
void qc.cancelQueries({ queryKey: marriageQueryKeys.sections() });
void qc.cancelQueries({
queryKey: marriageQueryKeys.all,
predicate: (query) => {
const key = query.queryKey;
// Preserve config queries — they're user-independent
if (
Array.isArray(key) &&
key[0] === "marriage" &&
key[1] === "config"
) {
return false;
}
return true;
},
});
// 2. Remove all user-owned query data from cache
// Remove profile
qc.removeQueries({ queryKey: marriageQueryKeys.profile() });
// Remove form overview + sections
qc.removeQueries({
queryKey: marriageQueryKeys.all,
predicate: (query) => {
const key = query.queryKey;
if (!Array.isArray(key) || key[0] !== "marriage") return false;
const kind = key[1];
// Remove: profile, form-overview, form-section, sections,
// cattell, glasser, advisors, cases (contact-info)
// Keep: config
return kind !== "config";
},
});
};
window.addEventListener(HABIB_AUTH_TOKEN_CHANGED_EVENT, handleAuthChange);
return () => {
window.removeEventListener(
HABIB_AUTH_TOKEN_CHANGED_EVENT,
handleAuthChange,
);
};
}, []);
return <>{children}</>;
}

25
src/components/Componentes/data-error-state.tsx

@ -11,6 +11,8 @@ type DataErrorStateProps = {
onRetry?: () => void; onRetry?: () => void;
/** Optional className for the outermost wrapper */ /** Optional className for the outermost wrapper */
className?: string; className?: string;
/** Render full-screen fixed overlay covering any headers/footers (default: true) */
fullScreen?: boolean;
}; };
/** /**
@ -30,6 +32,7 @@ export default function DataErrorState({
message, message,
onRetry, onRetry,
className = "", className = "",
fullScreen = true,
}: DataErrorStateProps) { }: DataErrorStateProps) {
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
@ -43,20 +46,22 @@ export default function DataErrorState({
"Something went wrong. Please check your internet connection and try again."; "Something went wrong. Please check your internet connection and try again.";
const retryLabel = t["Try again"] ?? "Try again"; const retryLabel = t["Try again"] ?? "Try again";
const containerClasses = fullScreen
? `fixed inset-0 z-[9999] flex h-[100dvh] w-full flex-col items-center justify-center bg-[#F5F5F5] px-6 text-center ${className}`
: `flex min-h-[60svh] w-full flex-col items-center justify-center gap-0 px-6 text-center ${className}`;
return ( return (
<div
className={`flex min-h-[60svh] flex-col items-center justify-center gap-0 px-6 text-center ${className}`}
>
<div className={containerClasses}>
{/* ── Disconnected-plug illustration (inline SVG — no network request) ── */} {/* ── Disconnected-plug illustration (inline SVG — no network request) ── */}
<DisconnectedIllustration /> <DisconnectedIllustration />
{/* ── Title ── */}
<h2 className="mt-8 text-[18px] font-bold leading-snug text-[#1B1B1B]">
{/* ── Title (matching Flutter fontSize: 22, normal weight, #1B1B1B) ── */}
<h2 className="mt-10 text-[22px] font-normal leading-snug text-[#1B1B1B]">
{displayTitle} {displayTitle}
</h2> </h2>
{/* ── Subtitle / message ── */}
<p className="mt-2 max-w-[280px] text-[13px] font-semibold leading-[1.55] text-[#8B8B8B]">
{/* ── Subtitle / message (matching Flutter fontSize: 14, fontWeight: 600, #8B8B8B) ── */}
<p className="mt-2 max-w-[320px] text-[14px] font-semibold leading-[1.4] text-[#8B8B8B]">
{displayMessage} {displayMessage}
</p> </p>
@ -65,11 +70,11 @@ export default function DataErrorState({
<button <button
type="button" type="button"
onClick={onRetry} onClick={onRetry}
className="mt-6 flex items-center gap-2 rounded-[15px] bg-white px-5 py-2.5 text-[13px] font-semibold text-[#202227] shadow-[0_4px_16px_rgba(0,0,0,0.08)] transition-all duration-200 active:scale-95 hover:shadow-[0_6px_20px_rgba(0,0,0,0.12)]"
className="mt-6 inline-flex items-center gap-2 rounded-[15px] bg-white px-5 py-2.5 text-[14px] font-semibold text-[#202227] shadow-[0_4px_16px_rgba(0,0,0,0.08)] transition-all duration-200 active:scale-95 hover:shadow-[0_6px_20px_rgba(0,0,0,0.12)] cursor-pointer"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
className="size-4"
className="size-4.5"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@ -80,7 +85,7 @@ export default function DataErrorState({
<path d="M21 12a9 9 0 1 1-2.64-6.36" /> <path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 4v5h-5" /> <path d="M21 4v5h-5" />
</svg> </svg>
{retryLabel}
<span>{retryLabel}</span>
</button> </button>
)} )}
</div> </div>

98
src/components/Componentes/dev-tap-instrumentation.tsx

@ -0,0 +1,98 @@
"use client";
import { useEffect } from "react";
/**
* Development-only capture-phase instrumentation for debugging section-card
* and question flow tap/focus responsiveness. Records pointerdown pointerup click,
* touchstart touchmove touchend, and focusin/focusout lifecycle events.
*
* Mount this inside the questions-list and question detail flows during development.
* Automatically inactive outside of development.
*/
export default function DevTapInstrumentation() {
useEffect(() => {
if (process.env.NODE_ENV !== "development") return;
const events = [
"pointerdown",
"pointerup",
"pointercancel",
"click",
"touchstart",
"touchmove",
"touchend",
"touchcancel",
"focusin",
"focusout",
] as const;
const startTime = performance.now();
let sequenceId = 0;
let touchStartY: number | null = null;
const handler = (event: Event) => {
const target = event.target as HTMLElement | null;
const anchor = target?.closest?.("a");
const elapsed = (performance.now() - startTime).toFixed(1);
sequenceId += 1;
let deltaY: number | null = null;
if (event.type === "touchstart") {
touchStartY = (event as TouchEvent).touches?.[0]?.clientY ?? null;
} else if (event.type === "touchmove" || event.type === "touchend") {
const currentY =
(event as TouchEvent).touches?.[0]?.clientY ??
(event as TouchEvent).changedTouches?.[0]?.clientY ??
null;
if (touchStartY !== null && currentY !== null) {
deltaY = Math.round(touchStartY - currentY);
}
}
const isInteractive = Boolean(
target?.closest?.(
'input, textarea, select, button, label, a, [role="button"], [role="option"], [role="checkbox"], [role="radio"], [role="switch"], [role="combobox"], [role="listbox"], [contenteditable="true"]',
),
);
const color = event.type.startsWith("focus")
? "#0EB13C"
: event.type.startsWith("touch")
? "#3B82F6"
: "#E03950";
console.debug(
`[tap-debug #${sequenceId}] %c${event.type}%c @ ${elapsed}ms`,
`color: ${color}; font-weight: bold`,
"color: inherit",
{
type: event.type,
target: target?.tagName,
targetId: target?.id,
targetClass: target?.className,
activeElement: document.activeElement?.tagName,
activeElementId: document.activeElement?.id,
isInteractive,
deltaY,
closestAnchorHref: anchor?.getAttribute("href") || null,
defaultPrevented: event.defaultPrevented,
pathname: window.location.pathname,
timestamp: performance.now(),
},
);
};
for (const eventName of events) {
document.addEventListener(eventName, handler, { capture: true });
}
return () => {
for (const eventName of events) {
document.removeEventListener(eventName, handler, { capture: true });
}
};
}, []);
return null;
}

218
src/components/Componentes/discount-widget.tsx

@ -0,0 +1,218 @@
"use client";
import { useState } from "react";
import { useI18n } from "@/translations/provider";
import {
type CheckDiscountResult,
useValidateDiscountMutation,
} from "@/hooks/marriage/use-validate-discount";
export interface DiscountWidgetProps {
objectId: number | string;
service?: string;
onDiscountApplied?: (result: CheckDiscountResult) => void;
onDiscountCleared?: () => void;
className?: string;
}
export function DiscountWidget({
objectId,
service = "marriagesubscriptionplan",
onDiscountApplied,
onDiscountCleared,
className = "",
}: DiscountWidgetProps) {
const { dictionary: t } = useI18n();
const [isExpanded, setIsExpanded] = useState(false);
const [code, setCode] = useState("");
const [discountStatus, setDiscountStatus] = useState<
"initial" | "empty" | "loading" | "success" | "failed"
>("initial");
const [discountResult, setDiscountResult] =
useState<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>
);
}

11
src/components/Componentes/entry-route-resolver.tsx

@ -19,6 +19,17 @@ type EntryRouteResolverProps = {
anonymousEntryVisible?: boolean; anonymousEntryVisible?: boolean;
}; };
/**
* Client-side fallback entry route resolver.
*
* Used when SSR profile fetch failed and the server couldn't determine the
* correct destination. Resolves the route client-side and navigates.
*
* IMPORTANT: This component must NEVER call __announceHabibWebReady().
* It renders null and navigates to a destination page the destination
* page owns visual readiness. Announcing ready here would expose a blank
* frame to the user before the destination renders.
*/
export default function EntryRouteResolver({ export default function EntryRouteResolver({
anonymousEntryVisible = false, anonymousEntryVisible = false,
}: EntryRouteResolverProps) { }: EntryRouteResolverProps) {

28
src/components/Componentes/hardware-back-bridge.tsx

@ -0,0 +1,28 @@
"use client";
import { useEffect } from "react";
import { handleHardwareBack } from "@/hooks/use-hardware-back-handler";
/**
* Wires the React hardware-back handler stack to the global
* window.__habibHandleHardwareBack function.
*
* Mount this once in the Providers tree (after React hydration).
* It replaces the bootstrap stub with the real handler that walks
* the registered stack.
*/
export function HardwareBackBridge() {
useEffect(() => {
window.__habibHandleHardwareBack = handleHardwareBack;
return () => {
// On unmount (shouldn't happen in practice), restore the stub.
window.__habibHandleHardwareBack = () =>
Promise.resolve({ handled: false });
};
}, []);
return null;
}
export default HardwareBackBridge;

31
src/components/Componentes/information-sheet.tsx

@ -5,6 +5,7 @@ import type { HTMLAttributes, ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import Button from "./button"; import Button from "./button";
import { LoadingSkeleton } from "./loading-skeleton"; import { LoadingSkeleton } from "./loading-skeleton";
import { LoadingThreeDot } from "./loading-three-dot";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -75,14 +76,14 @@ const ICON_PRESETS: Record<
coin: { coin: {
src: "/assets/images/Inner Plugdsain Iframe.svg", src: "/assets/images/Inner Plugdsain Iframe.svg",
alt: "Coin", alt: "Coin",
width: 50,
height: 50,
width: 56,
height: 56,
}, },
"coin.svg": { "coin.svg": {
src: "/assets/images/Inner Plugdsain Iframe.svg", src: "/assets/images/Inner Plugdsain Iframe.svg",
alt: "Coin", alt: "Coin",
width: 50,
height: 50,
width: 56,
height: 56,
}, },
check: { check: {
src: "/assets/images/Vectofdasr.svg", src: "/assets/images/Vectofdasr.svg",
@ -205,10 +206,8 @@ export function InformationSheet({
return ( return (
<div <div
className={[ className={[
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms] animate-in fade-in",
isClosing
? "bg-[#171717]/0 opacity-0"
: "bg-[#171717]/55 opacity-100",
"fixed inset-0 z-50 flex items-end justify-center overflow-y-auto transition-all duration-[220ms] animate-in fade-in",
isClosing ? "bg-[#171717]/0 opacity-0" : "bg-[#171717]/55 opacity-100",
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
@ -236,7 +235,7 @@ export function InformationSheet({
<section <section
{...props} {...props}
className={[ className={[
"w-full sm:max-w-[375px] rounded-t-[15px] bg-[#F9F8F8] p-3.5 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform animate-in slide-in-from-bottom",
"w-full sm:max-w-[375px] rounded-t-[22px] bg-[#F9F8F8] px-4 pt-4 pb-[max(24px,calc(24px+var(--safe-bottom)))] text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0", isClosing ? "translate-y-full" : "translate-y-0",
className, className,
] ]
@ -245,17 +244,9 @@ export function InformationSheet({
> >
<div className="mx-auto flex flex-col items-center w-full"> <div className="mx-auto flex flex-col items-center w-full">
{isLoading ? ( {isLoading ? (
<>
<LoadingSkeleton className="h-[50px] w-[50px] rounded-full" />
<LoadingSkeleton className="mt-3.5 h-[20px] w-[140px]" />
<div className="mt-3 flex flex-col items-center gap-2 w-full">
<LoadingSkeleton className="h-[12px] w-[220px]" />
<LoadingSkeleton className="h-[12px] w-[160px]" />
</div>
<div className="mt-4 flex w-full gap-3">
<LoadingSkeleton className="h-[52px] flex-1 rounded-[11px]" />
</div>
</>
<div className="flex h-[280px] min-h-[280px] w-full items-center justify-center">
<LoadingThreeDot className="text-[#FF4E67]" />
</div>
) : ( ) : (
<> <>
{resolvedIcon ? ( {resolvedIcon ? (

134
src/components/Componentes/marriage-advisors-overlay.test.tsx

@ -0,0 +1,134 @@
import { act, cleanup, render, renderHook, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/translations/provider";
import {
MarriageAdvisorsOverlay,
useMarriageAdvisorsOverlay,
} from "./marriage-advisors-overlay";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
}),
}));
vi.mock("@/hooks/marriage/use-marriage-advisors", () => ({
useMarriageAdvisorsQuery: () => ({
data: { results: [] },
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
}));
vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: () => ({
data: null,
isLoading: false,
refetch: vi.fn(),
}),
}));
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<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();
});
});

77
src/components/Componentes/marriage-advisors-overlay.tsx

@ -0,0 +1,77 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import MarriageAdvisorsPage from "@/app/marriage-advisors/page";
import SectionOverlayHost from "@/components/Componentes/section-overlay-host";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
export type MarriageAdvisorsOverlayProps = {
open: boolean;
onClose: () => void;
};
/**
* Hook to manage Marriage Advisors slide-in overlay state.
* Syncs with browser history (?advisors=open) and intercepts hardware back in Flutter.
*/
export function useMarriageAdvisorsOverlay() {
const [isAdvisorOpen, setIsAdvisorOpen] = useState(false);
useEffect(() => {
const readAdvisorsFromUrl = () => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
setIsAdvisorOpen(params.get("advisors") === "open");
};
readAdvisorsFromUrl();
window.addEventListener("popstate", readAdvisorsFromUrl);
return () => window.removeEventListener("popstate", readAdvisorsFromUrl);
}, []);
const openAdvisors = useCallback(() => {
setIsAdvisorOpen(true);
if (typeof window !== "undefined") {
const url = new URL(window.location.href);
url.searchParams.set("advisors", "open");
window.history.pushState({ advisors: "open" }, "", url.toString());
}
}, []);
const closeAdvisors = useCallback(() => {
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
if (params.get("advisors") === "open") {
setIsAdvisorOpen(false);
window.history.back();
return;
}
}
setIsAdvisorOpen(false);
}, []);
// Intercept hardware back in Flutter WebView when advisor overlay is open
useHardwareBackHandler(() => {
closeAdvisors();
return true; // handled: keep WebView screen open
}, isAdvisorOpen);
return {
isAdvisorOpen,
openAdvisors,
closeAdvisors,
};
}
export function MarriageAdvisorsOverlay({
open,
onClose,
}: MarriageAdvisorsOverlayProps) {
return (
<SectionOverlayHost open={open} onClose={onClose}>
<MarriageAdvisorsPage onClose={onClose} />
</SectionOverlayHost>
);
}
export default MarriageAdvisorsOverlay;

140
src/components/Componentes/match-profile-overlay.test.tsx

@ -0,0 +1,140 @@
import { act, cleanup, render, renderHook, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/translations/provider";
import {
MatchProfileOverlay,
useMatchProfileOverlay,
} from "./match-profile-overlay";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
}),
}));
vi.mock("@/hooks/marriage/use-profile-main", () => ({
useMarriageProfileQuery: () => ({
data: {
status: "matched",
gender: "female",
active_case: { case_id: "test-case-1", status: "introduced" },
match_summary: {
gender: "male",
public_info: [{ key: "full_name", label: "نام", value: "علی احمدی" }],
},
},
isLoading: false,
refetch: vi.fn(),
}),
}));
vi.mock("@/hooks/marriage/use-case-respond", () => ({
useRespondToMarriageCaseMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}));
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<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();
});
});

77
src/components/Componentes/match-profile-overlay.tsx

@ -0,0 +1,77 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import NewMatchProfilePage from "@/app/new-match/profile/page";
import SectionOverlayHost from "@/components/Componentes/section-overlay-host";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
export type MatchProfileOverlayProps = {
open: boolean;
onClose: () => void;
};
/**
* Hook to manage Match Profile ("More detail") slide-in overlay state.
* Syncs with browser history (?profile=open) and intercepts hardware back in Flutter.
*/
export function useMatchProfileOverlay() {
const [isProfileOpen, setIsProfileOpen] = useState(false);
useEffect(() => {
const readProfileFromUrl = () => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
setIsProfileOpen(params.get("profile") === "open");
};
readProfileFromUrl();
window.addEventListener("popstate", readProfileFromUrl);
return () => window.removeEventListener("popstate", readProfileFromUrl);
}, []);
const openProfile = useCallback(() => {
setIsProfileOpen(true);
if (typeof window !== "undefined") {
const url = new URL(window.location.href);
url.searchParams.set("profile", "open");
window.history.pushState({ profile: "open" }, "", url.toString());
}
}, []);
const closeProfile = useCallback(() => {
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
if (params.get("profile") === "open") {
setIsProfileOpen(false);
window.history.back();
return;
}
}
setIsProfileOpen(false);
}, []);
// Intercept hardware back in Flutter WebView when profile overlay is open
useHardwareBackHandler(() => {
closeProfile();
return true; // handled: keep WebView screen open
}, isProfileOpen);
return {
isProfileOpen,
openProfile,
closeProfile,
};
}
export function MatchProfileOverlay({
open,
onClose,
}: MatchProfileOverlayProps) {
return (
<SectionOverlayHost open={open} onClose={onClose}>
<NewMatchProfilePage onClose={onClose} />
</SectionOverlayHost>
);
}
export default MatchProfileOverlay;

260
src/components/Componentes/navigation-button.tsx

@ -1,24 +1,14 @@
"use client"; "use client";
import { useQueryClient } from "@tanstack/react-query";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { ButtonHTMLAttributes, ReactNode } from "react"; import type { ButtonHTMLAttributes, ReactNode } from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { GoArrowLeft } from "react-icons/go"; import { GoArrowLeft } from "react-icons/go";
import { HiEllipsisVertical } from "react-icons/hi2"; import { HiEllipsisVertical } from "react-icons/hi2";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import {
extractHabcoinPaymentUrl,
useHabcoinPaymentMutation,
} from "@/hooks/marriage/use-habcoin-payment";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import {
buyHabibCoinPackages,
isInFlutterWebView,
} from "@/lib/webview-actions";
import { isInFlutterWebView } from "@/lib/webview-actions";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import ErrorToast from "./error-toast";
import HelpModal from "./help-modal"; import HelpModal from "./help-modal";
import InformationSheet from "./information-sheet"; import InformationSheet from "./information-sheet";
import { hasSupportAccess } from "./support-access"; import { hasSupportAccess } from "./support-access";
@ -47,6 +37,8 @@ export type NavigationButtonProps = Omit<
helpDescription?: ReactNode; helpDescription?: ReactNode;
helpButtonText?: ReactNode; helpButtonText?: ReactNode;
disableHelpModal?: boolean; disableHelpModal?: boolean;
badge?: ReactNode;
profile?: any;
}; };
export function NavigationButton({ export function NavigationButton({
@ -59,29 +51,52 @@ export function NavigationButton({
helpDescription, helpDescription,
helpButtonText, helpButtonText,
disableHelpModal = false, disableHelpModal = false,
badge,
profile: profileProp,
...props ...props
}: NavigationButtonProps) { }: NavigationButtonProps) {
const router = useRouter(); const router = useRouter();
const { dictionary: t } = useI18n();
const { dictionary: t, locale } = useI18n();
const isRtl = locale === "fa" || locale === "ar" || locale === "ur";
const [isHelpOpen, setIsHelpOpen] = useState(false); const [isHelpOpen, setIsHelpOpen] = useState(false);
const [isSupportOpen, setIsSupportOpen] = useState(false); const [isSupportOpen, setIsSupportOpen] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isSubscriptionInfoOpen, setIsSubscriptionInfoOpen] = useState(false); const [isSubscriptionInfoOpen, setIsSubscriptionInfoOpen] = useState(false);
const [isSubscriptionLoading, setIsSubscriptionLoading] = useState(false); const [isSubscriptionLoading, setIsSubscriptionLoading] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const { data: profile, refetch } = useMarriageProfileQuery();
const paymentMutation = useHabcoinPaymentMutation();
const queryClient = useQueryClient();
const [toastMessage, setToastMessage] = useState<string | null>(null);
const [toastVariant, setToastVariant] = useState<"error" | "success">(
"error",
);
const [isRenewing, setIsRenewing] = useState(false);
const [isInsufficientCoins, setIsInsufficientCoins] = useState(false);
const needsProfile =
!profileProp &&
(icon === "support" ||
icon === "subscription" ||
icon === "more" ||
icon === "consultation");
const { data: queriedProfile, refetch: refetchQuery } =
useMarriageProfileQuery({
enabled: needsProfile,
});
const profile = profileProp !== undefined ? profileProp : queriedProfile;
const refetch = refetchQuery;
const isFemale = profile?.gender === "female";
const hasActiveSubscription =
!!profile?.active_subscription &&
profile.active_subscription.is_active !== false &&
profile.active_subscription.is_valid !== false;
const handleOpenSubscriptionModal = () => {
setIsSubscriptionInfoOpen(true);
if (!profile) {
setIsSubscriptionLoading(true);
refetch().finally(() => {
setIsSubscriptionLoading(false);
});
}
};
const renderSubscriptionModal = () => { const renderSubscriptionModal = () => {
if (!isSubscriptionInfoOpen) return null; if (!isSubscriptionInfoOpen) return null;
if (isSubscriptionLoading) {
if (isSubscriptionLoading && !profile) {
return ( return (
<InformationSheet <InformationSheet
isLoading={true} isLoading={true}
@ -89,149 +104,59 @@ export function NavigationButton({
/> />
); );
} }
return hasActiveSubscription ? (
<>
<InformationSheet
icon="/assets/images/diamond-color.png"
title={t["Subscription Status"]}
description={t["{days} days remaining of your subscription."].replace(
"{days}",
String(profile?.active_subscription?.remaining_days ?? 0),
)}
onClose={() => setIsSubscriptionInfoOpen(false)}
buttons={({ close }) => (
<div className="space-y-3">
{isInsufficientCoins && isInFlutterWebView() && (
<button
type="button"
className="appearance-none border-0 bg-transparent p-0 text-left w-full"
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"
className="appearance-none border-0 bg-transparent p-0 text-left min-w-0"
onClick={close}
>
<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">
<span className="truncate">{t["Back"]}</span>
</div>
</button>
const sub = profile?.active_subscription;
const planType = sub?.plan?.plan_type || "time_based";
const remainingDays = sub?.remaining_days ?? 0;
const usageLimit = sub?.plan?.usage_limit ?? 0;
const totalUsages = sub?.total_usages ?? 0;
const remainingUsages = Math.max(0, usageLimit - totalUsages);
<button
type="button"
disabled={!profile?.recommended_plan?.id || isRenewing}
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
onClick={async () => {
const planId = profile?.recommended_plan?.id;
if (!planId) return;
setIsRenewing(true);
setToastMessage(null);
setToastVariant("error");
setIsInsufficientCoins(false);
try {
const paymentResponse =
await paymentMutation.mutateAsync(planId);
const paymentUrl =
extractHabcoinPaymentUrl(paymentResponse);
if (paymentUrl) {
window.location.assign(paymentUrl);
return;
}
// Success! Refetch profile query to update remaining_days in modal.
await queryClient.refetchQueries({
queryKey: marriageQueryKeys.profile(),
});
// Close the modal and show success toast
setIsSubscriptionInfoOpen(false);
setToastVariant("success");
setToastMessage(
t["Payment successful"] || "Payment successful",
);
} catch (err: any) {
console.error("Renewal payment failed", err);
const errMessage =
err?.response?.data?.error || err?.message || "";
setToastVariant("error");
if (
errMessage.includes("Not enough coins") ||
err?.response?.status === 400
) {
setIsInsufficientCoins(true);
setToastMessage(
t[
"Insufficient coin balance. Please recharge your account."
] ||
"Insufficient coin balance. Please recharge your account.",
);
} else {
setToastMessage(errMessage || "Payment failed");
}
} finally {
setIsRenewing(false);
}
}}
>
<div className="inline-flex w-full items-center justify-center gap-2 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">
<span className="truncate">
{isRenewing ? t["Renewing..."] : t["Renew Subscription"]}
</span>
let subscriptionDescription = "";
if (planType === "count_based") {
subscriptionDescription = (
t["{count} case introductions remaining of your subscription."] ||
"{count} case introductions remaining of your subscription."
).replace("{count}", String(remainingUsages));
} else if (planType === "combined") {
subscriptionDescription = (
t[
"{days} days and {count} case introductions remaining of your subscription."
] ||
"{days} days and {count} case introductions remaining of your subscription."
)
.replace("{days}", String(remainingDays))
.replace("{count}", String(remainingUsages));
} else {
subscriptionDescription = (
t["{days} days remaining of your subscription."] ||
"{days} days remaining of your subscription."
).replace("{days}", String(remainingDays));
}
{!isRenewing && (
<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">
{profile?.recommended_plan?.price || "100"}
</span>
</span>
)}
</div>
</button>
</div>
</div>
)}
/>
</>
return hasActiveSubscription ? (
<InformationSheet
icon="/assets/images/diamond-color.png"
title={t["Subscription Status"] || "Subscription Status"}
description={subscriptionDescription}
buttons={t["Got it"] || t["Back"] || "Back"}
onClose={() => setIsSubscriptionInfoOpen(false)}
/>
) : ( ) : (
<InformationSheet <InformationSheet
icon="/assets/images/icon-park-outline_diamond.svg" icon="/assets/images/icon-park-outline_diamond.svg"
title={t["No Active Subscription"]}
title={t["No Active Subscription"] || "No Active Subscription"}
description={ description={
t[ t[
"You currently do not have an active subscription. Activation of subscription is only possible when your first case is introduced to you." "You currently do not have an active subscription. Activation of subscription is only possible when your first case is introduced to you."
]
] ||
"You currently do not have an active subscription. Activation of subscription is only possible when your first case is introduced to you."
} }
buttons={t["Got it"]}
buttons={t["Got it"] || t["Back"] || "Back"}
onClose={() => setIsSubscriptionInfoOpen(false)} onClose={() => setIsSubscriptionInfoOpen(false)}
/> />
); );
}; };
const isFemale = profile?.gender === "female";
const hasActiveSubscription = !!profile?.active_subscription;
useEffect(() => { useEffect(() => {
if (!isDropdownOpen) return; if (!isDropdownOpen) return;
const handleOutsideClick = (event: MouseEvent) => { const handleOutsideClick = (event: MouseEvent) => {
@ -279,9 +204,10 @@ export function NavigationButton({
src="/assets/images/diamond-color.png" src="/assets/images/diamond-color.png"
alt="" alt=""
aria-hidden="true" aria-hidden="true"
className="size-6"
className="size-6 object-contain"
width={24} width={24}
height={24} height={24}
unoptimized
/> />
) : ( ) : (
<UiIcon name="diamond" aria-hidden="true" className="size-6" /> <UiIcon name="diamond" aria-hidden="true" className="size-6" />
@ -361,11 +287,7 @@ export function NavigationButton({
type="button" type="button"
onClick={() => { onClick={() => {
setIsDropdownOpen(false); setIsDropdownOpen(false);
setIsSubscriptionInfoOpen(true);
setIsSubscriptionLoading(true);
refetch().finally(() => {
setIsSubscriptionLoading(false);
});
handleOpenSubscriptionModal();
}} }}
className="flex w-full items-center gap-3 px-4 py-3 text-start text-sm font-semibold text-gray-700 hover:bg-gray-50 cursor-pointer border-0 bg-transparent" className="flex w-full items-center gap-3 px-4 py-3 text-start text-sm font-semibold text-gray-700 hover:bg-gray-50 cursor-pointer border-0 bg-transparent"
> >
@ -388,14 +310,6 @@ export function NavigationButton({
/> />
{renderSubscriptionModal()} {renderSubscriptionModal()}
{toastMessage && (
<ErrorToast
message={toastMessage}
variant={toastVariant}
onClose={() => setToastMessage(null)}
/>
)}
</div> </div>
); );
} }
@ -433,11 +347,7 @@ export function NavigationButton({
} else if (icon === "support") { } else if (icon === "support") {
setIsSupportOpen(true); setIsSupportOpen(true);
} else if (icon === "subscription") { } else if (icon === "subscription") {
setIsSubscriptionInfoOpen(true);
setIsSubscriptionLoading(true);
refetch().finally(() => {
setIsSubscriptionLoading(false);
});
handleOpenSubscriptionModal();
} }
} }
} }
@ -469,14 +379,6 @@ export function NavigationButton({
/> />
{renderSubscriptionModal()} {renderSubscriptionModal()}
{toastMessage && (
<ErrorToast
message={toastMessage}
variant={toastVariant}
onClose={() => setToastMessage(null)}
/>
)}
</> </>
); );
} }

76
src/components/Componentes/page-header.tsx

@ -14,29 +14,45 @@ type PageHeaderProps = {
leftButton?: NavigationButtonProps; leftButton?: NavigationButtonProps;
/** Props for the right button. Defaults to icon="support" with support label. */ /** Props for the right button. Defaults to icon="support" with support label. */
rightButton?: NavigationButtonProps; rightButton?: NavigationButtonProps;
/**
* When false, skip the profile query entirely. Useful on pages where the
* right button has an explicit onClick and no icon resolution is needed
* (e.g. Intro's "support" icon that opens a report sheet).
* Defaults to true.
*/
enableProfileQuery?: boolean;
profile?: any;
}; };
export function PageHeader({ export function PageHeader({
className, className,
leftButton, leftButton,
rightButton, rightButton,
enableProfileQuery = true,
profile: profileProp,
}: PageHeaderProps) { }: PageHeaderProps) {
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const { data: profile } = useMarriageProfileQuery();
// Only fetch profile when needed for icon resolution (subscription/support
// visibility depends on profile data). Pages that pass an explicit onClick
// for the right button can set enableProfileQuery=false to avoid an
// unnecessary API call (e.g. anonymous Intro).
const iconFromProp = rightButton?.icon; const iconFromProp = rightButton?.icon;
const isDefaultOrSubscription =
iconFromProp === undefined || iconFromProp === "subscription";
const isCustomRightButton =
!!rightButton?.onClick ||
(iconFromProp !== undefined &&
iconFromProp !== "subscription" &&
iconFromProp !== "support");
let finalIcon: NavigationButtonProps["icon"] = iconFromProp || "support";
if (isDefaultOrSubscription) {
if (hasSupportAccess(profile)) {
finalIcon = "support";
} else if (profile?.gender === "male") {
finalIcon = "subscription";
}
}
const needsProfile =
enableProfileQuery && !rightButton?.onClick && !profileProp;
const { data: queriedProfile } = useMarriageProfileQuery({
enabled: needsProfile,
});
const profile = profileProp !== undefined ? profileProp : queriedProfile;
const isMale = profile?.gender === "male";
const { icon: rightButtonIcon, ...rightButtonRest } = rightButton || {}; const { icon: rightButtonIcon, ...rightButtonRest } = rightButton || {};
return ( return (
@ -48,18 +64,44 @@ export function PageHeader({
{leftButton?.className?.includes("hidden") ? ( {leftButton?.className?.includes("hidden") ? (
<div className="size-10 shrink-0" /> <div className="size-10 shrink-0" />
) : ( ) : (
<NavigationButton icon="back" {...leftButton} />
<NavigationButton icon="back" profile={profile} {...leftButton} />
)} )}
<h1 className="font-faminela text-[20px] font-normal text-foreground"> <h1 className="font-faminela text-[20px] font-normal text-foreground">
{t["Habib Marriage"]} {t["Habib Marriage"]}
</h1> </h1>
<NavigationButton
icon={finalIcon}
{...(finalIcon === "support" ? { iconLabel: t["Support"] } : {})}
{...rightButtonRest}
/>
{isCustomRightButton ? (
<NavigationButton
icon={iconFromProp || "support"}
profile={profile}
{...(iconFromProp === "support" ? { iconLabel: t["Support"] } : {})}
{...rightButtonRest}
/>
) : isMale ? (
<div className="flex items-center gap-2">
<NavigationButton
icon="subscription"
profile={profile}
iconLabel={t["Subscription Status"] || "Subscription"}
/>
<NavigationButton
icon="support"
profile={profile}
iconLabel={t["Support"]}
{...rightButtonRest}
/>
</div>
) : (
<NavigationButton
icon="support"
profile={profile}
iconLabel={t["Support"]}
{...rightButtonRest}
/>
)}
</header> </header>
); );
} }
export default PageHeader; export default PageHeader;

232
src/components/Componentes/question-answer-storage.tsx

@ -27,6 +27,13 @@ import {
} from "@/hooks/marriage/use-section-data"; } from "@/hooks/marriage/use-section-data";
import { getApiRequestUrl } from "@/lib/http"; import { getApiRequestUrl } from "@/lib/http";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import {
getScopedSectionDraftKey,
readScopedSectionDraft,
removeScopedSectionDraft,
writeScopedSectionDraft,
type ScopedPendingField,
} from "@/lib/user-scoped-storage";
const STORAGE_VERSION = 2; const STORAGE_VERSION = 2;
@ -318,7 +325,6 @@ export function QuestionAnswersProvider({
const storageKeyRef = useRef(storageKey); const storageKeyRef = useRef(storageKey);
const slugRef = useRef(slug); const slugRef = useRef(slug);
const backendFieldsRef = useRef<MarriageField[]>([]); const backendFieldsRef = useRef<MarriageField[]>([]);
const versionRef = useRef<number | undefined>(undefined);
const dirtyKeysRef = useRef(new Set<string>()); const dirtyKeysRef = useRef(new Set<string>());
const { data: profile } = useMarriageProfileQuery(); const { data: profile } = useMarriageProfileQuery();
@ -333,67 +339,117 @@ export function QuestionAnswersProvider({
slugRef.current = slug; slugRef.current = slug;
questionsRef.current = questions; questionsRef.current = questions;
backendFieldsRef.current = serverSectionData?.data || []; backendFieldsRef.current = serverSectionData?.data || [];
versionRef.current = serverSectionData?.version;
}, [ }, [
answers, answers,
hasPendingSync, hasPendingSync,
slug, slug,
questions, questions,
serverSectionData?.data, serverSectionData?.data,
serverSectionData?.version,
]); ]);
useEffect(() => { useEffect(() => {
storageKeyRef.current = storageKey; storageKeyRef.current = storageKey;
slugRef.current = slug; slugRef.current = slug;
const stored = readStoredAnswers(storageKey, slug);
let finalAnswers = stored.answers;
let finalPendingSync = stored.pendingSync;
dirtyKeysRef.current = new Set(stored.pendingKeys);
const profileId = profile?.id;
// ── NEW RECONCILIATION (V3): Server is canonical ──────────────
//
// displayAnswers = canonicalServerAnswers
// THEN overlay ONLY local fields explicitly listed as pending/dirty
// for the CURRENT user.
//
// A single pending question must never make every historical local
// answer override server data. A successful server response containing
// no answers must render an empty section unless the current user has
// genuine unsynced pending fields.
if (serverSectionData?.data) { if (serverSectionData?.data) {
// Step 1: Server is canonical
const serverAnswers = fieldsToAnswers(serverSectionData.data); const serverAnswers = fieldsToAnswers(serverSectionData.data);
if (stored.pendingSync && canEdit) {
// Merge: local answers override server answers for unsynced changes
finalAnswers = { ...serverAnswers, ...stored.answers };
} else {
// A section response can temporarily omit fields (most notably while
// the combined family section is being refreshed). Keep locally known
// fields that the response did not include, while letting explicit
// server values, including null/empty values, win for matching keys.
finalAnswers = { ...stored.answers, ...serverAnswers };
finalPendingSync = false;
let finalAnswers: QuestionAnswersByKey = { ...serverAnswers };
let finalPendingSync = false;
const nextDirtyKeys = new Set<string>();
// Step 2: If we have a valid profile, read the user-scoped draft
if (profileId && canEdit) {
const scopedDraft = readScopedSectionDraft(profileId, slug);
if (scopedDraft && Object.keys(scopedDraft.pending).length > 0) {
// Overlay ONLY the pending dirty fields from the current user
for (const [key, pendingField] of Object.entries(scopedDraft.pending)) {
finalAnswers[key] = {
key: pendingField.key,
label: pendingField.label,
type: pendingField.type,
value: pendingField.value,
option_id: pendingField.option_id ?? undefined,
private: pendingField.private,
} as MarriageField;
nextDirtyKeys.add(key);
}
finalPendingSync = true;
}
} }
}
answersRef.current = finalAnswers;
hasPendingSyncRef.current = finalPendingSync;
setAnswers((prev) => {
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(finalAnswers);
if (prevKeys.length === nextKeys.length) {
const isSame = prevKeys.every(
(k) => prev[k]?.value === finalAnswers[k]?.value,
);
if (isSame) return prev;
// Also check the old V2 storage for backward compatibility during
// the transition period. Any old V2 pending edits are treated as
// a one-time overlay but NOT migrated (ownership can't be proven).
// After this render they won't be re-read because the V2 key is
// cleaned up by the legacy migration in AuthDataBoundary.
if (nextDirtyKeys.size === 0) {
const legacyStored = readStoredAnswers(storageKey, slug);
if (legacyStored.pendingSync && legacyStored.pendingKeys.length > 0 && canEdit) {
for (const pendingKey of legacyStored.pendingKeys) {
const pendingField = legacyStored.answers[pendingKey];
if (pendingField) {
finalAnswers[pendingKey] = pendingField;
nextDirtyKeys.add(pendingKey);
}
}
if (nextDirtyKeys.size > 0) {
finalPendingSync = true;
// Migrate these to scoped storage if we have a profile
if (profileId) {
const pendingMap: Record<string, ScopedPendingField> = {};
for (const key of nextDirtyKeys) {
const field = finalAnswers[key];
if (field) {
pendingMap[key] = {
key: field.key,
label: field.label,
type: field.type,
value: field.value,
option_id: (field as any).option_id,
private: field.private,
};
}
}
writeScopedSectionDraft(profileId, slug, pendingMap);
}
// Remove old V2 key
try { window.localStorage.removeItem(storageKey); } catch {}
}
}
} }
return finalAnswers;
});
setHasPendingSync(finalPendingSync);
// Update localStorage to stay in sync
writeStoredAnswers(
getQuestionAnswersStorageKey(slug),
slug,
questions,
finalAnswers,
finalPendingSync,
serverSectionData?.data || undefined,
[...dirtyKeysRef.current],
);
}, [slug, storageKey, serverSectionData, questions, canEdit]);
dirtyKeysRef.current = nextDirtyKeys;
answersRef.current = finalAnswers;
hasPendingSyncRef.current = finalPendingSync;
setAnswers((prev) => {
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(finalAnswers);
if (prevKeys.length === nextKeys.length) {
const isSame = prevKeys.every(
(k) => prev[k]?.value === finalAnswers[k]?.value,
);
if (isSame) return prev;
}
return finalAnswers;
});
setHasPendingSync(finalPendingSync);
}
}, [slug, storageKey, serverSectionData, questions, canEdit, profile?.id]);
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null); const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@ -430,15 +486,26 @@ export function QuestionAnswersProvider({
hasPendingSyncRef.current = true; hasPendingSyncRef.current = true;
answersRevisionRef.current += 1; answersRevisionRef.current += 1;
dirtyKeysRef.current.add(field.key); dirtyKeysRef.current.add(field.key);
writeStoredAnswers(
storageKeyRef.current,
slugRef.current,
questionsRef.current,
nextAnswers,
true,
undefined,
[...dirtyKeysRef.current],
);
// Write only dirty fields to profile-scoped localStorage
const currentProfileId = profile?.id;
if (currentProfileId) {
const pendingMap: Record<string, ScopedPendingField> = {};
for (const dirtyKey of dirtyKeysRef.current) {
const dirtyField = nextAnswers[dirtyKey];
if (dirtyField) {
pendingMap[dirtyKey] = {
key: dirtyField.key,
label: dirtyField.label,
type: dirtyField.type,
value: dirtyField.value,
option_id: (dirtyField as any).option_id,
private: dirtyField.private,
};
}
}
writeScopedSectionDraft(currentProfileId, slugRef.current, pendingMap);
}
if (syncTimeoutRef.current !== null) { if (syncTimeoutRef.current !== null) {
clearTimeout(syncTimeoutRef.current); clearTimeout(syncTimeoutRef.current);
@ -493,7 +560,6 @@ export function QuestionAnswersProvider({
const payload = { const payload = {
...fullPayload, ...fullPayload,
fields: pendingFields, fields: pendingFields,
version: versionRef.current,
}; };
const revision = answersRevisionRef.current; const revision = answersRevisionRef.current;
@ -537,10 +603,6 @@ export function QuestionAnswersProvider({
} }
} }
payload.fields.forEach((field) => {
dirtyKeysRef.current.delete(field.key);
});
// Do not mark a newer edit as synced just because an older request // Do not mark a newer edit as synced just because an older request
// completed. A forced exit waits for and saves that newer revision too. // completed. A forced exit waits for and saves that newer revision too.
if (revision !== answersRevisionRef.current) { if (revision !== answersRevisionRef.current) {
@ -550,6 +612,10 @@ export function QuestionAnswersProvider({
return; return;
} }
payload.fields.forEach((field) => {
dirtyKeysRef.current.delete(field.key);
});
if (dirtyKeysRef.current.size > 0) { if (dirtyKeysRef.current.size > 0) {
await flushAnswersRef.current(); await flushAnswersRef.current();
return; return;
@ -557,17 +623,13 @@ export function QuestionAnswersProvider({
hasPendingSyncRef.current = false; hasPendingSyncRef.current = false;
setHasPendingSync(false); setHasPendingSync(false);
writeStoredAnswers(
storageKeyRef.current,
slugRef.current,
questionsRef.current,
answersRef.current,
false,
undefined,
[],
);
// All dirty fields acknowledged — remove the draft entirely
const currentProfileId = profile?.id;
if (currentProfileId) {
removeScopedSectionDraft(currentProfileId, slugRef.current);
}
}, },
[mutateAsync, canEdit, locale, queryClient],
[mutateAsync, canEdit, locale, queryClient, profile?.id],
); );
const flushAnswersRef = useRef(flushAnswers); const flushAnswersRef = useRef(flushAnswers);
@ -596,7 +658,7 @@ export function QuestionAnswersProvider({
const pendingFields = fullPayload.fields.filter((field) => const pendingFields = fullPayload.fields.filter((field) =>
dirtyKeysRef.current.has(field.key), dirtyKeysRef.current.has(field.key),
); );
const payload = { ...fullPayload, fields: pendingFields, version: versionRef.current };
const payload = { ...fullPayload, fields: pendingFields };
const revision = answersRevisionRef.current; const revision = answersRevisionRef.current;
if (payload.fields.length === 0) { if (payload.fields.length === 0) {
@ -621,7 +683,6 @@ export function QuestionAnswersProvider({
fetch(getKeepalivePatchUrl(slugRef.current), { fetch(getKeepalivePatchUrl(slugRef.current), {
body: JSON.stringify({ body: JSON.stringify({
answers: answersPayload, answers: answersPayload,
version: payload.version,
}), }),
credentials: "include", credentials: "include",
headers, headers,
@ -644,15 +705,30 @@ export function QuestionAnswersProvider({
const stillPending = dirtyKeysRef.current.size > 0; const stillPending = dirtyKeysRef.current.size > 0;
hasPendingSyncRef.current = stillPending; hasPendingSyncRef.current = stillPending;
setHasPendingSync(stillPending); setHasPendingSync(stillPending);
writeStoredAnswers(
storageKeyRef.current,
slugRef.current,
questionsRef.current,
answersRef.current,
stillPending,
undefined,
[...dirtyKeysRef.current],
);
// Update scoped draft: remove acknowledged fields or delete draft
const currentProfileId = profile?.id;
if (currentProfileId) {
if (stillPending) {
const remainingPending: Record<string, ScopedPendingField> = {};
for (const dirtyKey of dirtyKeysRef.current) {
const field = answersRef.current[dirtyKey];
if (field) {
remainingPending[dirtyKey] = {
key: field.key,
label: field.label,
type: field.type,
value: field.value,
option_id: (field as any).option_id,
private: field.private,
};
}
}
writeScopedSectionDraft(currentProfileId, slugRef.current, remainingPending);
} else {
removeScopedSectionDraft(currentProfileId, slugRef.current);
}
}
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: marriageQueryKeys.profile(), queryKey: marriageQueryKeys.profile(),

4
src/components/Componentes/question-birthplace.tsx

@ -9,6 +9,7 @@ import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
import { LoadingThreeDot } from "./loading-three-dot"; import { LoadingThreeDot } from "./loading-three-dot";
import { useSheetScrollLock } from "./use-sheet-scroll-lock"; import { useSheetScrollLock } from "./use-sheet-scroll-lock";
import { Input } from "@/components/ui/input";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -479,7 +480,7 @@ export function QuestionBirthplace({
{/* City Text Input */} {/* City Text Input */}
<div className="w-full"> <div className="w-full">
<input
<Input
ref={cityInputRef} ref={cityInputRef}
type="text" type="text"
data-no-auto-focus data-no-auto-focus
@ -487,7 +488,6 @@ export function QuestionBirthplace({
value={cityInput} value={cityInput}
onChange={handleCityChange} onChange={handleCityChange}
placeholder={cityPlaceholder} placeholder={cityPlaceholder}
className="h-[54px] w-full rounded-[16px] border border-[#D0D5DD] bg-white px-4.5 text-[15px] font-medium text-[#181818] placeholder:text-[#98A2B3] hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F] outline-none transition-all"
/> />
</div> </div>
</div> </div>

11
src/components/Componentes/question-card.tsx

@ -16,6 +16,7 @@ type QuestionCardProps = {
onInfoClick?: (item: QuestionListItem) => void; onInfoClick?: (item: QuestionListItem) => void;
onNearViewport?: (item: QuestionListItem) => void; onNearViewport?: (item: QuestionListItem) => void;
onPrefetch?: (item: QuestionListItem) => void; onPrefetch?: (item: QuestionListItem) => void;
onSelect?: (item: QuestionListItem) => void;
}; };
const RADIUS = 8; const RADIUS = 8;
@ -46,6 +47,7 @@ export function QuestionCard({
onInfoClick, onInfoClick,
onNearViewport, onNearViewport,
onPrefetch, onPrefetch,
onSelect,
}: QuestionCardProps) { }: QuestionCardProps) {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const hasProgress = typeof progress === "number" && Number.isFinite(progress); const hasProgress = typeof progress === "number" && Number.isFinite(progress);
@ -82,11 +84,18 @@ export function QuestionCard({
return ( return (
<Link <Link
href={localizePath(`/questions-list/${item.slug}`, locale)} href={localizePath(`/questions-list/${item.slug}`, locale)}
prefetch={true}
aria-label={t["Open {title}"].replace("{title}", item.title)} aria-label={t["Open {title}"].replace("{title}", item.title)}
className="block rounded-[20px] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#F26C85]" className="block rounded-[20px] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#F26C85]"
onClick={(e) => {
if (onSelect && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.button === 0) {
e.preventDefault();
onSelect(item);
}
}}
onFocus={() => onPrefetch?.(item)} onFocus={() => onPrefetch?.(item)}
onPointerDown={() => onPrefetch?.(item)}
onPointerEnter={() => onPrefetch?.(item)} onPointerEnter={() => onPrefetch?.(item)}
onPointerDown={() => onPrefetch?.(item)}
> >
<article <article
ref={cardRef} ref={cardRef}

35
src/components/Componentes/question-date-sheet.tsx

@ -11,6 +11,7 @@ import {
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { Button } from "./button";
import { useSheetScrollLock } from "./use-sheet-scroll-lock"; import { useSheetScrollLock } from "./use-sheet-scroll-lock";
const EXIT_ANIMATION_MS = 300; const EXIT_ANIMATION_MS = 300;
@ -156,12 +157,23 @@ export function QuestionDateSheet({
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const parts = value ? value.split("-") : [];
const [selectedYear, setSelectedYear] = useState(parts[0] || YEARS[0]);
const parts =
value && typeof value === "string" ? value.trim().split("-") : [];
const rawYear = parts.length === 3 ? parts[0] : "";
const rawMonth = parts.length === 3 ? parts[1]?.padStart(2, "0") : "";
const rawDay = parts.length === 3 ? parts[2]?.padStart(2, "0") : "";
const [selectedYear, setSelectedYear] = useState(
rawYear && YEARS.includes(rawYear) ? rawYear : YEARS[0],
);
const [selectedMonth, setSelectedMonth] = useState( const [selectedMonth, setSelectedMonth] = useState(
parts[1] || MONTH_VALUES[0],
rawMonth && MONTH_VALUES.includes(rawMonth) ? rawMonth : MONTH_VALUES[0],
);
const [selectedDay, setSelectedDay] = useState(
rawDay && Number.parseInt(rawDay, 10) >= 1 && Number.parseInt(rawDay, 10) <= 31
? rawDay
: "01",
); );
const [selectedDay, setSelectedDay] = useState(parts[2] || "01");
const dirtyRef = useRef(false); const dirtyRef = useRef(false);
const closeSheet = useCallback(() => { const closeSheet = useCallback(() => {
@ -338,6 +350,21 @@ export function QuestionDateSheet({
/> />
</div> </div>
</div> </div>
<div className="pt-5 pb-2">
<Button
onClick={() => {
const maxDay = daysInMonth(selectedYear, selectedMonth);
const dayNum = Math.min(Number.parseInt(selectedDay, 10), maxDay);
const formattedDay = String(dayNum).padStart(2, "0");
onApply(
`${selectedYear}-${selectedMonth.padStart(2, "0")}-${formattedDay}`,
);
closeSheet();
}}
>
{t.Confirm || (locale === "fa" ? "تأیید" : "Confirm")}
</Button>
</div>
</div> </div>
</section> </section>
</div>, </div>,

15
src/components/Componentes/question-date.test.tsx

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { QuestionDate } from "./question-date"; import { QuestionDate } from "./question-date";
@ -36,6 +36,7 @@ const question = {
describe("QuestionDate", () => { describe("QuestionDate", () => {
beforeEach(() => { beforeEach(() => {
cleanup();
answerValue = null; answerValue = null;
}); });
@ -57,4 +58,16 @@ describe("QuestionDate", () => {
const yearWheel = screen.getByLabelText("Year"); const yearWheel = screen.getByLabelText("Year");
expect(yearWheel.textContent).not.toMatch(/[,٬]/); expect(yearWheel.textContent).not.toMatch(/[,٬]/);
}); });
it("handles invalid date strings gracefully without throwing RangeError", () => {
answerValue = "Mortezaei";
const { rerender } = render(<QuestionDate question={question} />);
expect(screen.queryByText("Age")).toBeNull();
expect(screen.getByText("Select date")).toBeDefined();
answerValue = "invalid-date-format";
rerender(<QuestionDate question={question} />);
expect(screen.queryByText("Age")).toBeNull();
expect(screen.getByText("Select date")).toBeDefined();
});
}); });

66
src/components/Componentes/question-date.tsx

@ -19,11 +19,33 @@ function parseDateParts(dateValue: string): {
month: string; month: string;
day: string; day: string;
} { } {
const parts = dateValue ? dateValue.split("-") : [];
if (typeof dateValue !== "string") {
return { year: "", month: "", day: "" };
}
const parts = dateValue.trim().split("-");
if (parts.length !== 3) {
return { year: "", month: "", day: "" };
}
const [y, m, d] = parts;
const yearNum = Number.parseInt(y, 10);
const monthNum = Number.parseInt(m, 10);
const dayNum = Number.parseInt(d, 10);
if (
Number.isNaN(yearNum) ||
Number.isNaN(monthNum) ||
Number.isNaN(dayNum) ||
yearNum <= 0 ||
monthNum < 1 ||
monthNum > 12 ||
dayNum < 1 ||
dayNum > 31
) {
return { year: "", month: "", day: "" };
}
return { return {
year: parts[0] || "",
month: parts[1] || "",
day: parts[2] || "",
year: y,
month: m.padStart(2, "0"),
day: d.padStart(2, "0"),
}; };
} }
@ -70,16 +92,32 @@ export function QuestionDate({ question, disabled }: QuestionDateProps) {
const displayDate = useMemo(() => { const displayDate = useMemo(() => {
if (!year || !month || !day) return ""; if (!year || !month || !day) return "";
const date = new Date(
Number.parseInt(year, 10),
Number.parseInt(month, 10) - 1,
Number.parseInt(day, 10),
);
return new Intl.DateTimeFormat(`${locale}-u-ca-gregory`, {
day: "numeric",
month: "long",
year: "numeric",
}).format(date);
const y = Number.parseInt(year, 10);
const m = Number.parseInt(month, 10);
const d = Number.parseInt(day, 10);
if (
!y ||
!m ||
!d ||
Number.isNaN(y) ||
Number.isNaN(m) ||
Number.isNaN(d)
) {
return "";
}
const date = new Date(y, m - 1, d);
if (Number.isNaN(date.getTime()) || !Number.isFinite(date.getTime())) {
return "";
}
try {
return new Intl.DateTimeFormat(`${locale}-u-ca-gregory`, {
day: "numeric",
month: "long",
year: "numeric",
}).format(date);
} catch {
return "";
}
}, [year, month, day, locale]); }, [year, month, day, locale]);
const openSheet = () => { const openSheet = () => {

10
src/components/Componentes/question-dropdown.tsx

@ -222,6 +222,16 @@ export function QuestionDropdown({
<input <input
ref={searchInputRef} ref={searchInputRef}
type="text" type="text"
name="dropdown_search_field"
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"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..." placeholder="Search..."

10
src/components/Componentes/question-exit-navigation-button.tsx

@ -12,10 +12,12 @@ import { markFirstEntryCompleted } from "@/lib/first-entry-helper";
export type QuestionExitNavigationButtonProps = NavigationButtonProps & { export type QuestionExitNavigationButtonProps = NavigationButtonProps & {
exitHref?: string; exitHref?: string;
onExit?: () => void;
}; };
export function QuestionExitNavigationButton({ export function QuestionExitNavigationButton({
exitHref, exitHref,
onExit,
...props ...props
}: QuestionExitNavigationButtonProps) { }: QuestionExitNavigationButtonProps) {
const router = useRouter(); const router = useRouter();
@ -44,8 +46,12 @@ export function QuestionExitNavigationButton({
} catch { } catch {
// ignore // ignore
} finally { } finally {
const target = localizePath(exitHref || "/questions-list", locale);
router.push(target);
if (onExit) {
onExit();
} else {
const target = localizePath(exitHref || "/questions-list", locale);
router.replace(target);
}
} }
}} }}
/> />

15
src/components/Componentes/question-number.tsx

@ -5,6 +5,7 @@ import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
import { Input } from "@/components/ui/input";
type QuestionNumberProps = { type QuestionNumberProps = {
question: QuestionField; question: QuestionField;
@ -186,7 +187,7 @@ export default function QuestionNumber({
<QuestionTitle question={question} /> <QuestionTitle question={question} />
<div className="flex gap-3 w-full relative" ref={currencyContainerRef}> <div className="flex gap-3 w-full relative" ref={currencyContainerRef}>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<input
<Input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
required={question.required && !disabled} required={question.required && !disabled}
@ -295,6 +296,16 @@ export default function QuestionNumber({
<input <input
ref={currencySearchInputRef} ref={currencySearchInputRef}
type="text" type="text"
name="currency_search_field"
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"
value={currencySearchQuery} value={currencySearchQuery}
onChange={(e) => setCurrencySearchQuery(e.target.value)} onChange={(e) => setCurrencySearchQuery(e.target.value)}
placeholder={locale === "fa" ? "جستجو..." : "Search..."} placeholder={locale === "fa" ? "جستجو..." : "Search..."}
@ -376,7 +387,7 @@ export default function QuestionNumber({
].join(" ")} ].join(" ")}
> >
<QuestionTitle question={question} /> <QuestionTitle question={question} />
<input
<Input
type="number" type="number"
required={question.required && !disabled} required={question.required && !disabled}
disabled={disabled || Boolean(derivedFromQuestion)} disabled={disabled || Boolean(derivedFromQuestion)}

161
src/components/Componentes/question-phone.tsx

@ -646,6 +646,22 @@ export function QuestionPhone({
const selectCountryTitle = t["Select country"] || question.title; const selectCountryTitle = t["Select country"] || question.title;
const searchPlaceholder =
locale === "fa"
? "جستجو..."
: locale === "ar"
? "بحث..."
: locale === "tr"
? "Ara..."
: "Search...";
const noResultsText =
locale === "fa"
? "موردی یافت نشد"
: locale === "ar"
? "لم يتم العثور على نتائج"
: "No options found";
return ( return (
<div <div
data-question-answered={isAnswered ? "true" : "false"} data-question-answered={isAnswered ? "true" : "false"}
@ -658,6 +674,7 @@ export function QuestionPhone({
<QuestionTitle question={question} /> <QuestionTitle question={question} />
<div <div
dir="ltr" dir="ltr"
data-snap-drag-ignore="true"
className={[ className={[
"flex h-[54px] w-full items-center rounded-[16px] border bg-white text-[#181818] focus-within:border-[#6F6F6F] focus-within:ring-1 focus-within:ring-[#6F6F6F] transition-all", "flex h-[54px] w-full items-center rounded-[16px] border bg-white text-[#181818] focus-within:border-[#6F6F6F] focus-within:ring-1 focus-within:ring-[#6F6F6F] transition-all",
showInvalidState showInvalidState
@ -713,6 +730,16 @@ export function QuestionPhone({
<input <input
type="tel" type="tel"
inputMode="tel" inputMode="tel"
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"
disabled={disabled} disabled={disabled}
placeholder={question.extras.placeHolder?.replace(/^\+\d+\s*/, "")} placeholder={question.extras.placeHolder?.replace(/^\+\d+\s*/, "")}
value={phoneValue} value={phoneValue}
@ -755,18 +782,41 @@ export function QuestionPhone({
> >
<section <section
className={[ className={[
"flex h-[68svh] w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom",
"flex h-[82svh] w-full flex-col overflow-hidden rounded-t-[22px] bg-white pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)] transition-transform duration-[300ms] ease-out sm:max-w-[375px] animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0", isClosing ? "translate-y-full" : "translate-y-0",
].join(" ")} ].join(" ")}
> >
<header className="shrink-0 px-5 pb-2 pt-1">
<h2 className="text-[17px] font-bold leading-6 text-[#181818]">
{/* Header with Title and Close Button */}
<div className="flex items-center justify-between px-5 pt-2 pb-3 border-b border-[#F2F4F7]">
<h3 className="text-[17px] font-bold text-[#181818] truncate pr-2">
{selectCountryTitle} {selectCountryTitle}
</h2>
</header>
</h3>
<button
type="button"
onClick={closeSheet}
className="flex size-8 shrink-0 items-center justify-center rounded-full text-[#667085] hover:bg-[#F2F4F7] hover:text-[#181818] transition-colors cursor-pointer"
aria-label="Close"
>
<svg
aria-hidden="true"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="shrink-0 border-b border-[#EAECF0] px-5 pb-3">
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[12px] bg-[#F2F4F7] px-3.5 transition-colors focus-within:bg-[#EAECF0]">
{/* Search Bar */}
<div className="px-5 pt-3.5 pb-2">
<div className="flex h-[46px] w-full items-center gap-2.5 rounded-[14px] bg-[#F2F4F7] px-3.5 transition-colors focus-within:bg-[#EAECF0]">
<svg <svg
width="18" width="18"
height="18" height="18"
@ -793,22 +843,26 @@ export function QuestionPhone({
<input <input
ref={searchInputRef} ref={searchInputRef}
type="text" type="text"
name="country_search_field"
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"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
aria-label={selectCountryTitle}
placeholder={
locale === "fa"
? "جستجوی کشور یا پیش‌شماره..."
: "Search country or dial code..."
}
placeholder={searchPlaceholder}
className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]" className="flex-1 bg-transparent text-[14px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3]"
/> />
{searchQuery ? ( {searchQuery ? (
<button <button
type="button" type="button"
onClick={() => setSearchQuery("")} onClick={() => setSearchQuery("")}
aria-label={selectCountryTitle}
className="flex size-7 items-center justify-center rounded-md text-[#667085] hover:bg-white hover:text-[#181818] text-xs font-semibold"
className="text-[#667085] hover:text-[#181818] text-xs font-semibold p-1 cursor-pointer"
> >
</button> </button>
@ -816,63 +870,82 @@ export function QuestionPhone({
</div> </div>
</div> </div>
{/* Country Options List */}
<div <div
ref={listRef} ref={listRef}
onTouchStart={(event) => event.stopPropagation()} onTouchStart={(event) => event.stopPropagation()}
onTouchMove={(event) => event.stopPropagation()} onTouchMove={(event) => event.stopPropagation()}
onTouchEnd={(event) => event.stopPropagation()} onTouchEnd={(event) => event.stopPropagation()}
className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain px-5"
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-contain px-5 py-3"
> >
{filteredCountries.length > 0 ? ( {filteredCountries.length > 0 ? (
filteredCountries.map((c) => { filteredCountries.map((c) => {
const cleanCode = c.code.replace(/[^\d]/g, "");
const activeCleanCode = (codeValue || "").replace(
/[^\d]/g,
"",
);
const isSelected = cleanCode === activeCleanCode;
return ( return (
<button <button
key={`${c.name}-${c.code}`} key={`${c.name}-${c.code}`}
type="button" type="button"
onClick={() => handleSelectCountryCode(c.code)} onClick={() => handleSelectCountryCode(c.code)}
className="flex min-h-[50px] w-full items-center gap-3 border-b border-[#F2F4F7] px-0 text-start cursor-pointer transition-colors hover:bg-[#F9FAFB] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[#667085]"
className={[
"flex min-h-12 w-full items-center gap-3 rounded-lg border px-3 py-3 text-start transition-colors cursor-pointer",
isSelected
? "bg-[#FFF4F5] border-[#F0445B]/30 text-[#181818]"
: "bg-white hover:bg-[#F2F4F7] border-[#EAECF0] text-[#181818]",
].join(" ")}
> >
<span className="text-[20px]" aria-hidden="true">
{/* Radio Indicator */}
<div
className={[
"size-[22px] shrink-0 rounded-full transition-all duration-150 flex items-center justify-center",
isSelected
? "border-[6px] border-[#F0445B] bg-white"
: "border-[2px] border-[#98A2B3] bg-white",
].join(" ")}
/>
{/* Country Flag */}
<span
className="text-[20px] shrink-0"
aria-hidden="true"
>
{c.flag} {c.flag}
</span> </span>
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span className="truncate text-[15px] font-semibold text-[#181818]">
{/* Country Name & Dial Code */}
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span
className={[
"truncate text-[15px]",
isSelected
? "font-bold text-[#181818]"
: "font-semibold text-[#344054]",
].join(" ")}
>
{c.name} {c.name}
</span> </span>
<span <span
dir="ltr" dir="ltr"
className="shrink-0 text-[14px] font-medium text-[#667085] tabular-nums"
className={[
"shrink-0 text-[14px] font-medium tabular-nums",
isSelected ? "text-[#F0445B]" : "text-[#667085]",
].join(" ")}
> >
{c.code} {c.code}
</span> </span>
</span> </span>
{c === selectedCountry ? (
<svg
aria-hidden="true"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
className="shrink-0 text-[#F0445B]"
>
<path
d="M4 10.25L8 14L16 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<span aria-hidden="true" className="w-5 shrink-0" />
)}
</button> </button>
); );
}) })
) : ( ) : (
<span className="py-2 text-[13px] text-[#667085]">
{locale === "fa" ? "موردی یافت نشد" : "No options found"}
</span>
<div className="py-8 text-center text-[14px] text-[#667085]">
{noResultsText}
</div>
)} )}
</div> </div>
</section> </section>

19
src/components/Componentes/question-section-flow.tsx

@ -17,11 +17,13 @@ import AnswerPaceSheet from "@/app/questions-list/[slug]/answer-pace-sheet";
import { FixToTheEnd } from "./fix-to-the-end"; import { FixToTheEnd } from "./fix-to-the-end";
import Button from "./button"; import Button from "./button";
import { markFirstEntryCompleted } from "@/lib/first-entry-helper"; import { markFirstEntryCompleted } from "@/lib/first-entry-helper";
import DevTapInstrumentation from "./dev-tap-instrumentation";
type QuestionSectionFlowProps = { type QuestionSectionFlowProps = {
children: ReactNode; children: ReactNode;
continueLabel: string; continueLabel: string;
exitHref: string; exitHref: string;
onExit?: () => void;
total: number; total: number;
optionalQuestionIndexes: readonly number[]; optionalQuestionIndexes: readonly number[];
questions?: readonly QuestionField[]; questions?: readonly QuestionField[];
@ -31,12 +33,14 @@ function SectionFlowContent({
children, children,
continueLabel, continueLabel,
exitHref, exitHref,
onExit,
optionalQuestionIndexes, optionalQuestionIndexes,
questions, questions,
}: { }: {
children: ReactNode; children: ReactNode;
continueLabel: string; continueLabel: string;
exitHref: string; exitHref: string;
onExit?: () => void;
optionalQuestionIndexes: readonly number[]; optionalQuestionIndexes: readonly number[];
questions?: readonly QuestionField[]; questions?: readonly QuestionField[];
}) { }) {
@ -63,10 +67,14 @@ function SectionFlowContent({
} catch { } catch {
// ignore // ignore
} finally { } finally {
const target = localizePath(exitHref || "/questions-list", locale);
router.push(target);
if (onExit) {
onExit();
} else {
const target = localizePath(exitHref || "/questions-list", locale);
router.replace(target);
}
} }
}, [exitHref, flushAnswers, locale, router, isSubmitting]);
}, [exitHref, onExit, flushAnswers, locale, router, isSubmitting]);
const markOptionalQuestionsPassed = useCallback( const markOptionalQuestionsPassed = useCallback(
(currentIndex: number, nextIndex: number) => { (currentIndex: number, nextIndex: number) => {
@ -126,6 +134,9 @@ function SectionFlowContent({
{children} {children}
</QuestionSnapList> </QuestionSnapList>
{process.env.NODE_ENV === "development" ? (
<DevTapInstrumentation />
) : null}
<FixToTheEnd> <FixToTheEnd>
<Button <Button
disabled={!isCompleted || isSubmitting} disabled={!isCompleted || isSubmitting}
@ -143,6 +154,7 @@ export function QuestionSectionFlow({
children, children,
continueLabel, continueLabel,
exitHref, exitHref,
onExit,
total, total,
optionalQuestionIndexes, optionalQuestionIndexes,
questions, questions,
@ -152,6 +164,7 @@ export function QuestionSectionFlow({
<SectionFlowContent <SectionFlowContent
continueLabel={continueLabel} continueLabel={continueLabel}
exitHref={exitHref} exitHref={exitHref}
onExit={onExit}
optionalQuestionIndexes={optionalQuestionIndexes} optionalQuestionIndexes={optionalQuestionIndexes}
questions={questions} questions={questions}
> >

10
src/components/Componentes/question-sheet.tsx

@ -347,6 +347,16 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
</svg> </svg>
<input <input
type="text" type="text"
name="sheet_search_field"
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"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder} placeholder={searchPlaceholder}

260
src/components/Componentes/question-snap-list.test.tsx

@ -183,4 +183,264 @@ describe("QuestionSnapList keyboard interaction", () => {
expect(input).toHaveFocus(); expect(input).toHaveFocus();
}); });
}); });
describe("touch isolation and first-tap focus reliability", () => {
it("does not step question or blur active input when touch has jitter on input", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<label htmlFor="name-input">Full Name</label>
<input id="name-input" aria-label="Full Name" type="text" />
</div>
<div>
<input aria-label="Second Question" type="text" />
</div>
</QuestionSnapList>,
);
const input = screen.getByRole("textbox", { name: "Full Name" });
input.focus();
expect(input).toHaveFocus();
// Finger touches input with 12px jitter/drift (typical coarse touch contact area shift)
fireEvent.touchStart(input, {
touches: [{ clientY: 300 }],
target: input,
});
fireEvent.touchMove(input, {
touches: [{ clientY: 288 }],
target: input,
});
fireEvent.touchEnd(input, {
changedTouches: [{ clientY: 288 }],
target: input,
});
// Input should remain focused, and active question must remain index 0
expect(input).toHaveFocus();
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
});
it("does not engage drag or blur input when touched while snap animation is settling", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<textarea aria-label="Bio description" />
</div>
<div>
<textarea aria-label="Second Bio" />
</div>
</QuestionSnapList>,
);
const textarea = screen.getByRole("textbox", {
name: "Bio description",
});
textarea.focus();
// Simulate touching during animation or right as question is rendered
fireEvent.touchStart(textarea, {
touches: [{ clientY: 400 }],
target: textarea,
});
fireEvent.touchEnd(textarea, {
changedTouches: [{ clientY: 390 }],
target: textarea,
});
expect(textarea).toHaveFocus();
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
});
it("does not step question on option button/label with micro-jitter (clean tap)", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<button type="button">Select Option</button>
</div>
<div>
<button type="button">Next Step</button>
</div>
</QuestionSnapList>,
);
const button = screen.getByRole("button", { name: "Select Option" });
// Small jitter (delta 8px < INTERACTIVE_DRAG_SLOP 16px)
fireEvent.touchStart(button, {
touches: [{ clientX: 100, clientY: 250 }],
target: button,
});
fireEvent.touchMove(button, {
touches: [{ clientX: 100, clientY: 242 }],
target: button,
});
fireEvent.touchEnd(button, {
changedTouches: [{ clientX: 100, clientY: 242 }],
target: button,
});
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
});
it("allows intentional vertical drag starting on a label/button to swipe questions (soft ownership)", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<label htmlFor="opt-1">Option 1</label>
<input id="opt-1" type="radio" name="opt" />
</div>
<div>
<label htmlFor="opt-2">Option 2</label>
<input id="opt-2" type="radio" name="opt" />
</div>
</QuestionSnapList>,
);
const label = screen.getByText("Option 1");
// Intentional drag (startY: 300, endY: 200 -> deltaY = 100 > slop)
fireEvent.touchStart(label, {
touches: [{ clientX: 100, clientY: 300 }],
target: label,
});
fireEvent.touchMove(label, {
touches: [{ clientX: 100, clientY: 200 }],
target: label,
});
fireEvent.touchEnd(label, {
changedTouches: [{ clientX: 100, clientY: 200 }],
target: label,
});
expect(onActiveIndexChange).toHaveBeenCalledWith(1);
});
it("suppresses trailing click on option after vertical drag swipe", () => {
const onClick = vi.fn();
render(
<QuestionSnapList>
<div>
<button type="button" onClick={onClick}>
Option Button
</button>
</div>
<div>Slide 2</div>
</QuestionSnapList>,
);
const button = screen.getByRole("button", { name: "Option Button" });
const region = screen.getByRole("region", { name: "Questions" });
// Swipe drag starting on button
fireEvent.touchStart(button, {
touches: [{ clientX: 100, clientY: 300 }],
target: button,
});
fireEvent.touchMove(button, {
touches: [{ clientX: 100, clientY: 200 }],
target: button,
});
fireEvent.touchEnd(button, {
changedTouches: [{ clientX: 100, clientY: 200 }],
target: button,
});
// Synthetic click dispatched after drag
fireEvent.click(button);
expect(onClick).not.toHaveBeenCalled();
});
it("allows intentional vertical swipe on non-interactive question background", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div data-testid="slide-1">Slide 1</div>
<div data-testid="slide-2">Slide 2</div>
</QuestionSnapList>,
);
const region = screen.getByRole("region", { name: "Questions" });
// Fast upward flick on background area
fireEvent.touchStart(region, {
touches: [{ clientX: 100, clientY: 400 }],
target: region,
});
fireEvent.touchEnd(region, {
changedTouches: [{ clientX: 100, clientY: 320 }],
target: region,
});
expect(onActiveIndexChange).toHaveBeenCalledWith(1);
});
it("does not swipe or blur when touching and dragging inside a text input", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<input aria-label="First Name" type="text" />
</div>
<div>
<input aria-label="Last Name" type="text" />
</div>
</QuestionSnapList>,
);
const input = screen.getByRole("textbox", { name: "First Name" });
input.focus();
// Large drag on input (e.g. user selecting text or scrolling within text input)
fireEvent.touchStart(input, {
touches: [{ clientX: 100, clientY: 400 }],
target: input,
});
fireEvent.touchMove(input, {
touches: [{ clientX: 100, clientY: 200 }],
target: input,
});
fireEvent.touchEnd(input, {
changedTouches: [{ clientX: 100, clientY: 200 }],
target: input,
});
expect(input).toHaveFocus();
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
});
it("does not swipe on hard-ignored controls like range sliders", () => {
const onActiveIndexChange = vi.fn();
render(
<QuestionSnapList onActiveIndexChange={onActiveIndexChange}>
<div>
<input type="range" aria-label="Volume slider" />
</div>
<div>Slide 2</div>
</QuestionSnapList>,
);
const slider = screen.getByRole("slider", { name: "Volume slider" });
fireEvent.touchStart(slider, {
touches: [{ clientX: 100, clientY: 300 }],
target: slider,
});
fireEvent.touchMove(slider, {
touches: [{ clientX: 100, clientY: 200 }],
target: slider,
});
fireEvent.touchEnd(slider, {
changedTouches: [{ clientX: 100, clientY: 200 }],
target: slider,
});
expect(onActiveIndexChange).not.toHaveBeenCalledWith(1);
});
});
}); });

357
src/components/Componentes/question-snap-list.tsx

@ -15,29 +15,58 @@ import {
} from "./question-viewport-coordinator"; } from "./question-viewport-coordinator";
const WHEEL_GESTURE_IDLE_MS = 320; const WHEEL_GESTURE_IDLE_MS = 320;
const TOUCH_MIN_DISTANCE = 8;
const DRAG_ENGAGE_DISTANCE = 10;
const BACKGROUND_DRAG_SLOP = 10;
const OPTION_CARD_DRAG_SLOP = 16;
const FAST_FLICK_MIN_DISTANCE = 36;
const DRAG_COMMIT_RATIO = 0.3; const DRAG_COMMIT_RATIO = 0.3;
const DRAG_FLICK_VELOCITY = 0.55;
const DRAG_FLICK_VELOCITY = 0.4;
const SNAP_ANIMATION_MS = 340; const SNAP_ANIMATION_MS = 340;
const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const RUBBER_BAND_RESISTANCE = 0.4; const RUBBER_BAND_RESISTANCE = 0.4;
const DRAG_IGNORE_SELECTOR = [
"input",
/**
* Text inputs and direct embedded controls that MUST be completely isolated
* from the snap/drag gesture recognizer so first-tap focus, virtual keyboard,
* and caret manipulation work 100% reliably.
*/
const TEXT_INPUT_SELECTOR = [
'input:not([type="radio"]):not([type="checkbox"])',
"textarea", "textarea",
"select", "select",
'[contenteditable="true"]', '[contenteditable="true"]',
'input[type="range"]',
"[data-snap-drag-ignore]", "[data-snap-drag-ignore]",
].join(", "); ].join(", ");
/**
* Option cards, radio/checkbox labels, and buttons on choice questions
* where tap = select option, but vertical drag > slop = swipe question.
*/
const OPTION_CARD_SELECTOR = [
"label",
"button",
"a",
'[role="button"]',
'[role="option"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="switch"]',
'[role="combobox"]',
'[role="listbox"]',
].join(", ");
type SnapDragState = { type SnapDragState = {
pointerDown: boolean; pointerDown: boolean;
ignored: boolean;
hardIgnored: boolean;
isOptionCard: boolean;
engaged: boolean; engaged: boolean;
didDrag: boolean;
animating: boolean; animating: boolean;
baseOffset: number; baseOffset: number;
startX: number;
startY: number; startY: number;
lastY: number; lastY: number;
startTime: number;
lastMoveTime: number; lastMoveTime: number;
velocity: number; velocity: number;
offset: number; offset: number;
@ -73,6 +102,7 @@ export function QuestionSnapList({
const questionRefs = useRef<Array<HTMLDivElement | null>>([]); const questionRefs = useRef<Array<HTMLDivElement | null>>([]);
const containerRef = useRef<HTMLElement | null>(null); const containerRef = useRef<HTMLElement | null>(null);
const previousActiveIndexRef = useRef<number | null>(null); const previousActiveIndexRef = useRef<number | null>(null);
const suppressNextClickRef = useRef(false);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const activeIndexRef = useRef(activeIndex); const activeIndexRef = useRef(activeIndex);
@ -82,12 +112,16 @@ export function QuestionSnapList({
const dragRef = useRef<SnapDragState>({ const dragRef = useRef<SnapDragState>({
pointerDown: false, pointerDown: false,
ignored: false,
hardIgnored: false,
isOptionCard: false,
engaged: false, engaged: false,
didDrag: false,
animating: false, animating: false,
baseOffset: 0, baseOffset: 0,
startX: 0,
startY: 0, startY: 0,
lastY: 0, lastY: 0,
startTime: 0,
lastMoveTime: 0, lastMoveTime: 0,
velocity: 0, velocity: 0,
offset: 0, offset: 0,
@ -100,21 +134,18 @@ export function QuestionSnapList({
(direction: 1 | -1) => { (direction: 1 | -1) => {
const nextIndex = Math.max( const nextIndex = Math.max(
0, 0,
Math.min(questions.length - 1, activeIndex + direction),
Math.min(questions.length - 1, activeIndexRef.current + direction),
); );
if (nextIndex === activeIndex) {
if (nextIndex === activeIndexRef.current) {
return; return;
} }
resetQuestionKeyboardState(); resetQuestionKeyboardState();
onQuestionExit?.(activeIndex, nextIndex);
onQuestionTransition?.(activeIndex, nextIndex);
onQuestionExit?.(activeIndexRef.current, nextIndex);
setActiveIndex(nextIndex); setActiveIndex(nextIndex);
}, },
[activeIndex, onQuestionExit, onQuestionTransition, questions.length],
[onQuestionExit, questions.length],
); );
const scheduleWheelUnlock = useCallback(() => { const scheduleWheelUnlock = useCallback(() => {
@ -208,8 +239,9 @@ export function QuestionSnapList({
useEffect(() => { useEffect(() => {
const previousActiveIndex = previousActiveIndexRef.current; const previousActiveIndex = previousActiveIndexRef.current;
onQuestionTransition?.(previousActiveIndex ?? activeIndex, activeIndex);
if (previousActiveIndex !== null && previousActiveIndex !== activeIndex) {
onQuestionTransition?.(previousActiveIndex, activeIndex);
}
previousActiveIndexRef.current = activeIndex; previousActiveIndexRef.current = activeIndex;
}, [activeIndex, onQuestionTransition]); }, [activeIndex, onQuestionTransition]);
@ -249,8 +281,6 @@ export function QuestionSnapList({
[], [],
); );
// Positive offset slides the active panel up (towards the next question),
// exactly like following a finger in a reels-style pager.
const applyDragOffset = useCallback( const applyDragOffset = useCallback(
(offset: number, animate: boolean) => { (offset: number, animate: boolean) => {
const index = activeIndexRef.current; const index = activeIndexRef.current;
@ -333,12 +363,76 @@ export function QuestionSnapList({
[questions.length, scheduleWheelUnlock, stepQuestion], [questions.length, scheduleWheelUnlock, stepQuestion],
); );
const handleTouchStart = useCallback(
(event: React.TouchEvent<HTMLElement>) => {
const finishDrag = useCallback(
(cancelled: boolean) => {
const drag = dragRef.current;
if (!drag.pointerDown || drag.hardIgnored) {
drag.pointerDown = false;
drag.engaged = false;
touchStartYRef.current = null;
return;
}
drag.pointerDown = false;
touchStartYRef.current = null;
if (!drag.engaged) {
return;
}
drag.engaged = false;
// Keep suppressNextClickRef active briefly so trailing click from drag is suppressed
if (drag.didDrag) {
suppressNextClickRef.current = true;
window.setTimeout(() => {
suppressNextClickRef.current = false;
}, 60);
}
const index = activeIndexRef.current;
const canNext = index < questionsCountRef.current - 1;
const canPrev = index > 0;
const flicked = Math.abs(drag.velocity) > DRAG_FLICK_VELOCITY;
const draggedFar =
Math.abs(drag.offset) > drag.height * DRAG_COMMIT_RATIO;
const movedPastFlick = Math.abs(drag.offset) >= FAST_FLICK_MIN_DISTANCE;
let direction: 0 | 1 | -1 = 0;
if (!cancelled && (draggedFar || flicked || movedPastFlick)) {
if ((drag.offset > 0 || drag.velocity > 0) && canNext) {
direction = 1;
} else if ((drag.offset < 0 || drag.velocity < 0) && canPrev) {
direction = -1;
}
}
if (direction === 0) {
snapPanelsTo(0);
return;
}
const nextIndex = index + direction;
resetQuestionKeyboardState();
snapPanelsTo(direction * drag.height);
onQuestionExit?.(index, nextIndex);
setActiveIndex(nextIndex);
},
[onQuestionExit, snapPanelsTo],
);
// Native non-passive touch listeners on container element.
// This allows the container to have `touch-action: pan-y` (enabling instant 1st-tap focus on inputs)
// while allowing non-passive `event.preventDefault()` during swipe to prevent WebView native pan cancels.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const onTouchStart = (event: TouchEvent) => {
if (document.body.classList.contains("dropdown-open")) { if (document.body.classList.contains("dropdown-open")) {
return; return;
} }
suppressNextClickRef.current = false;
const drag = dragRef.current; const drag = dragRef.current;
if (drag.cleanupTimer !== null) { if (drag.cleanupTimer !== null) {
window.clearTimeout(drag.cleanupTimer); window.clearTimeout(drag.cleanupTimer);
@ -346,15 +440,33 @@ export function QuestionSnapList({
} }
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null;
drag.ignored = Boolean(target?.closest?.(DRAG_IGNORE_SELECTOR));
// 1. Hard-exclude text inputs and standalone controls
const isTextInput = Boolean(target?.closest?.(TEXT_INPUT_SELECTOR));
if (isTextInput) {
drag.pointerDown = false;
drag.engaged = false;
drag.hardIgnored = true;
drag.isOptionCard = false;
drag.didDrag = false;
drag.baseOffset = 0;
drag.offset = 0;
touchStartYRef.current = null;
return;
}
// 2. Identify option cards / buttons for soft ownership
const isOptionCard = Boolean(target?.closest?.(OPTION_CARD_SELECTOR));
drag.hardIgnored = false;
drag.isOptionCard = isOptionCard;
drag.didDrag = false;
drag.height = drag.height =
containerRef.current?.getBoundingClientRect().height ??
window.innerHeight;
container.getBoundingClientRect().height ||
window.innerHeight ||
600;
if (drag.animating) {
// Grabbed mid-snap (or mid wheel transition): freeze the panels
// exactly where they currently are and continue the drag from there.
if (drag.animating && !isOptionCard) {
const activeElement = questionRefs.current[activeIndexRef.current]; const activeElement = questionRefs.current[activeIndexRef.current];
if (activeElement) { if (activeElement) {
drag.baseOffset = -readTranslateY(activeElement); drag.baseOffset = -readTranslateY(activeElement);
@ -372,42 +484,65 @@ export function QuestionSnapList({
drag.pointerDown = true; drag.pointerDown = true;
drag.velocity = 0; drag.velocity = 0;
drag.offset = drag.baseOffset; drag.offset = drag.baseOffset;
drag.startY = event.touches[0]?.clientY ?? 0;
const touch = event.touches[0];
drag.startX = touch?.clientX ?? 0;
drag.startY = touch?.clientY ?? 0;
drag.lastY = drag.startY; drag.lastY = drag.startY;
drag.lastMoveTime = event.timeStamp || performance.now();
drag.startTime = performance.now();
drag.lastMoveTime = drag.startTime;
touchStartYRef.current = drag.startY; touchStartYRef.current = drag.startY;
},
[applyDragOffset, readTranslateY],
);
};
const handleTouchMove = useCallback(
(event: React.TouchEvent<HTMLElement>) => {
const onTouchMove = (event: TouchEvent) => {
if (document.body.classList.contains("dropdown-open")) { if (document.body.classList.contains("dropdown-open")) {
return; return;
} }
const drag = dragRef.current; const drag = dragRef.current;
if (!drag.pointerDown) {
event.preventDefault();
if (!drag.pointerDown || drag.hardIgnored) {
return; return;
} }
if (drag.ignored) {
return;
}
const touch = event.touches[0];
const currentX = touch?.clientX ?? drag.startX;
const currentY = touch?.clientY ?? drag.lastY;
const deltaX = currentX - drag.startX;
const deltaY = currentY - drag.startY;
const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY);
const y = event.touches[0]?.clientY ?? drag.lastY;
const now = performance.now();
if (!drag.engaged) { if (!drag.engaged) {
if (Math.abs(drag.startY - y) < DRAG_ENGAGE_DISTANCE) {
event.preventDefault();
const slop = drag.isOptionCard
? OPTION_CARD_DRAG_SLOP
: BACKGROUND_DRAG_SLOP;
// If movement is under slop threshold, do not engage: let native clicks/focus happen
if (absY < slop) {
return; return;
} }
// Must be primarily vertical gesture (avoid hijacking horizontal swipe / scroll)
if (absY < absX * 1.1) {
return;
}
// Pager claims the gesture!
drag.engaged = true; drag.engaged = true;
drag.didDrag = true;
suppressNextClickRef.current = true;
const elapsed = Math.max(1, now - drag.startTime);
drag.velocity = (drag.startY - currentY) / elapsed;
} }
event.preventDefault();
// Non-passive preventDefault stops native pan and guarantees gesture ownership
if (event.cancelable) {
event.preventDefault();
}
if (event.touches.length > 1) { if (event.touches.length > 1) {
drag.engaged = false; drag.engaged = false;
@ -416,19 +551,20 @@ export function QuestionSnapList({
return; return;
} }
const now = event.timeStamp || performance.now();
const deltaTime = now - drag.lastMoveTime;
if (deltaTime > 0) {
const instantVelocity = (drag.lastY - y) / deltaTime;
drag.velocity = drag.velocity * 0.72 + instantVelocity * 0.28;
}
drag.lastY = y;
const deltaTime = Math.max(1, now - drag.lastMoveTime);
const instantVelocity = (drag.lastY - currentY) / deltaTime;
drag.velocity =
drag.velocity === 0
? instantVelocity
: drag.velocity * 0.72 + instantVelocity * 0.28;
drag.lastY = currentY;
drag.lastMoveTime = now; drag.lastMoveTime = now;
const index = activeIndexRef.current; const index = activeIndexRef.current;
const canNext = index < questionsCountRef.current - 1; const canNext = index < questionsCountRef.current - 1;
const canPrev = index > 0; const canPrev = index > 0;
let offset = drag.baseOffset + (drag.startY - y);
let offset = drag.baseOffset + (drag.startY - currentY);
if (offset > 0 && !canNext) { if (offset > 0 && !canNext) {
offset *= RUBBER_BAND_RESISTANCE; offset *= RUBBER_BAND_RESISTANCE;
} }
@ -438,63 +574,22 @@ export function QuestionSnapList({
drag.offset = offset; drag.offset = offset;
applyDragOffset(offset, false); applyDragOffset(offset, false);
},
[applyDragOffset, snapPanelsTo],
);
const finishDrag = useCallback(
(cancelled: boolean) => {
const drag = dragRef.current;
if (!drag.pointerDown) {
return;
}
drag.pointerDown = false;
touchStartYRef.current = null;
if (!drag.engaged) {
return;
}
drag.engaged = false;
const index = activeIndexRef.current;
const canNext = index < questionsCountRef.current - 1;
const canPrev = index > 0;
const flicked = Math.abs(drag.velocity) > DRAG_FLICK_VELOCITY;
const draggedFar =
Math.abs(drag.offset) > drag.height * DRAG_COMMIT_RATIO;
let direction: 0 | 1 | -1 = 0;
if (!cancelled && (draggedFar || flicked)) {
if ((drag.offset > 0 || drag.velocity > 0) && canNext) {
direction = 1;
} else if ((drag.offset < 0 || drag.velocity < 0) && canPrev) {
direction = -1;
}
}
};
if (direction === 0) {
snapPanelsTo(0);
const onTouchEnd = (event: TouchEvent) => {
if (document.body.classList.contains("dropdown-open")) {
return; return;
} }
const nextIndex = index + direction;
resetQuestionKeyboardState();
snapPanelsTo(direction * drag.height);
onQuestionExit?.(index, nextIndex);
onQuestionTransition?.(index, nextIndex);
setActiveIndex(nextIndex);
},
[onQuestionExit, onQuestionTransition, snapPanelsTo],
);
const drag = dragRef.current;
const handleTouchEnd = useCallback(
(event: React.TouchEvent<HTMLElement>) => {
if (document.body.classList.contains("dropdown-open")) {
if (drag.hardIgnored) {
drag.pointerDown = false;
drag.engaged = false;
touchStartYRef.current = null;
return; return;
} }
const drag = dragRef.current;
if (drag.engaged) { if (drag.engaged) {
finishDrag(false); finishDrag(false);
return; return;
@ -505,8 +600,13 @@ export function QuestionSnapList({
} }
drag.pointerDown = false; drag.pointerDown = false;
// Fallback for very fast flicks whose touchmove never engaged the
// drag layer: fall back to the distance-based step.
// If it was a clean tap on an option card / button, leave it to native click!
if (drag.isOptionCard) {
touchStartYRef.current = null;
return;
}
// Fast flick fallback on background area
const startY = touchStartYRef.current; const startY = touchStartYRef.current;
const endY = event.changedTouches[0]?.clientY; const endY = event.changedTouches[0]?.clientY;
touchStartYRef.current = null; touchStartYRef.current = null;
@ -517,19 +617,55 @@ export function QuestionSnapList({
const distance = startY - endY; const distance = startY - endY;
if (Math.abs(distance) < TOUCH_MIN_DISTANCE) {
if (Math.abs(distance) < FAST_FLICK_MIN_DISTANCE) {
return; return;
} }
stepQuestion(distance > 0 ? 1 : -1); stepQuestion(distance > 0 ? 1 : -1);
};
const onTouchCancel = () => {
const drag = dragRef.current;
if (drag.hardIgnored) {
drag.pointerDown = false;
drag.engaged = false;
touchStartYRef.current = null;
return;
}
finishDrag(true);
};
container.addEventListener("touchstart", onTouchStart, { passive: true });
container.addEventListener("touchmove", onTouchMove, { passive: false });
container.addEventListener("touchend", onTouchEnd, { passive: true });
container.addEventListener("touchcancel", onTouchCancel, { passive: true });
return () => {
container.removeEventListener("touchstart", onTouchStart);
container.removeEventListener("touchmove", onTouchMove);
container.removeEventListener("touchend", onTouchEnd);
container.removeEventListener("touchcancel", onTouchCancel);
};
}, [applyDragOffset, finishDrag, questions.length, readTranslateY, snapPanelsTo, stepQuestion]);
const handleClickCapture = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
const target = event.target as HTMLElement | null;
const isTextInput = Boolean(target?.closest?.(TEXT_INPUT_SELECTOR));
if (isTextInput) {
suppressNextClickRef.current = false;
return;
}
if (suppressNextClickRef.current) {
event.preventDefault();
event.stopPropagation();
suppressNextClickRef.current = false;
}
}, },
[finishDrag, questions.length, stepQuestion],
[],
); );
const handleTouchCancel = useCallback(() => {
finishDrag(true);
}, [finishDrag]);
if (questions.length === 0) { if (questions.length === 0) {
return null; return null;
} }
@ -539,16 +675,13 @@ export function QuestionSnapList({
ref={containerRef} ref={containerRef}
aria-label="Questions" aria-label="Questions"
className={[ className={[
"question-snap-list relative touch-none overflow-hidden focus-visible:outline-none",
"question-snap-list relative touch-pan-y overflow-hidden focus-visible:outline-none",
"flex-1 min-h-0 pt-4 pb-4", "flex-1 min-h-0 pt-4 pb-4",
className, className,
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
onTouchCancel={handleTouchCancel}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}
onTouchStart={handleTouchStart}
onClickCapture={handleClickCapture}
onWheel={handleWheel} onWheel={handleWheel}
> >
{questions.map((question, index) => { {questions.map((question, index) => {

57
src/components/Componentes/question-text.tsx

@ -2,7 +2,9 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { cn } from "@/lib/utils";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { Input } from "@/components/ui/input";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
@ -84,31 +86,23 @@ export default function QuestionText({
<div <div
data-question-answered={isAnswered ? "true" : "false"} data-question-answered={isAnswered ? "true" : "false"}
data-question-type="email" data-question-type="email"
className={[
className={cn(
"flex w-full flex-col gap-2 transition-opacity duration-200", "flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
disabled && "pointer-events-none opacity-30",
)}
> >
<QuestionTitle question={question} /> <QuestionTitle question={question} />
<div
<Input
type="text"
inputMode="email"
dir="ltr" dir="ltr"
className={[
"flex h-[54px] w-full items-center rounded-[16px] border bg-white text-[#181818] focus-within:border-[#6F6F6F] focus-within:ring-1 focus-within:ring-[#6F6F6F] transition-all",
showInvalidState
? "border-[#F2465F] ring-1 ring-[#F2465F]"
: "border-[#D0D5DD] hover:border-[#98A2B3]",
].join(" ")}
>
<input
type="text"
value={localValue}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
placeholder={question.extras.placeHolder}
disabled={disabled || isMuted}
className="h-full w-full border-0 bg-transparent px-4.5 text-[15px] font-medium text-[#181818] outline-none placeholder:text-[#98A2B3] disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
hasError={showInvalidState}
value={localValue}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
placeholder={question.extras.placeHolder}
disabled={disabled || isMuted}
/>
{showInvalidState ? ( {showInvalidState ? (
<span className="block group-10 font-semibold text-[#F2465F]"> <span className="block group-10 font-semibold text-[#F2465F]">
{question.validation?.errorMessage || {question.validation?.errorMessage ||
@ -130,29 +124,24 @@ export default function QuestionText({
<div <div
data-question-answered={isAnswered ? "true" : "false"} data-question-answered={isAnswered ? "true" : "false"}
data-question-type={question.type} data-question-type={question.type}
className={[
className={cn(
"flex w-full flex-col gap-2 transition-opacity duration-200", "flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
disabled && "pointer-events-none opacity-30",
)}
> >
<QuestionTitle question={question} /> <QuestionTitle question={question} />
<input
<Input
type={isNumericQuestion ? "tel" : "text"} type={isNumericQuestion ? "tel" : "text"}
inputMode={isNumericQuestion ? "numeric" : undefined}
inputMode={isNumericQuestion ? "numeric" : "text"}
pattern={isNumericQuestion ? "[0-9]*" : undefined} pattern={isNumericQuestion ? "[0-9]*" : undefined}
value={localValue} value={localValue}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur} onBlur={handleBlur}
placeholder={question.extras.placeHolder} placeholder={question.extras.placeHolder}
disabled={disabled || isMuted} disabled={disabled || isMuted}
className={[
"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:bg-[#F5F2F1] disabled:text-[#7C7472]",
isMuted
? "opacity-50 cursor-not-allowed bg-[#F5F5F5] border-[#E2E8F0]"
: "",
]
.filter(Boolean)
.join(" ")}
className={cn(
isMuted && "opacity-50 cursor-not-allowed bg-[#F5F5F5] border-[#E2E8F0]",
)}
/> />
{isMarjaQuestion ? ( {isMarjaQuestion ? (
<div className="mt-2.5 flex items-center"> <div className="mt-2.5 flex items-center">

12
src/components/Componentes/question-textarea.tsx

@ -2,7 +2,9 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { cn } from "@/lib/utils";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { Textarea } from "@/components/ui/textarea";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
@ -56,20 +58,20 @@ export function QuestionTextarea({
<div <div
data-question-answered={isAnswered ? "true" : "false"} data-question-answered={isAnswered ? "true" : "false"}
data-question-type="textarea" data-question-type="textarea"
className={[
className={cn(
"flex w-full flex-col gap-2 transition-opacity duration-200", "flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
disabled && "pointer-events-none opacity-30",
)}
> >
<QuestionTitle question={question} /> <QuestionTitle question={question} />
<textarea
<Textarea
value={localValue} value={localValue}
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
placeholder={question.extras.placeHolder} placeholder={question.extras.placeHolder}
disabled={disabled} disabled={disabled}
rows={5} rows={5}
className="h-[270px] 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:bg-[#F5F2F1] disabled:text-[#7C7472] resize-none"
className="h-[270px]"
/> />
{description ? ( {description ? (
<span className="block group-10 font-semibold text-[#747474]"> <span className="block group-10 font-semibold text-[#747474]">

116
src/components/Componentes/section-overlay-host.test.tsx

@ -0,0 +1,116 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/translations/provider";
import SectionOverlayHost from "./section-overlay-host";
describe("SectionOverlayHost", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
return setTimeout(() => cb(Date.now()), 0);
});
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
clearTimeout(id);
});
document.body.className = "";
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers();
document.body.className = "";
});
it("returns null when no children or open is false", () => {
const { container } = render(
<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();
});
});

148
src/components/Componentes/section-overlay-host.tsx

@ -0,0 +1,148 @@
"use client";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { localeDirections } from "@/translations/config";
import { useI18n } from "@/translations/provider";
type SectionOverlayContextValue = {
isOverlay: true;
onClose?: () => void;
};
const SectionOverlayContext =
createContext<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
src/components/Componentes/slider-page.test.tsx

@ -25,6 +25,7 @@ vi.mock('@/hooks/marriage/use-profile-main', () => ({
vi.mock('@/hooks/marriage/query-keys', () => ({ vi.mock('@/hooks/marriage/query-keys', () => ({
marriageQueryKeys: { marriageQueryKeys: {
profile: () => ['marriage', 'profile'], profile: () => ['marriage', 'profile'],
config: () => ['marriage', 'config'],
}, },
})); }));

4
src/components/Componentes/sticky-header.tsx

@ -26,11 +26,11 @@ export default function StickyHeader({
<header <header
style={ style={
sticky sticky
? { top: `-${top}px`, paddingTop: `${paddingTop}px` }
? { top: "0px", paddingTop: `${paddingTop}px` }
: { paddingTop: `${paddingTop}px` } : { paddingTop: `${paddingTop}px` }
} }
className={[ className={[
sticky ? "sticky z-30" : "relative z-30",
sticky ? "sticky top-0 z-30" : "relative z-30",
"rounded-b-[15px] bg-[linear-gradient(135deg,#E03950_0%,#FE6F82_100%)] px-[17px] pb-3", "rounded-b-[15px] bg-[linear-gradient(135deg,#E03950_0%,#FE6F82_100%)] px-[17px] pb-3",
className, className,
] ]

198
src/components/Componentes/subscription-required-sheet.tsx

@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { useHabcoinInventoryQuery } from "@/hooks/marriage/use-habcoin-inventory";
import { DiscountWidget } from "./discount-widget";
import type { CheckDiscountResult } from "@/hooks/marriage/use-validate-discount";
import { import {
buyHabibCoinPackages, buyHabibCoinPackages,
isInFlutterWebView, isInFlutterWebView,
@ -11,12 +13,14 @@ import { LoadingThreeDot } from "./loading-three-dot";
type SubscriptionRequiredSheetProps = { type SubscriptionRequiredSheetProps = {
onClose: () => void; onClose: () => void;
onPayment: () => void;
onPayment: (discountCode?: string) => void;
isPaymentPending?: boolean; isPaymentPending?: boolean;
/** Payment error to display above the buttons (e.g. "Not enough coins"). */ /** Payment error to display above the buttons (e.g. "Not enough coins"). */
errorMessage?: string | null; errorMessage?: string | null;
/** Show the "Buy Habib Coins" button (Flutter opens its packages page). */ /** Show the "Buy Habib Coins" button (Flutter opens its packages page). */
showBuyCoins?: boolean; showBuyCoins?: boolean;
price?: number;
planId?: number | string;
}; };
export function SubscriptionRequiredSheet({ export function SubscriptionRequiredSheet({
@ -25,111 +29,151 @@ export function SubscriptionRequiredSheet({
isPaymentPending = false, isPaymentPending = false,
errorMessage = null, errorMessage = null,
showBuyCoins = false, showBuyCoins = false,
price = 50,
planId,
}: SubscriptionRequiredSheetProps) { }: SubscriptionRequiredSheetProps) {
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const [appliedDiscount, setAppliedDiscount] =
useState<CheckDiscountResult | null>(null);
const { data: inventory, isLoading: isInventoryLoading } =
useHabcoinInventoryQuery();
const finalPrice = appliedDiscount?.valid
? appliedDiscount.discountedPrice
: price;
const balance = inventory?.coin_balance ?? 0;
const hasEnoughCoins =
!showBuyCoins && (inventory === undefined ? true : balance >= finalPrice);
if (isInventoryLoading) {
return <InformationSheet isLoading={true} onClose={onClose} />;
}
return ( return (
<InformationSheet <InformationSheet
icon="coin" icon="coin"
title={t["Subscription required"] || "Subscription required"}
title={
!hasEnoughCoins ? (
<span className="text-[#BD3F3F]">
{t["You do not have enough Habib Coins"] ||
"You do not have enough Habib Coins"}
</span>
) : (
t["Subscription required"] || "Subscription required"
)
}
description={ description={
<div className="space-y-4 text-left">
<p className="text-sm text-center leading-[1.45] font-medium text-[#2B2B2B]">
!hasEnoughCoins ? (
<p className="text-[14px] text-[#4D4D4D] leading-[1.3] font-normal text-center">
{t[ {t[
"To view profiles, you need a subscription. Each subscription includes up to {count} matches."
]?.replace("{count}", "3") || (
<>
To view profiles, you need a subscription. Each subscription
includes up to{" "}
<span className="font-bold underline decoration-[1.5px] underline-offset-2">
3
</span>{" "}
matches.
</>
)}
"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> </p>
<div className="rounded-[12px] border border-[#FF4F67] bg-[#FFECEF] px-3.5 py-2.5 text-center">
<p className="text-xs leading-[1.45] font-semibold text-[#FF4F67]">
) : (
<div className="space-y-4 text-left">
<p className="text-sm text-center leading-[1.45] font-medium text-[#2B2B2B]">
{t[ {t[
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users"
] ||
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users"}
"To view profiles, you need a subscription. Each subscription includes up to {count} matches."
]?.replace("{count}", "3") || (
<>
To view profiles, you need a subscription. Each subscription
includes up to{" "}
<span className="font-bold underline decoration-[1.5px] underline-offset-2">
3
</span>{" "}
matches.
</>
)}
</p> </p>
<div className="rounded-[12px] border border-[#FF4F67] bg-[#FFECEF] px-3.5 py-2.5 text-center">
<p className="text-xs leading-[1.45] font-semibold text-[#FF4F67]">
{t[
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users"
] ||
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users"}
</p>
</div>
</div> </div>
</div>
)
} }
onClose={onClose} onClose={onClose}
buttons={({ close }) => ( buttons={({ close }) => (
<div className="space-y-3"> <div className="space-y-3">
{planId ? (
<DiscountWidget
objectId={planId}
onDiscountApplied={(res) => setAppliedDiscount(res)}
onDiscountCleared={() => setAppliedDiscount(null)}
/>
) : null}
{errorMessage && ( {errorMessage && (
<div className="w-full bg-[#FEF2F2] text-[#B91C1C] text-xs p-3 rounded-xl text-center font-medium">
<div className="w-full bg-[#FEF2F2] text-[#B91C1C] text-xs p-3 rounded-[11px] text-center font-medium">
{errorMessage} {errorMessage}
</div> </div>
)} )}
{showBuyCoins && isInFlutterWebView() && (
<button
type="button"
className="appearance-none border-0 bg-transparent p-0 text-left w-full"
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"> <div className="grid w-full grid-cols-[33fr_67fr] gap-3">
<button <button
type="button" type="button"
className="appearance-none border-0 bg-transparent p-0 text-left min-w-0" className="appearance-none border-0 bg-transparent p-0 text-left min-w-0"
onClick={close} onClick={close}
> >
<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">
<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">
<span className="truncate">{t["Back"] || "Back"}</span> <span className="truncate">{t["Back"] || "Back"}</span>
</div> </div>
</button> </button>
<button
type="button"
disabled={isPaymentPending}
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
onClick={onPayment}
>
<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">
{isPaymentPending ? (
<LoadingThreeDot />
) : (
<>
<span className="truncate">
{t["Payment"] || "Payment"}
</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>
{!hasEnoughCoins ? (
<button
type="button"
className="appearance-none border-0 bg-transparent p-0 text-left 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={isPaymentPending}
className="appearance-none border-0 bg-transparent p-0 text-left disabled:cursor-not-allowed disabled:opacity-70 min-w-0"
onClick={() =>
onPayment(
appliedDiscount?.valid ? appliedDiscount.code : undefined,
)
}
>
<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">
{isPaymentPending ? (
<LoadingThreeDot />
) : (
<>
<span className="truncate">
{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>
</div> </div>
)} )}

19
src/components/Componentes/support-access.ts

@ -1,23 +1,8 @@
import type { MarriageProfile } from "@/hooks/marriage/types"; 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 { 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;
} }

22
src/components/Componentes/test-questions-flow.tsx

@ -35,7 +35,7 @@ type TestQuestionsFlowProps = {
stepsLabel?: string; stepsLabel?: string;
onFinish?: (answers: Record<number, string | number>) => void; onFinish?: (answers: Record<number, string | number>) => void;
onClose?: () => void; onClose?: () => void;
draftStorageKey?: string;
draftStorageKey?: string | null;
}; };
type StoredTestDraft = { type StoredTestDraft = {
answers?: Record<number, string | number>; answers?: Record<number, string | number>;
@ -44,7 +44,7 @@ type StoredTestDraft = {
}; };
function getStoredDraft( function getStoredDraft(
storageKey: string | undefined,
storageKey: string | null | undefined,
totalQuestions: number, totalQuestions: number,
) { ) {
if (!storageKey || typeof window === "undefined") if (!storageKey || typeof window === "undefined")
@ -169,12 +169,26 @@ export default function TestQuestionsFlow({
useEffect(() => { useEffect(() => {
if (!draftStorageKey) return; if (!draftStorageKey) return;
try { try {
const match = draftStorageKey.match(
/^marriage:user:(\d+):tests:([^:]+):draft:v(\d+)$/,
);
const ownerProfileId = match ? Number(match[1]) : undefined;
const version = match ? Number(match[3]) : undefined;
const slug = match ? match[2] : undefined;
window.localStorage.setItem( window.localStorage.setItem(
draftStorageKey, draftStorageKey,
JSON.stringify({ answers, currentIndex, totalQuestions }),
JSON.stringify({
answers,
currentIndex,
totalQuestions,
...(ownerProfileId !== undefined ? { ownerProfileId } : {}),
...(version !== undefined ? { version } : {}),
...(slug !== undefined ? { slug } : {}),
}),
); );
} catch {} } catch {}
}, [answers, currentIndex, draftStorageKey]);
}, [answers, currentIndex, draftStorageKey, totalQuestions]);
const handleOptionSelect = (value: string | number) => { const handleOptionSelect = (value: string | number) => {
if (!currentQuestion) return; if (!currentQuestion) return;

806
src/components/Componentes/token-switcher.tsx

@ -6,6 +6,9 @@ import { MdOutlineSwitchAccount } from "react-icons/md";
import { getClientCookie, setClientCookie } from "@/lib/cookies"; import { getClientCookie, setClientCookie } from "@/lib/cookies";
import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache"; import { setCachedMarriageEntryPath } from "@/lib/entry-route-cache";
// ─────────────────────────────────────────────────────────────────────────────
// ۱. توکن‌های ثابت سیستم (System Preset Tokens)
// ─────────────────────────────────────────────────────────────────────────────
export const FEMALE_TOKEN = "545f61bb3e061ccb9b19f84715eb1b1ed4740331"; export const FEMALE_TOKEN = "545f61bb3e061ccb9b19f84715eb1b1ed4740331";
export const MALE_TOKEN = "f3a7543b44ef0a713d1ee0d4f7866b3825cf1308"; export const MALE_TOKEN = "f3a7543b44ef0a713d1ee0d4f7866b3825cf1308";
export const MALE_2_TOKEN = "56cd0fceb93f7cecf7ffa84c590135026cf54a1d"; export const MALE_2_TOKEN = "56cd0fceb93f7cecf7ffa84c590135026cf54a1d";
@ -15,32 +18,130 @@ export const MALE_EMAIL = "[email protected]";
export const MALE_2_EMAIL = "[email protected]"; export const MALE_2_EMAIL = "[email protected]";
const TOKEN_COOKIE_NAME = "HABIB_TOKEN"; 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 = { type TokenSwitcherProps = {
variant?: "default" | "transparent"; variant?: "default" | "transparent";
className?: string; className?: string;
inlineAccounts?: DevAccountItem[];
}; };
export function TokenSwitcher({ export function TokenSwitcher({
variant = "default", variant = "default",
className, className,
inlineAccounts = [],
}: TokenSwitcherProps) { }: TokenSwitcherProps) {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [currentToken, setCurrentToken] = useState<string>(""); 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 [customTokenInput, setCustomTokenInput] = useState<string>("");
const [isCustomInputOpen, setIsCustomInputOpen] = useState(false);
const [selectedEmoji, setSelectedEmoji] = useState<string>("🔑");
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
if (typeof window !== "undefined") { 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(TOKEN_COOKIE_NAME) ??
getClientCookie("habib_token") ?? getClientCookie("habib_token") ??
sessionStorage.getItem(TOKEN_COOKIE_NAME) ?? 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; 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") { if (typeof window !== "undefined") {
try { try {
window.localStorage.clear();
window.sessionStorage.clear();
window.localStorage.setItem(
DEV_CUSTOM_TOKENS_STORAGE_KEY,
JSON.stringify(accounts),
);
} catch (e) { } 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); setCachedMarriageEntryPath(null);
setClientCookie(TOKEN_COOKIE_NAME, trimmed);
setClientCookie("habib_token", trimmed);
setClientCookie(TOKEN_COOKIE_NAME, cleanToken);
setClientCookie("habib_token", cleanToken);
try { try {
sessionStorage.setItem(TOKEN_COOKIE_NAME, trimmed);
sessionStorage.setItem(TOKEN_COOKIE_NAME, cleanToken);
} catch (e) { } catch (e) {
console.warn("sessionStorage is not accessible:", 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); setIsOpen(false);
window.location.replace("/"); 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 handleResetUser = async (userId: number | null, label: string) => {
const message = const message =
userId !== null userId !== null
? `آیا از پاک کردن تمامی اطلاعات هویتی، پاسخ‌ها و پیشرفت این کاربر (${label}) و شروع مجدد از مرحله اول آنبوردینگ مطمئن هستید؟` ? `آیا از پاک کردن تمامی اطلاعات هویتی، پاسخ‌ها و پیشرفت این کاربر (${label}) و شروع مجدد از مرحله اول آنبوردینگ مطمئن هستید؟`
: `آیا از پاک کردن تمامی اطلاعات محلی و کوکی‌های مربوط به این حالت (بدون توکن) و شروع مجدد مطمئن هستید؟`;
: `آیا از پاک کردن تمامی اطلاعات محلی و کوکی‌های مربوط به این حالت (${label}) و شروع مجدد مطمئن هستید؟`;
if (!window.confirm(message)) { if (!window.confirm(message)) {
return; return;
} }
try { 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) { if (userId !== null) {
const response = await fetch("/api/dev-reset-profile", { const response = await fetch("/api/dev-reset-profile", {
@ -128,63 +338,45 @@ export function TokenSwitcher({
? MALE_2_TOKEN ? MALE_2_TOKEN
: "NO_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) { } catch (error) {
console.error("Failed to reset user:", error); console.error("Failed to reset user:", error);
alert("خطایی در انجام عملیات رخ داد."); 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 ( return (
<> <>
{/* Floating Badge (وقتی بسته است فقط نام حساب و ایموجی را نمایش می‌دهد) */}
<button <button
type="button" type="button"
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
aria-label="تغییر توکن کاربر (تست)" aria-label="تغییر توکن کاربر (تست)"
title="تغییر توکن (آقا / خانم)"
title="تغییر توکن کاربر (محیط توسعه)"
className={[ 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" variant === "transparent"
? "bg-black/40 text-white hover:bg-black/50" ? "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", : "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) .filter(Boolean)
.join(" ")} .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> </button>
{isOpen && ( {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 <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 */} {/* 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 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="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" /> <MdOutlineSwitchAccount className="size-5" />
</div> </div>
<div> <div>
@ -227,67 +417,300 @@ export function TokenSwitcher({
انتخاب حساب کاربری (تست) انتخاب حساب کاربری (تست)
</h3> </h3>
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="text-xs text-slate-500 dark:text-slate-400">
تغییر سریع پروفایل و توکن جهت تست
تغییر سریع پروفایل، نامگذاری و مدیریت توکنها
</p> </p>
</div> </div>
</div> </div>
<button <button
type="button" type="button"
onClick={() => setIsOpen(false)} 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" /> <IoClose className="size-5" />
</button> </button>
</div> </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 <div
key={acc.token}
className={[ className={[
"flex items-center justify-between gap-3 p-3 rounded-2xl border transition-all select-none", "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-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", : "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(" ")} ].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"> <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>
<div className="flex-1 min-w-0 flex flex-col justify-center gap-0.5 text-right"> <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 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>
<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> </span>
</div> </div>
</div>
</button>
<div className="flex items-center gap-1.5 shrink-0 pr-1"> <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 className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-rose-500 text-white shadow-xs">
فعال فعال
</span> </span>
)} )}
<button <button
type="button" type="button"
onClick={() => handleResetUser(acc.id, acc.title)}
onClick={() => handleResetUser(null, "کاربر بدون توکن")}
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" 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> </button>
</div> </div>
</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>
<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 <button
key={emoji}
type="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"
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> </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>
<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>
</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 <input
type="text" type="text"
value={customTokenInput} value={customTokenInput}
onChange={(e) => setCustomTokenInput(e.target.value)} onChange={(e) => setCustomTokenInput(e.target.value)}
placeholder="توکن ۳۲/۴۰ کاراکتری..."
placeholder="رشته توکن (۳۲ یا ۴۰ کاراکتری)..."
dir="ltr" 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 <button
type="button" type="button"
disabled={!customTokenInput.trim()} 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> </button>
</div> </div>
</div>
</form>
</div> </div>
{/* Modal Footer */} {/* Modal Footer */}

15
src/components/Componentes/use-sheet-scroll-lock.ts

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
let activeSheetCount = 0; let activeSheetCount = 0;
let bodyHadDropdownClass = false; let bodyHadDropdownClass = false;
@ -23,6 +24,18 @@ export function useSheetScrollLock(
const onBackRef = useRef(onBack); const onBackRef = useRef(onBack);
onBackRef.current = onBack; onBackRef.current = onBack;
// Register in the hardware-back handler stack so that Flutter's
// __habibHandleHardwareBack() closes the sheet instead of navigating.
const handleHardwareBack = useCallback(() => {
if (onBackRef.current) {
onBackRef.current();
return true; // handled — sheet closed
}
return false;
}, []);
useHardwareBackHandler(handleHardwareBack, isOpen);
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;

2
src/components/ui/index.ts

@ -0,0 +1,2 @@
export * from "./input";
export * from "./textarea";

37
src/components/ui/input.tsx

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

36
src/components/ui/textarea.tsx

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

1
src/hooks/marriage/query-keys.ts

@ -2,6 +2,7 @@ import type { CaseId } from "./types";
export const marriageQueryKeys = { export const marriageQueryKeys = {
all: ["marriage"] as const, all: ["marriage"] as const,
config: () => [...marriageQueryKeys.all, "config"] as const,
contactInfo: (caseId: CaseId | "") => contactInfo: (caseId: CaseId | "") =>
[ [
...marriageQueryKeys.all, ...marriageQueryKeys.all,

27
src/hooks/marriage/use-habcoin-inventory.ts

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

33
src/hooks/marriage/use-habcoin-payment.ts

@ -6,6 +6,7 @@ import type { MutationOptions } from "./options";
import { marriageQueryKeys } from "./query-keys"; import { marriageQueryKeys } from "./query-keys";
const HABCOIN_PAY_SERVICE = "marriagesubscriptionplan" as const; const HABCOIN_PAY_SERVICE = "marriagesubscriptionplan" as const;
const HABCOIN_PAY_APP_NAME = "marriage" as const;
type HabcoinPaymentResponse = unknown; type HabcoinPaymentResponse = unknown;
@ -54,20 +55,38 @@ export function extractHabcoinPaymentUrl(response: HabcoinPaymentResponse) {
return null; return null;
} }
export async function getHabcoinPayment(objectId: number) {
export type HabcoinPaymentParams =
| number
| {
objectId: number;
discountCode?: string;
};
export async function getHabcoinPayment(params: HabcoinPaymentParams) {
const objectId = typeof params === "number" ? params : params.objectId;
const discountCode =
typeof params === "number" ? undefined : params.discountCode;
const queryParams: Record<string, unknown> = {
token: getSecurityToken(),
service: HABCOIN_PAY_SERVICE,
app_name: HABCOIN_PAY_APP_NAME,
object_id: objectId,
};
if (discountCode) {
queryParams.discount_code = discountCode;
}
const { data } = await http.get<HabcoinPaymentResponse>("/habcoin/pay/", { const { data } = await http.get<HabcoinPaymentResponse>("/habcoin/pay/", {
params: {
token: getSecurityToken(),
service: HABCOIN_PAY_SERVICE,
object_id: objectId,
},
params: queryParams,
}); });
return data; return data;
} }
export function useHabcoinPaymentMutation( export function useHabcoinPaymentMutation(
options?: MutationOptions<HabcoinPaymentResponse, number>,
options?: MutationOptions<HabcoinPaymentResponse, HabcoinPaymentParams>,
) { ) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();

3
src/hooks/marriage/use-marriage-config.ts

@ -2,6 +2,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { http } from "@/lib/http"; import { http } from "@/lib/http";
import { marriageQueryKeys } from "./query-keys";
export type MarriageConfig = { export type MarriageConfig = {
intro_video_url: string; intro_video_url: string;
@ -17,7 +18,7 @@ export async function getMarriageConfig() {
export function useMarriageConfigQuery() { export function useMarriageConfigQuery() {
return useQuery({ return useQuery({
queryKey: ["marriage", "config"],
queryKey: marriageQueryKeys.config(),
queryFn: getMarriageConfig, queryFn: getMarriageConfig,
}); });
} }

36
src/hooks/marriage/use-profile-main.ts

@ -12,25 +12,45 @@ import { marriageQueryKeys } from "./query-keys";
import type { MarriageProfileResponse } from "./types"; import type { MarriageProfileResponse } from "./types";
export async function getMarriageProfile() { export async function getMarriageProfile() {
const { data } = await http.get<MarriageProfileResponse>(
"/api/marriage/profile/main/",
);
console.log("🌐 [Profile API] Sending GET /api/marriage/profile/main/ ...");
try {
const { data } = await http.get<MarriageProfileResponse>(
"/api/marriage/profile/main/",
);
setCachedMarriageEntryPath(
hasCompletedMarriageProfileBasics(data) ? getSubmitPath(data) : null,
);
console.log("🌐 [Profile API] Successfully received data:", {
id: data?.id,
status: data?.status,
gender: data?.gender,
is_ready_for_match: data?.is_ready_for_match,
active_case: data?.active_case,
has_match_summary: Boolean(data?.match_summary),
match_summary_id: data?.match_summary?.id,
public_info_count: data?.match_summary?.public_info?.length,
raw_match_summary: data?.match_summary,
});
return data;
setCachedMarriageEntryPath(
hasCompletedMarriageProfileBasics(data) ? getSubmitPath(data) : null,
);
return data;
} catch (error) {
console.error("❌ [Profile API] Failed to fetch profile:", error);
throw error;
}
} }
export function useMarriageProfileQuery<TData = MarriageProfileResponse>( export function useMarriageProfileQuery<TData = MarriageProfileResponse>(
options?: QueryOptions<MarriageProfileResponse, TData>, options?: QueryOptions<MarriageProfileResponse, TData>,
) { ) {
return useQuery({
const query = useQuery({
staleTime: 10 * 1000, staleTime: 10 * 1000,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
...options, ...options,
queryFn: getMarriageProfile, queryFn: getMarriageProfile,
queryKey: marriageQueryKeys.profile(), queryKey: marriageQueryKeys.profile(),
}); });
return query;
} }

78
src/hooks/marriage/use-validate-discount.ts

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

12
src/hooks/use-current-profile-id.ts

@ -0,0 +1,12 @@
"use client";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
/**
* Returns the current authenticated user's profile ID.
* Returns null when profile hasn't loaded yet or user is unauthenticated.
*/
export function useCurrentProfileId(): number | null {
const { data: profile } = useMarriageProfileQuery();
return profile?.id ?? null;
}

25
src/hooks/use-habib-web-ready.ts

@ -0,0 +1,25 @@
"use client";
import { useEffect } from "react";
/**
* Shared destination readiness hook.
*
* Call this from final destination client components to signal that the page UI
* is ready and Flutter can remove its native loading cover.
*
* This hook only **requests** readiness it does not directly interact with
* the Flutter bridge. The root bootstrap script in layout.tsx owns actual
* delivery and handles the case where HabibApp is injected late.
*
* @param ready - Whether the page considers itself visually ready.
* Pass `true` once critical data is available or the fallback
* UI is showing. The signal fires at most once per mount.
*/
export function useHabibWebReady(ready: boolean) {
useEffect(() => {
if (ready && typeof window !== "undefined") {
window.__announceHabibWebReady?.();
}
}, [ready]);
}

84
src/hooks/use-hardware-back-handler.ts

@ -0,0 +1,84 @@
"use client";
import { useEffect, useRef } from "react";
/**
* Global stack of hardware-back handlers.
*
* When Flutter sends a hardware back event, the root handler in layout.tsx
* pops the last handler from this stack and calls it. If the handler returns
* true, it means "I handled it — keep the WebView open". If it returns false
* (or the stack is empty), Flutter should close the WebView screen.
*
* Pages register themselves with useHardwareBackHandler(). Sheets and modals
* also register the last one wins, matching the visual stacking order.
*/
const backHandlerStack: Array<() => boolean | Promise<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;

51
src/hooks/useFlutterBridge.ts

@ -219,53 +219,34 @@ export function useFlutterBridge(
}; };
}, [onEvent, enableLogging]); }, [onEvent, enableLogging]);
// تشخیص آمادگی واقعی + ارسال خودکار WEB_READY
// در WebView واقعی Flutter، شیء window.HabibApp توسط addJavaScriptChannel
// تزریق می‌شود؛ وجودش یعنی پل برقرار است. منتظر INITIAL_CONFIG نمی‌مانیم،
// چون Flutter چنین ایونتی نمی‌فرستد.
// Detect HabibApp bridge availability and set isReady state.
// web_ready delivery is owned exclusively by the root bootstrap script
// in layout.tsx — this hook must NOT independently send web_ready.
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
let interval: ReturnType<typeof setInterval> | undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;
const markReadyAndAnnounce = () => {
if (!window.HabibApp?.postMessage) return false;
if (window.HabibApp?.postMessage) {
setIsReady(true); setIsReady(true);
return;
}
// Single web_ready per page load: the root bootstrap script owns the
// flag (window.__habibWebReadySent). If it already announced, this
// hook must not send a second web_ready that would wake a duplicate
// initial_config round-trip.
if (!window.__habibWebReadySent) {
window.__habibWebReadySent = true;
sendToFlutter("WEB_READY", {
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: Date.now(),
});
addLog("✅ WEB_READY به‌صورت خودکار ارسال شد", "success");
}
return true;
};
let interval: ReturnType<typeof setInterval> | undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;
// اگر کانال هنوز تزریق نشده، کمی صبر می‌کنیم (تزریق ممکن است با تأخیر باشد)
if (!markReadyAndAnnounce()) {
interval = setInterval(() => {
if (markReadyAndAnnounce() && interval) clearInterval(interval);
}, 100);
timeout = setTimeout(() => {
interval = setInterval(() => {
if (window.HabibApp?.postMessage) {
setIsReady(true);
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
}, 5000);
}
}
}, 100);
timeout = setTimeout(() => {
if (interval) clearInterval(interval);
}, 5000);
return () => { return () => {
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
if (timeout) clearTimeout(timeout); if (timeout) clearTimeout(timeout);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// گوش‌دادن به پاسخ‌های واقعی Flutter از مسیر window.onFlutterResponse. // گوش‌دادن به پاسخ‌های واقعی Flutter از مسیر window.onFlutterResponse.

89
src/lib/ssr-fetch.ts

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

163
src/lib/user-scoped-storage.test.ts

@ -0,0 +1,163 @@
import { describe, expect, it, beforeEach } from "vitest";
import {
SCOPED_STORAGE_VERSION,
getScopedSectionDraftKey,
getScopedAssessmentDraftKey,
readScopedSectionDraft,
readScopedAssessmentDraft,
writeScopedSectionDraft,
writeScopedAssessmentDraft,
removeScopedSectionDraft,
removeScopedAssessmentDraft,
migrateLegacyStorageKeys,
isLegacyMigrationDone,
isDraftOwnedBy,
getAllScopedSectionDraftKeys,
extractSlugFromScopedKey,
} from "./user-scoped-storage";
describe("user-scoped-storage", () => {
beforeEach(() => {
window.localStorage.clear();
});
describe("Key generators", () => {
it("generates profile-scoped section draft keys with version", () => {
const key = getScopedSectionDraftKey(42, "personal_info");
expect(key).toBe(`marriage:user:42:sections:personal_info:draft:v${SCOPED_STORAGE_VERSION}`);
});
it("generates profile-scoped assessment draft keys with version", () => {
const key = getScopedAssessmentDraftKey(42, "personality_test");
expect(key).toBe(`marriage:user:42:tests:personality_test:draft:v${SCOPED_STORAGE_VERSION}`);
});
it("extracts slug correctly from scoped key", () => {
const key = getScopedSectionDraftKey(101, "family_background");
expect(extractSlugFromScopedKey(key)).toBe("family_background");
});
});
describe("Section draft isolation across accounts", () => {
it("isolates Account A edits from Account B", () => {
const accountAProfileId = 1001;
const accountBProfileId = 2002;
// Account A writes a draft
writeScopedSectionDraft(accountAProfileId, "job_info", {
job_title: {
key: "job_title",
label: "Job Title",
type: "text",
value: "Engineer",
},
});
// Account A can read it
const draftA = readScopedSectionDraft(accountAProfileId, "job_info");
expect(draftA).not.toBeNull();
expect(draftA?.pending.job_title.value).toBe("Engineer");
expect(draftA?.ownerProfileId).toBe(accountAProfileId);
// Account B CANNOT read Account A's draft
const draftB = readScopedSectionDraft(accountBProfileId, "job_info");
expect(draftB).toBeNull();
});
it("removes draft when pending is empty", () => {
const profileId = 123;
writeScopedSectionDraft(profileId, "bio", {
about: { key: "about", label: "About", type: "text", value: "Hello" },
});
expect(readScopedSectionDraft(profileId, "bio")).not.toBeNull();
// Write empty pending map -> draft is removed
writeScopedSectionDraft(profileId, "bio", {});
expect(readScopedSectionDraft(profileId, "bio")).toBeNull();
});
it("removes draft explicitly via removeScopedSectionDraft", () => {
const profileId = 123;
writeScopedSectionDraft(profileId, "bio", {
about: { key: "about", label: "About", type: "text", value: "Hello" },
});
removeScopedSectionDraft(profileId, "bio");
expect(readScopedSectionDraft(profileId, "bio")).toBeNull();
});
it("lists all scoped draft keys for a given profile", () => {
writeScopedSectionDraft(1, "sec_1", { q: { key: "q", label: "Q", type: "text", value: "A" } });
writeScopedSectionDraft(1, "sec_2", { q: { key: "q", label: "Q", type: "text", value: "B" } });
writeScopedSectionDraft(2, "sec_3", { q: { key: "q", label: "Q", type: "text", value: "C" } });
const keys1 = getAllScopedSectionDraftKeys(1);
expect(keys1).toHaveLength(2);
expect(keys1.map(extractSlugFromScopedKey)).toEqual(expect.arrayContaining(["sec_1", "sec_2"]));
const keys2 = getAllScopedSectionDraftKeys(2);
expect(keys2).toHaveLength(1);
expect(extractSlugFromScopedKey(keys2[0])).toBe("sec_3");
});
});
describe("Assessment draft isolation across accounts", () => {
it("isolates assessment answers across accounts", () => {
const userA = 501;
const userB = 502;
writeScopedAssessmentDraft(userA, "personality_test", { 1: "A", 2: "B" }, 2, 10);
const draftA = readScopedAssessmentDraft(userA, "personality_test");
expect(draftA).not.toBeNull();
expect(draftA?.answers).toEqual({ 1: "A", 2: "B" });
expect(draftA?.currentIndex).toBe(2);
expect(draftA?.totalQuestions).toBe(10);
const draftB = readScopedAssessmentDraft(userB, "personality_test");
expect(draftB).toBeNull();
});
it("removes assessment draft explicitly", () => {
writeScopedAssessmentDraft(100, "glasser_5_needs_test", { 1: 5 }, 1, 20);
removeScopedAssessmentDraft(100, "glasser_5_needs_test");
expect(readScopedAssessmentDraft(100, "glasser_5_needs_test")).toBeNull();
});
});
describe("Legacy migration", () => {
it("removes old unscoped keys while preserving unrelated localStorage", () => {
// Setup old keys
window.localStorage.setItem("marriage:sections:about:answers:v2", JSON.stringify({ fields: [] }));
window.localStorage.setItem("marriage:tests:personality_test:draft", JSON.stringify({ answers: { 1: "A" } }));
window.localStorage.setItem("marriage:sections:glasser_5_needs_test:answers", JSON.stringify({ completed: true }));
// Setup unrelated keys that MUST NOT be touched
window.localStorage.setItem("marriage:device_id", "device-12345");
window.localStorage.setItem("theme_preference", "dark");
expect(isLegacyMigrationDone()).toBe(false);
migrateLegacyStorageKeys();
expect(isLegacyMigrationDone()).toBe(true);
// Old keys should be gone
expect(window.localStorage.getItem("marriage:sections:about:answers:v2")).toBeNull();
expect(window.localStorage.getItem("marriage:tests:personality_test:draft")).toBeNull();
expect(window.localStorage.getItem("marriage:sections:glasser_5_needs_test:answers")).toBeNull();
// Unrelated keys must be preserved
expect(window.localStorage.getItem("marriage:device_id")).toBe("device-12345");
expect(window.localStorage.getItem("theme_preference")).toBe("dark");
});
});
describe("Ownership verification helper", () => {
it("verifies draft ownership correctly", () => {
expect(isDraftOwnedBy({ ownerProfileId: 99 }, 99)).toBe(true);
expect(isDraftOwnedBy({ ownerProfileId: 99 }, 100)).toBe(false);
expect(isDraftOwnedBy(null, 99)).toBe(false);
expect(isDraftOwnedBy(undefined, 99)).toBe(false);
});
});
});

285
src/lib/user-scoped-storage.ts

@ -0,0 +1,285 @@
/**
* User-scoped localStorage utilities.
*
* All draft/answer data is keyed by the authenticated user's profile ID
* so that Account A's unsynced edits can never be displayed or synced
* for Account B.
*
* The raw auth token is NEVER used as a key component.
*/
export const SCOPED_STORAGE_VERSION = 3;
// ─── Types ───────────────────────────────────────────────────────────
export interface ScopedSectionDraft {
version: typeof SCOPED_STORAGE_VERSION;
ownerProfileId: number;
slug: string;
pending: Record<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;
}

26
src/lib/webview-actions.test.ts

@ -4,6 +4,7 @@ import {
copyToClipboard, copyToClipboard,
downloadFile, downloadFile,
isInFlutterWebView, isInFlutterWebView,
openConsultantPage,
openExternalUrl, openExternalUrl,
postActionToFlutter, postActionToFlutter,
uploadFile, uploadFile,
@ -188,4 +189,29 @@ describe("webview-actions", () => {
); );
}); });
}); });
describe("openConsultantPage", () => {
it("dispatches open_consultant_page action with username", () => {
const mockPostMessage = vi.fn();
window.HabibApp = { postMessage: mockPostMessage };
const result = openConsultantPage("sara_marriage_advisor");
expect(result).toBe(true);
expect(mockPostMessage).toHaveBeenCalledWith(
JSON.stringify({
action: "open_consultant_page",
data: {
consultant: "sara_marriage_advisor",
},
}),
);
});
it("returns false when not in flutter webview", () => {
delete (window as any).HabibApp;
const result = openConsultantPage("sara_marriage_advisor");
expect(result).toBe(false);
});
});
}); });

14
src/lib/webview-actions.ts

@ -166,3 +166,17 @@ export function uploadFile(options: UploadFileOptions): boolean {
export function buyHabibCoinPackages(): boolean { export function buyHabibCoinPackages(): boolean {
return postActionToFlutter("buy_coin"); return postActionToFlutter("buy_coin");
} }
// ─── open_consultant_page ────────────────────────────────
/**
* Ask Flutter to open the native Talk consultant single page.
*
* ```ts
* openConsultantPage("consultant_username");
* ```
*/
export function openConsultantPage(username: string): boolean {
return postActionToFlutter("open_consultant_page", { consultant: username });
}

20
src/translations/locales/ar.json

@ -1,4 +1,7 @@
{ {
"2": "2",
"70": "70",
"175": "175",
"### Family Religious Atmosphere Options\n\n* **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life.\n* **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society.\n* **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation.\n* **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### خيارات الجو الديني العائلي * **الدين والالتزام الصارم:** يحدد هذا عائلة مكرسة للغاية لأداء جميع **الواجبات الإلزامية**، والحفاظ بشكل صارم على **الحدود الدينية** (مثل قواعد المحارم)، ودعم **الطقوس والتعاليم الدينية** في جميع جوانب الحياة. * **متدين (ملتزم بالالتزامات):** يشير هذا إلى عائلة ملتزمة بأساسيات **الواجبات الدينية** (مثل الصلاة والصيام) و **الأخلاق الإسلامية**، التي تعيش ضمن الأطر القياسية لمجتمع ديني. * **التقليدية (التي تحترم القيم الدينية):** تصف هذه العائلة التي تلتزم بالقيم الأخلاقية و**تحترم الدين**، ولكن لا يجوز لها أن تنفذ بدقة كل **قانون أو التزام ديني** محدد. * **غير دينية / علمانية:** يمثل هذا عائلة لا تؤثر فيها **الطقوس والأطر الدينية** بشكل كبير على **نمط حياتهم اليومي أو علاقاتهم أو قراراتهم**، على الرغم من الاحترام العام للدين.", "### Family Religious Atmosphere Options\n\n* **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life.\n* **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society.\n* **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation.\n* **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### خيارات الجو الديني العائلي * **الدين والالتزام الصارم:** يحدد هذا عائلة مكرسة للغاية لأداء جميع **الواجبات الإلزامية**، والحفاظ بشكل صارم على **الحدود الدينية** (مثل قواعد المحارم)، ودعم **الطقوس والتعاليم الدينية** في جميع جوانب الحياة. * **متدين (ملتزم بالالتزامات):** يشير هذا إلى عائلة ملتزمة بأساسيات **الواجبات الدينية** (مثل الصلاة والصيام) و **الأخلاق الإسلامية**، التي تعيش ضمن الأطر القياسية لمجتمع ديني. * **التقليدية (التي تحترم القيم الدينية):** تصف هذه العائلة التي تلتزم بالقيم الأخلاقية و**تحترم الدين**، ولكن لا يجوز لها أن تنفذ بدقة كل **قانون أو التزام ديني** محدد. * **غير دينية / علمانية:** يمثل هذا عائلة لا تؤثر فيها **الطقوس والأطر الدينية** بشكل كبير على **نمط حياتهم اليومي أو علاقاتهم أو قراراتهم**، على الرغم من الاحترام العام للدين.",
"(Complete Required Forms)": "(إكمال النماذج المطلوبة)", "(Complete Required Forms)": "(إكمال النماذج المطلوبة)",
"(after 2 days)": "(بعد يومين)", "(after 2 days)": "(بعد يومين)",
@ -11,9 +14,7 @@
"1. Eligibility and Membership Requirements": "1. شروط الأهلية والعضوية", "1. Eligibility and Membership Requirements": "1. شروط الأهلية والعضوية",
"160 to 170": "160 إلى 170", "160 to 170": "160 إلى 170",
"170 to 180": "170 إلى 180", "170 to 180": "170 إلى 180",
"175": "175",
"180 to 190": "180 إلى 190", "180 to 190": "180 إلى 190",
"2": "2",
"2 minutes": "2 دقيقة", "2 minutes": "2 دقيقة",
"2. Privacy and Data Management": "2. الخصوصية وإدارة البيانات", "2. Privacy and Data Management": "2. الخصوصية وإدارة البيانات",
"25-30": "25-30", "25-30": "25-30",
@ -24,7 +25,6 @@
"5 minutes": "5 دقائق", "5 minutes": "5 دقائق",
"50 Coins": "50 قطعة نقدية", "50 Coins": "50 قطعة نقدية",
"6 minutes": "6 دقائق", "6 minutes": "6 دقائق",
"70": "70",
"8 minutes": "8 دقائق", "8 minutes": "8 دقائق",
"A Path to Heavenly Marriage": "الطريق إلى الزواج السماوي", "A Path to Heavenly Marriage": "الطريق إلى الزواج السماوي",
"A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "ليس هناك حاجة إلى عنوان دقيق. يكفي فقط المنطقة العامة للمكان الذي تعيش فيه، مثل المدينة أو المنطقة أو الحي أو أقرب مدينة رئيسية.", "A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "ليس هناك حاجة إلى عنوان دقيق. يكفي فقط المنطقة العامة للمكان الذي تعيش فيه، مثل المدينة أو المنطقة أو الحي أو أقرب مدينة رئيسية.",
@ -767,5 +767,17 @@
"Buy Habib Coins": "شراء عملات حبيب", "Buy Habib Coins": "شراء عملات حبيب",
"Subscription required": "الاشتراك مطلوب", "Subscription required": "الاشتراك مطلوب",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "لعرض الملفات الشخصية، تحتاج إلى اشتراك. يتضمن كل اشتراك ما يصل إلى {count} تطابقات.", "To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "لعرض الملفات الشخصية، تحتاج إلى اشتراك. يتضمن كل اشتراك ما يصل إلى {count} تطابقات.",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "لعرض الملفات الشخصية، يلزم الاشتراك. يساعد ذلك في تغطية خدمات الدعم والتوفيق ويسري فقط على المستخدمين الذكور"
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "لعرض الملفات الشخصية، يلزم الاشتراك. يساعد ذلك في تغطية خدمات الدعم والتوفيق ويسري فقط على المستخدمين الذكور",
"More detail": "مزيد من التفاصيل",
"{count} case introductions remaining of your subscription.": "{count} من حالات التعارف المتبقية من اشتراكك.",
"{days} days and {count} case introductions remaining of your subscription.": "{days} يوم و {count} من حالات التعارف المتبقية من اشتراكك.",
"You do not have enough Habib Coins": "ليس لديك عدد كافٍ من حبيب كوين",
"Please add more Habib Coins to your balance (or use discount code), so that you can use them to access this content": "يرجى إضافة المزيد من حبيب كوين إلى رصيدك (أو استخدم رمز الخصم) لتتمكن من الوصول إلى هذا المحتوى",
"Do you have a discount code?": "هل لديك رمز خصم؟",
"Enter discount code...": "أدخل رمز الخصم...",
"Apply": "تطبيق",
"Clear": "مسح",
"This field is required": "هذه الخانة مطلوبة",
"Habib Coins": "حبيب كوين",
"{} Habib Coins": "{} حبيب كوين"
} }

20
src/translations/locales/az.json

@ -1,4 +1,7 @@
{ {
"2": "2",
"70": "70",
"175": "175",
"### Family Religious Atmosphere Options\n\n* **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life.\n* **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society.\n* **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation.\n* **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### Ailə Dini Atmosfer Seçimləri * **Dini və Ciddi şəkildə əməl edən:** Bu, bütün **məcburi vəzifələri** yerinə yetirməyə, **dini sərhədləri** (məs., Məhrəm qaydaları kimi) ciddi şəkildə qorumağa və bütün aspektlərdə **dini ayin və təlimlərə** riayət etməyə yüksək dərəcədə bağlı olan ailəni göstərir. * **Dini (Vəziyyətlərə əməl edən):** Bu, əsas **dini vəzifələrə** (namaz və oruc kimi) və **İslam etikasına** sadiq olan, dini cəmiyyətin standart çərçivələri daxilində yaşayan ailəni göstərir. * **Ənənəvi (Dini Dəyərlərə Hörmətli):** Bu, əxlaqi dəyərlərə sadiq qalan və **dinə hörmət edən** ailəni təsvir edir, lakin hər bir xüsusi **dini qanunu** və ya öhdəliyi ciddi şəkildə yerinə yetirməyə bilməz. * **Dini olmayan / Dünyəvi:** Bu, **dini ayinlər və çərçivələrin** dinə ümumi hörmət bəsləməsinə baxmayaraq, onların gündəlik **həyat tərzinə, münasibətlərinə və ya qərarlarına** əhəmiyyətli dərəcədə təsir göstərməyən ailəni təmsil edir.", "### Family Religious Atmosphere Options\n\n* **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life.\n* **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society.\n* **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation.\n* **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### Ailə Dini Atmosfer Seçimləri * **Dini və Ciddi şəkildə əməl edən:** Bu, bütün **məcburi vəzifələri** yerinə yetirməyə, **dini sərhədləri** (məs., Məhrəm qaydaları kimi) ciddi şəkildə qorumağa və bütün aspektlərdə **dini ayin və təlimlərə** riayət etməyə yüksək dərəcədə bağlı olan ailəni göstərir. * **Dini (Vəziyyətlərə əməl edən):** Bu, əsas **dini vəzifələrə** (namaz və oruc kimi) və **İslam etikasına** sadiq olan, dini cəmiyyətin standart çərçivələri daxilində yaşayan ailəni göstərir. * **Ənənəvi (Dini Dəyərlərə Hörmətli):** Bu, əxlaqi dəyərlərə sadiq qalan və **dinə hörmət edən** ailəni təsvir edir, lakin hər bir xüsusi **dini qanunu** və ya öhdəliyi ciddi şəkildə yerinə yetirməyə bilməz. * **Dini olmayan / Dünyəvi:** Bu, **dini ayinlər və çərçivələrin** dinə ümumi hörmət bəsləməsinə baxmayaraq, onların gündəlik **həyat tərzinə, münasibətlərinə və ya qərarlarına** əhəmiyyətli dərəcədə təsir göstərməyən ailəni təmsil edir.",
"(Complete Required Forms)": "(Tələb olunan formaları doldurun)", "(Complete Required Forms)": "(Tələb olunan formaları doldurun)",
"(after 2 days)": "(2 gündən sonra)", "(after 2 days)": "(2 gündən sonra)",
@ -11,9 +14,7 @@
"1. Eligibility and Membership Requirements": "1. Uyğunluq və Üzvlük Tələbləri", "1. Eligibility and Membership Requirements": "1. Uyğunluq və Üzvlük Tələbləri",
"160 to 170": "160-170", "160 to 170": "160-170",
"170 to 180": "170-180", "170 to 180": "170-180",
"175": "175",
"180 to 190": "180-190", "180 to 190": "180-190",
"2": "2",
"2 minutes": "2 dəqiqə", "2 minutes": "2 dəqiqə",
"2. Privacy and Data Management": "2. Məxfilik və Məlumatların İdarə Edilməsi", "2. Privacy and Data Management": "2. Məxfilik və Məlumatların İdarə Edilməsi",
"25-30": "25-30", "25-30": "25-30",
@ -24,7 +25,6 @@
"5 minutes": "5 dəqiqə", "5 minutes": "5 dəqiqə",
"50 Coins": "50 qəpik", "50 Coins": "50 qəpik",
"6 minutes": "6 dəqiqə", "6 minutes": "6 dəqiqə",
"70": "70",
"8 minutes": "8 dəqiqə", "8 minutes": "8 dəqiqə",
"A Path to Heavenly Marriage": "Səmavi Evliliyə gedən yol", "A Path to Heavenly Marriage": "Səmavi Evliliyə gedən yol",
"A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "Dəqiq ünvan tələb olunmur. Yaşadığınız yerin ümumi ərazisi kifayətdir, məsələn, şəhər, rayon, məhəllə və ya ən yaxın böyük şəhər.", "A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "Dəqiq ünvan tələb olunmur. Yaşadığınız yerin ümumi ərazisi kifayətdir, məsələn, şəhər, rayon, məhəllə və ya ən yaxın böyük şəhər.",
@ -767,5 +767,17 @@
"Buy Habib Coins": "Həbib Sikkələri Al", "Buy Habib Coins": "Həbib Sikkələri Al",
"Subscription required": "Abunə tələb olunur", "Subscription required": "Abunə tələb olunur",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "Profilləri görmək üçün abunəyə ehtiyacınız var. Hər abunə {count} uyğunlaşmanı əhatə edir.", "To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "Profilləri görmək üçün abunəyə ehtiyacınız var. Hər abunə {count} uyğunlaşmanı əhatə edir.",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "Profilləri görmək üçün abunə tələb olunur. Bu, dəstək və uyğunlaşma xidmətlərimizi əhatə edir və yalnız kişi istifadəçilərə aiddir"
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "Profilləri görmək üçün abunə tələb olunur. Bu, dəstək və uyğunlaşma xidmətlərimizi əhatə edir və yalnız kişi istifadəçilərə aiddir",
"More detail": "Daha ətraflı",
"{count} case introductions remaining of your subscription.": "Abunəliyinizdən {count} təqdimat qaldı.",
"{days} days and {count} case introductions remaining of your subscription.": "Abunəliyinizdən {days} gün və {count} təqdimat qaldı.",
"You do not have enough Habib Coins": "Kifayət qədər Həbib Koininiz yoxdur",
"Please add more Habib Coins to your balance (or use discount code), so that you can use them to access this content": "Bu məzmuna daxil olmaq üçün balansınıza daha çox Həbib Coin əlavə edin (və ya endirim kodu istifadə edin)",
"Do you have a discount code?": "Endirim kodunuz var?",
"Enter discount code...": "Endirim kodunu daxil edin...",
"Apply": "Tətbiq et",
"Clear": "Təmizlə",
"This field is required": "Bu sahə zəruridir",
"Habib Coins": "Həbib Koin",
"{} Habib Coins": "{} Həbib Koin"
} }

20
src/translations/locales/bn.json

@ -1,4 +1,7 @@
{ {
"2": "2",
"70": "70",
"175": "175",
"### Family Religious Atmosphere Options\n\n* **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life.\n* **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society.\n* **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation.\n* **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### পারিবারিক ধর্মীয় পরিবেশের বিকল্পগুলি * **ধর্মীয় এবং কঠোরভাবে পর্যবেক্ষক:** এটি এমন একটি পরিবারকে নির্দিষ্ট করে যা সমস্ত **বাধ্যতামূলক দায়িত্ব** পালনের জন্য অত্যন্ত নিবেদিত, কঠোরভাবে **ধর্মীয় সীমানা** (যেমন মাহরাম নিয়ম) বজায় রাখতে এবং **জীবনের **ধর্মীয় আচার ও শিক্ষা**কে সমুন্নত রাখে। * **ধর্মীয় (দায়বদ্ধতা পালনকারী):** এটি একটি পরিবারকে নির্দেশ করে যে মূল **ধর্মীয় কর্তব্য** (যেমন প্রার্থনা এবং উপবাস) এবং **ইসলামিক নীতি**, একটি ধর্মীয় সমাজের মানক কাঠামোর মধ্যে বসবাস করে। * **ঐতিহ্যগত (ধর্মীয় মূল্যবোধের প্রতি শ্রদ্ধাশীল):** এটি এমন একটি পরিবারকে বর্ণনা করে যেটি নৈতিক মূল্যবোধের প্রতি ভক্তি রাখে এবং **ধর্মকে সম্মান করে**, কিন্তু প্রতিটি নির্দিষ্ট **ধর্মীয় আইন** বা বাধ্যবাধকতা কঠোরভাবে পালন নাও করতে পারে। * **অধর্মীয় / ধর্মনিরপেক্ষ:** এটি এমন একটি পরিবারের প্রতিনিধিত্ব করে যেখানে **ধর্মীয় আচার-অনুষ্ঠান এবং কাঠামো** তাদের দৈনন্দিন **জীবনধারা, সম্পর্ক বা সিদ্ধান্ত**কে উল্লেখযোগ্যভাবে প্রভাবিত করে না, যদিও ধর্মের প্রতি সাধারণ শ্রদ্ধা রয়েছে।", "### Family Religious Atmosphere Options\n\n* **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life.\n* **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society.\n* **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation.\n* **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### পারিবারিক ধর্মীয় পরিবেশের বিকল্পগুলি * **ধর্মীয় এবং কঠোরভাবে পর্যবেক্ষক:** এটি এমন একটি পরিবারকে নির্দিষ্ট করে যা সমস্ত **বাধ্যতামূলক দায়িত্ব** পালনের জন্য অত্যন্ত নিবেদিত, কঠোরভাবে **ধর্মীয় সীমানা** (যেমন মাহরাম নিয়ম) বজায় রাখতে এবং **জীবনের **ধর্মীয় আচার ও শিক্ষা**কে সমুন্নত রাখে। * **ধর্মীয় (দায়বদ্ধতা পালনকারী):** এটি একটি পরিবারকে নির্দেশ করে যে মূল **ধর্মীয় কর্তব্য** (যেমন প্রার্থনা এবং উপবাস) এবং **ইসলামিক নীতি**, একটি ধর্মীয় সমাজের মানক কাঠামোর মধ্যে বসবাস করে। * **ঐতিহ্যগত (ধর্মীয় মূল্যবোধের প্রতি শ্রদ্ধাশীল):** এটি এমন একটি পরিবারকে বর্ণনা করে যেটি নৈতিক মূল্যবোধের প্রতি ভক্তি রাখে এবং **ধর্মকে সম্মান করে**, কিন্তু প্রতিটি নির্দিষ্ট **ধর্মীয় আইন** বা বাধ্যবাধকতা কঠোরভাবে পালন নাও করতে পারে। * **অধর্মীয় / ধর্মনিরপেক্ষ:** এটি এমন একটি পরিবারের প্রতিনিধিত্ব করে যেখানে **ধর্মীয় আচার-অনুষ্ঠান এবং কাঠামো** তাদের দৈনন্দিন **জীবনধারা, সম্পর্ক বা সিদ্ধান্ত**কে উল্লেখযোগ্যভাবে প্রভাবিত করে না, যদিও ধর্মের প্রতি সাধারণ শ্রদ্ধা রয়েছে।",
"(Complete Required Forms)": "(প্রয়োজনীয় ফর্ম সম্পূর্ণ করুন)", "(Complete Required Forms)": "(প্রয়োজনীয় ফর্ম সম্পূর্ণ করুন)",
"(after 2 days)": "(২ দিন পর)", "(after 2 days)": "(২ দিন পর)",
@ -11,9 +14,7 @@
"1. Eligibility and Membership Requirements": "1. যোগ্যতা এবং সদস্যতার প্রয়োজনীয়তা", "1. Eligibility and Membership Requirements": "1. যোগ্যতা এবং সদস্যতার প্রয়োজনীয়তা",
"160 to 170": "160 থেকে 170", "160 to 170": "160 থেকে 170",
"170 to 180": "170 থেকে 180", "170 to 180": "170 থেকে 180",
"175": "175",
"180 to 190": "180 থেকে 190", "180 to 190": "180 থেকে 190",
"2": "2",
"2 minutes": "2 মিনিট", "2 minutes": "2 মিনিট",
"2. Privacy and Data Management": "2. গোপনীয়তা এবং ডেটা ব্যবস্থাপনা", "2. Privacy and Data Management": "2. গোপনীয়তা এবং ডেটা ব্যবস্থাপনা",
"25-30": "25-30", "25-30": "25-30",
@ -24,7 +25,6 @@
"5 minutes": "5 মিনিট", "5 minutes": "5 মিনিট",
"50 Coins": "50 কয়েন", "50 Coins": "50 কয়েন",
"6 minutes": "6 মিনিট", "6 minutes": "6 মিনিট",
"70": "70",
"8 minutes": "8 মিনিট", "8 minutes": "8 মিনিট",
"A Path to Heavenly Marriage": "স্বর্গীয় বিবাহের পথ", "A Path to Heavenly Marriage": "স্বর্গীয় বিবাহের পথ",
"A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "একটি সুনির্দিষ্ট ঠিকানা প্রয়োজন হয় না. আপনি যেখানে বাস করেন তার সাধারণ এলাকাটিই যথেষ্ট, যেমন শহর, অঞ্চল, পাড়া বা নিকটতম প্রধান শহর৷", "A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "একটি সুনির্দিষ্ট ঠিকানা প্রয়োজন হয় না. আপনি যেখানে বাস করেন তার সাধারণ এলাকাটিই যথেষ্ট, যেমন শহর, অঞ্চল, পাড়া বা নিকটতম প্রধান শহর৷",
@ -767,5 +767,17 @@
"Buy Habib Coins": "হাবিব কয়েন কিনুন", "Buy Habib Coins": "হাবিব কয়েন কিনুন",
"Subscription required": "সাবস্ক্রিপশন প্রয়োজন", "Subscription required": "সাবস্ক্রিপশন প্রয়োজন",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "প্রোফাইল দেখতে আপনার সাবস্ক্রিপশন প্রয়োজন। প্রতিটি সাবস্ক্রিপশনে {count}টি ম্যাচ অন্তর্ভুক্ত।", "To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "প্রোফাইল দেখতে আপনার সাবস্ক্রিপশন প্রয়োজন। প্রতিটি সাবস্ক্রিপশনে {count}টি ম্যাচ অন্তর্ভুক্ত।",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "প্রোফাইল দেখতে সাবস্ক্রিপশন প্রয়োজন। এটি আমাদের সহায়তা এবং ম্যাচিং পরিষেবার খরচ বহন করতে সাহায্য করে এবং শুধুমাত্র পুরুষ ব্যবহারকারীদের জন্য প্রযোজ্য"
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "প্রোফাইল দেখতে সাবস্ক্রিপশন প্রয়োজন। এটি আমাদের সহায়তা এবং ম্যাচিং পরিষেবার খরচ বহন করতে সাহায্য করে এবং শুধুমাত্র পুরুষ ব্যবহারকারীদের জন্য প্রযোজ্য",
"More detail": "আরও বিস্তারিত",
"{count} case introductions remaining of your subscription.": "আপনার সাবস্ক্রিপশনের {count}টি পরিচিতি বাকি আছে।",
"{days} days and {count} case introductions remaining of your subscription.": "আপনার সাবস্ক্রিপশনের {days} দিন এবং {count}টি পরিচিতি বাকি আছে।",
"You do not have enough Habib Coins": "আপনার পর্যাপ্ত হাবিব কয়েন নেই",
"Please add more Habib Coins to your balance (or use discount code), so that you can use them to access this content": "অনুগ্রহ করে আপনার ব্যালেন্সে আরও হাবিব কয়েন যোগ করুন (অথবা ডিসকাউন্ট কোড ব্যবহার করুন) যাতে আপনি এই বিষয়বস্তু অ্যাক্সেস করতে পারেন",
"Do you have a discount code?": "আপনার কি ডিসকাউন্ট কোড আছে?",
"Enter discount code...": "ডিসকাউন্ট কোড লিখুন...",
"Apply": "প্রয়োগ",
"Clear": "মুছুন",
"This field is required": "এই ক্ষেত্রটি আবশ্যক",
"Habib Coins": "হাবিব কয়েন",
"{} Habib Coins": "{} হাবিব কয়েন"
} }

20
src/translations/locales/da.json

@ -1,4 +1,7 @@
{ {
"2": "2",
"70": "70",
"175": "175",
"### Family Religious Atmosphere Options\n\n* **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life.\n* **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society.\n* **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation.\n* **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### Valgmuligheder for familiereligiøs atmosfære * **Religiøs og strengt observant:** Dette specificerer en familie, der er meget dedikeret til at udføre alle **obligatoriske pligter**, strengt opretholde **religiøse grænser** (såsom Mahram-regler) og opretholde **religiøse ritualer og lære** på tværs af alle aspekter af livet. * **Religiøs (Observant of Obligations):** Dette indikerer en familie, der er forpligtet til kerne **religiøse pligter** (såsom bøn og faste) og **islamisk etik**, der lever inden for et religiøst samfunds standardrammer. * **Traditionelt (respekterer religiøse værdier):** Dette beskriver en familie, der holder hengivenhed til moralske værdier og **respekterer religion**, men som måske ikke strengt udfører enhver specifik **religiøs lov** eller forpligtelse. * **Ikke-religiøs/sekulær:** Dette repræsenterer en familie, hvor **religiøse ritualer og rammer** ikke har væsentlig indflydelse på deres daglige **livsstil, forhold eller beslutninger**, på trods af at de har en generel respekt for religion.", "### Family Religious Atmosphere Options\n\n* **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life.\n* **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society.\n* **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation.\n* **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### Valgmuligheder for familiereligiøs atmosfære * **Religiøs og strengt observant:** Dette specificerer en familie, der er meget dedikeret til at udføre alle **obligatoriske pligter**, strengt opretholde **religiøse grænser** (såsom Mahram-regler) og opretholde **religiøse ritualer og lære** på tværs af alle aspekter af livet. * **Religiøs (Observant of Obligations):** Dette indikerer en familie, der er forpligtet til kerne **religiøse pligter** (såsom bøn og faste) og **islamisk etik**, der lever inden for et religiøst samfunds standardrammer. * **Traditionelt (respekterer religiøse værdier):** Dette beskriver en familie, der holder hengivenhed til moralske værdier og **respekterer religion**, men som måske ikke strengt udfører enhver specifik **religiøs lov** eller forpligtelse. * **Ikke-religiøs/sekulær:** Dette repræsenterer en familie, hvor **religiøse ritualer og rammer** ikke har væsentlig indflydelse på deres daglige **livsstil, forhold eller beslutninger**, på trods af at de har en generel respekt for religion.",
"(Complete Required Forms)": "(Complete Required Forms)", "(Complete Required Forms)": "(Complete Required Forms)",
"(after 2 days)": "(after 2 days)", "(after 2 days)": "(after 2 days)",
@ -11,9 +14,7 @@
"1. Eligibility and Membership Requirements": "1. Eligibility and Membership Requirements", "1. Eligibility and Membership Requirements": "1. Eligibility and Membership Requirements",
"160 to 170": "160 til 170", "160 to 170": "160 til 170",
"170 to 180": "170 til 180", "170 to 180": "170 til 180",
"175": "175",
"180 to 190": "180 til 190", "180 to 190": "180 til 190",
"2": "2",
"2 minutes": "2 minutter", "2 minutes": "2 minutter",
"2. Privacy and Data Management": "2. Privacy and Data Management", "2. Privacy and Data Management": "2. Privacy and Data Management",
"25-30": "25-30", "25-30": "25-30",
@ -24,7 +25,6 @@
"5 minutes": "5 minutter", "5 minutes": "5 minutter",
"50 Coins": "50 Coins", "50 Coins": "50 Coins",
"6 minutes": "6 minutter", "6 minutes": "6 minutter",
"70": "70",
"8 minutes": "8 minutter", "8 minutes": "8 minutter",
"A Path to Heavenly Marriage": "A Path to Heavenly Marriage", "A Path to Heavenly Marriage": "A Path to Heavenly Marriage",
"A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "En præcis adresse er ikke påkrævet. Bare det generelle område, hvor du bor, er tilstrækkeligt, såsom byen, regionen, kvarteret eller den nærmeste større by.", "A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "En præcis adresse er ikke påkrævet. Bare det generelle område, hvor du bor, er tilstrækkeligt, såsom byen, regionen, kvarteret eller den nærmeste større by.",
@ -767,5 +767,17 @@
"Buy Habib Coins": "Køb Habib-mønter", "Buy Habib Coins": "Køb Habib-mønter",
"Subscription required": "Abonnement påkrævet", "Subscription required": "Abonnement påkrævet",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "For at se profiler har du brug for et abonnement. Hvert abonnement inkluderer op til {count} matches.", "To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "For at se profiler har du brug for et abonnement. Hvert abonnement inkluderer op til {count} matches.",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "For at se profiler kræves et abonnement. Dette hjælper med at dække vores support- og matchningstjenester og gælder kun for mandlige brugere"
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "For at se profiler kræves et abonnement. Dette hjælper med at dække vores support- og matchningstjenester og gælder kun for mandlige brugere",
"More detail": "Flere detaljer",
"{count} case introductions remaining of your subscription.": "{count} match-introduktioner tilbage af dit abonnement.",
"{days} days and {count} case introductions remaining of your subscription.": "{days} dage og {count} match-introduktioner tilbage af dit abonnement.",
"You do not have enough Habib Coins": "Du har ikke nok Habib-mønter",
"Please add more Habib Coins to your balance (or use discount code), so that you can use them to access this content": "Tilføj venligst flere Habib-mønt til din saldo (eller brug en rabatkode) for at få adgang til dette indhold",
"Do you have a discount code?": "Har du en rabatkode?",
"Enter discount code...": "Indtast rabatkode...",
"Apply": "Anvend",
"Clear": "Ryd",
"This field is required": "Dette felt er påkrævet",
"Habib Coins": "Habib-mønter",
"{} Habib Coins": "{} Habib-mønter"
} }

20
src/translations/locales/de.json

@ -1,4 +1,7 @@
{ {
"2": "2",
"70": "70",
"175": "175",
"### Family Religious Atmosphere Options\n\n* **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life.\n* **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society.\n* **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation.\n* **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### Optionen für die religiöse Atmosphäre in der Familie * **Religiös und strikt befolgend:** Dies bezeichnet eine Familie, die sich in hohem Maße der Erfüllung aller **obligatorischen Pflichten** widmet, die **religiösen Grenzen** (z. B. die Mahram-Regeln) strikt einhält und **religiöse Rituale und Lehren** in allen Aspekten des Lebens aufrechterhält. * **Religiös (observant of Obligations):** Dies weist auf eine Familie hin, die sich den grundlegenden **religiösen Pflichten** (wie Gebet und Fasten) und der **islamischen Ethik** verpflichtet und innerhalb der Standardrahmen einer religiösen Gesellschaft lebt. * **Traditionell (Respekt gegenüber religiösen Werten):** Dies beschreibt eine Familie, die moralischen Werten treu bleibt und **die Religion respektiert**, aber möglicherweise nicht jedes bestimmte **religiöse Gesetz** oder jede spezifische Verpflichtung strikt einhält. * **Nicht-religiös/säkular:** Dies stellt eine Familie dar, in der **religiöse Rituale und Rahmenbedingungen** ihren täglichen **Lebensstil, ihre Beziehungen oder Entscheidungen** nicht wesentlich beeinflussen, obwohl sie allgemein Respekt vor der Religion haben.", "### Family Religious Atmosphere Options\n\n* **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life.\n* **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society.\n* **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation.\n* **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### Optionen für die religiöse Atmosphäre in der Familie * **Religiös und strikt befolgend:** Dies bezeichnet eine Familie, die sich in hohem Maße der Erfüllung aller **obligatorischen Pflichten** widmet, die **religiösen Grenzen** (z. B. die Mahram-Regeln) strikt einhält und **religiöse Rituale und Lehren** in allen Aspekten des Lebens aufrechterhält. * **Religiös (observant of Obligations):** Dies weist auf eine Familie hin, die sich den grundlegenden **religiösen Pflichten** (wie Gebet und Fasten) und der **islamischen Ethik** verpflichtet und innerhalb der Standardrahmen einer religiösen Gesellschaft lebt. * **Traditionell (Respekt gegenüber religiösen Werten):** Dies beschreibt eine Familie, die moralischen Werten treu bleibt und **die Religion respektiert**, aber möglicherweise nicht jedes bestimmte **religiöse Gesetz** oder jede spezifische Verpflichtung strikt einhält. * **Nicht-religiös/säkular:** Dies stellt eine Familie dar, in der **religiöse Rituale und Rahmenbedingungen** ihren täglichen **Lebensstil, ihre Beziehungen oder Entscheidungen** nicht wesentlich beeinflussen, obwohl sie allgemein Respekt vor der Religion haben.",
"(Complete Required Forms)": "(Complete Required Forms)", "(Complete Required Forms)": "(Complete Required Forms)",
"(after 2 days)": "(after 2 days)", "(after 2 days)": "(after 2 days)",
@ -11,9 +14,7 @@
"1. Eligibility and Membership Requirements": "1. Eligibility and Membership Requirements", "1. Eligibility and Membership Requirements": "1. Eligibility and Membership Requirements",
"160 to 170": "160 bis 170", "160 to 170": "160 bis 170",
"170 to 180": "170 bis 180", "170 to 180": "170 bis 180",
"175": "175",
"180 to 190": "180 bis 190", "180 to 190": "180 bis 190",
"2": "2",
"2 minutes": "2 Minuten", "2 minutes": "2 Minuten",
"2. Privacy and Data Management": "2. Privacy and Data Management", "2. Privacy and Data Management": "2. Privacy and Data Management",
"25-30": "25-30", "25-30": "25-30",
@ -24,7 +25,6 @@
"5 minutes": "5 Minuten", "5 minutes": "5 Minuten",
"50 Coins": "50 Coins", "50 Coins": "50 Coins",
"6 minutes": "6 Minuten", "6 minutes": "6 Minuten",
"70": "70",
"8 minutes": "8 Minuten", "8 minutes": "8 Minuten",
"A Path to Heavenly Marriage": "A Path to Heavenly Marriage", "A Path to Heavenly Marriage": "A Path to Heavenly Marriage",
"A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "Eine genaue Adresse ist nicht erforderlich. Es reicht lediglich der allgemeine Bereich Ihres Wohnortes aus, beispielsweise die Stadt, die Region, das Viertel oder die nächstgelegene größere Stadt.", "A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "Eine genaue Adresse ist nicht erforderlich. Es reicht lediglich der allgemeine Bereich Ihres Wohnortes aus, beispielsweise die Stadt, die Region, das Viertel oder die nächstgelegene größere Stadt.",
@ -767,5 +767,17 @@
"Buy Habib Coins": "Habib-Münzen kaufen", "Buy Habib Coins": "Habib-Münzen kaufen",
"Subscription required": "Abonnement erforderlich", "Subscription required": "Abonnement erforderlich",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "Um Profile anzusehen, benötigen Sie ein Abonnement. Jedes Abonnement beinhaltet bis zu {count} Übereinstimmungen.", "To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "Um Profile anzusehen, benötigen Sie ein Abonnement. Jedes Abonnement beinhaltet bis zu {count} Übereinstimmungen.",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "Zum Anzeigen von Profilen ist ein Abonnement erforderlich. Dies hilft, unsere Support- und Matching-Dienste zu finanzieren und gilt nur für männliche Nutzer"
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "Zum Anzeigen von Profilen ist ein Abonnement erforderlich. Dies hilft, unsere Support- und Matching-Dienste zu finanzieren und gilt nur für männliche Nutzer",
"More detail": "Mehr Details",
"{count} case introductions remaining of your subscription.": "{count} Vorstellungen verbleiben von Ihrem Abonnement.",
"{days} days and {count} case introductions remaining of your subscription.": "{days} Tage und {count} Vorstellungen verbleiben von Ihrem Abonnement.",
"You do not have enough Habib Coins": "Sie haben nicht genügend Habib-Münzen",
"Please add more Habib Coins to your balance (or use discount code), so that you can use them to access this content": "Bitte fügen Sie Ihrem Guthaben mehr Habib-Münze hinzu (oder verwenden Sie einen Rabattcode), um auf diesen Inhalt zugreifen zu können",
"Do you have a discount code?": "Haben Sie einen Rabattcode?",
"Enter discount code...": "Rabattcode eingeben...",
"Apply": "Anwenden",
"Clear": "Löschen",
"This field is required": "Dieses Feld ist erforderlich",
"Habib Coins": "Habib-Münzen",
"{} Habib Coins": "{} Habib-Münzen"
} }

19
src/translations/locales/en.json

@ -1,4 +1,7 @@
{ {
"2": "2",
"70": "70",
"175": "175",
"### Family Religious Atmosphere Options * **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life. * **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society. * **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation. * **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### Family Religious Atmosphere Options * **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life. * **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society. * **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation. * **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.", "### Family Religious Atmosphere Options * **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life. * **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society. * **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation. * **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### Family Religious Atmosphere Options * **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life. * **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society. * **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation. * **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.",
"(Complete Required Forms)": "(Complete Required Forms)", "(Complete Required Forms)": "(Complete Required Forms)",
"(after 2 days)": "(after 2 days)", "(after 2 days)": "(after 2 days)",
@ -11,9 +14,7 @@
"1. Eligibility and Membership Requirements": "1. Eligibility and Membership Requirements", "1. Eligibility and Membership Requirements": "1. Eligibility and Membership Requirements",
"160 to 170": "160 to 170", "160 to 170": "160 to 170",
"170 to 180": "170 to 180", "170 to 180": "170 to 180",
"175": "175",
"180 to 190": "180 to 190", "180 to 190": "180 to 190",
"2": "2",
"2 minutes": "2 minutes", "2 minutes": "2 minutes",
"2. Privacy and Data Management": "2. Privacy and Data Management", "2. Privacy and Data Management": "2. Privacy and Data Management",
"25-30": "25-30", "25-30": "25-30",
@ -24,7 +25,6 @@
"5 minutes": "5 minutes", "5 minutes": "5 minutes",
"50 Coins": "50 Coins", "50 Coins": "50 Coins",
"6 minutes": "6 minutes", "6 minutes": "6 minutes",
"70": "70",
"8 minutes": "8 minutes", "8 minutes": "8 minutes",
"A Path to Heavenly Marriage": "A Path to Heavenly Marriage", "A Path to Heavenly Marriage": "A Path to Heavenly Marriage",
"A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.", "A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.",
@ -817,5 +817,16 @@
"Buy Habib Coins": "Buy Habib Coins", "Buy Habib Coins": "Buy Habib Coins",
"Subscription required": "Subscription required", "Subscription required": "Subscription required",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "To view profiles, you need a subscription. Each subscription includes up to {count} matches.", "To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "To view profiles, you need a subscription. Each subscription includes up to {count} matches.",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users"
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users",
"{count} case introductions remaining of your subscription.": "{count} case introductions remaining of your subscription.",
"{days} days and {count} case introductions remaining of your subscription.": "{days} days and {count} case introductions remaining of your subscription.",
"You do not have enough Habib Coins": "You do not have enough Habib Coins",
"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",
"Do you have a discount code?": "Do you have a discount code?",
"Enter discount code...": "Enter discount code...",
"Apply": "Apply",
"Clear": "Clear",
"This field is required": "This field is required",
"Habib Coins": "Habib Coins",
"{} Habib Coins": "{} Habib Coins"
} }

20
src/translations/locales/es.json

@ -1,4 +1,7 @@
{ {
"2": "2",
"70": "70",
"175": "175",
"### Family Religious Atmosphere Options\n\n* **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life.\n* **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society.\n* **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation.\n* **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### Opciones de ambiente religioso familiar * **Religioso y estrictamente observante:** Esto especifica una familia altamente dedicada a realizar todos los **deberes obligatorios**, mantener estrictamente **límites religiosos** (como las reglas de Mahram) y defender **rituales y enseñanzas religiosas** en todos los aspectos de la vida. * **Religioso (observante de las obligaciones):** Esto indica una familia comprometida con los **deberes religiosos** básicos (como la oración y el ayuno) y la **ética islámica**, que vive dentro de los marcos estándar de una sociedad religiosa. * **Tradicional (Respetuoso de los Valores Religiosos):** Esto describe una familia que tiene devoción a los valores morales y **respeta la religión**, pero no puede ejecutar estrictamente cada **ley u obligación religiosa** específica. * **No religioso/Secular:** Esto representa una familia donde **los rituales y marcos religiosos** no influyen significativamente en su **estilo de vida, relaciones o decisiones** diarias, a pesar de tener un respeto general por la religión.", "### Family Religious Atmosphere Options\n\n* **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life.\n* **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society.\n* **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation.\n* **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### Opciones de ambiente religioso familiar * **Religioso y estrictamente observante:** Esto especifica una familia altamente dedicada a realizar todos los **deberes obligatorios**, mantener estrictamente **límites religiosos** (como las reglas de Mahram) y defender **rituales y enseñanzas religiosas** en todos los aspectos de la vida. * **Religioso (observante de las obligaciones):** Esto indica una familia comprometida con los **deberes religiosos** básicos (como la oración y el ayuno) y la **ética islámica**, que vive dentro de los marcos estándar de una sociedad religiosa. * **Tradicional (Respetuoso de los Valores Religiosos):** Esto describe una familia que tiene devoción a los valores morales y **respeta la religión**, pero no puede ejecutar estrictamente cada **ley u obligación religiosa** específica. * **No religioso/Secular:** Esto representa una familia donde **los rituales y marcos religiosos** no influyen significativamente en su **estilo de vida, relaciones o decisiones** diarias, a pesar de tener un respeto general por la religión.",
"(Complete Required Forms)": "(Complete Required Forms)", "(Complete Required Forms)": "(Complete Required Forms)",
"(after 2 days)": "(after 2 days)", "(after 2 days)": "(after 2 days)",
@ -11,9 +14,7 @@
"1. Eligibility and Membership Requirements": "1. Eligibility and Membership Requirements", "1. Eligibility and Membership Requirements": "1. Eligibility and Membership Requirements",
"160 to 170": "160 a 170", "160 to 170": "160 a 170",
"170 to 180": "170 a 180", "170 to 180": "170 a 180",
"175": "175",
"180 to 190": "180 a 190", "180 to 190": "180 a 190",
"2": "2",
"2 minutes": "2 minutos", "2 minutes": "2 minutos",
"2. Privacy and Data Management": "2. Privacy and Data Management", "2. Privacy and Data Management": "2. Privacy and Data Management",
"25-30": "25-30", "25-30": "25-30",
@ -24,7 +25,6 @@
"5 minutes": "5 minutos", "5 minutes": "5 minutos",
"50 Coins": "50 Coins", "50 Coins": "50 Coins",
"6 minutes": "6 minutos", "6 minutes": "6 minutos",
"70": "70",
"8 minutes": "8 minutos", "8 minutes": "8 minutos",
"A Path to Heavenly Marriage": "A Path to Heavenly Marriage", "A Path to Heavenly Marriage": "A Path to Heavenly Marriage",
"A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "No se requiere una dirección precisa. Sólo el área general donde vive es suficiente, como la ciudad, región, vecindario o ciudad importante más cercana.", "A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "No se requiere una dirección precisa. Sólo el área general donde vive es suficiente, como la ciudad, región, vecindario o ciudad importante más cercana.",
@ -767,5 +767,17 @@
"Buy Habib Coins": "Comprar monedas Habib", "Buy Habib Coins": "Comprar monedas Habib",
"Subscription required": "Suscripción requerida", "Subscription required": "Suscripción requerida",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "Para ver perfiles, necesitas una suscripción. Cada suscripción incluye hasta {count} coincidencias.", "To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "Para ver perfiles, necesitas una suscripción. Cada suscripción incluye hasta {count} coincidencias.",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "Para ver perfiles, se requiere una suscripción. Esto ayuda a cubrir nuestros servicios de soporte y emparejamiento y aplica solo a usuarios masculinos"
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "Para ver perfiles, se requiere una suscripción. Esto ayuda a cubrir nuestros servicios de soporte y emparejamiento y aplica solo a usuarios masculinos",
"More detail": "Más detalles",
"{count} case introductions remaining of your subscription.": "Quedan {count} presentaciones de casos de tu suscripción.",
"{days} days and {count} case introductions remaining of your subscription.": "Quedan {days} días y {count} presentaciones de casos de tu suscripción.",
"You do not have enough Habib Coins": "No tienes suficientes Monedas Habib",
"Please add more Habib Coins to your balance (or use discount code), so that you can use them to access this content": "Agrega más Moneda Habib a tu saldo (o usa un código de descuento) para poder acceder a este contenido",
"Do you have a discount code?": "¿Tienes un código de descuento?",
"Enter discount code...": "Introduce el código de descuento...",
"Apply": "Aplicar",
"Clear": "Borrar",
"This field is required": "Este campo es obligatorio",
"Habib Coins": "Monedas Habib",
"{} Habib Coins": "{} Monedas Habib"
} }

19
src/translations/locales/fa.json

@ -1,4 +1,7 @@
{ {
"2": "۲",
"70": "۷۰",
"175": "۱۷۵",
"### Family Religious Atmosphere Options * **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life. * **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society. * **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation. * **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### راهنمای گزینه‌های فضای مذهبی خانواده * **مذهبی و کاملاً مقید:** خانواده‌ای که تقید بسیار بالایی به انجام تمام واجبات دارد، حدود شرعی (مانند محرم و نامحرم) را به شدت رعایت می‌کند و آداب و مناسک مذهبی در تمام شئون زندگی آن‌ها جریان دارد. * **مذهبی (مقید به واجبات):** خانواده‌ای که متعهد به واجبات اصلی مذهبی (مانند نماز و روزه) و اخلاق اسلامی است و در چارچوب‌های متعارف یک جامعه متدین زندگی می‌کند. * **سنتی (محترم به ارزش‌های دینی):** خانواده‌ای که به ارزش‌های اخلاقی پایبند است و به دین احترام می‌گذارد، اما ممکن است تمام احکام و واجبات مذهبی را به طور دقیق و کامل اجرا نکند. * **غیرمذهبی / عرفی:** خانواده‌ای که مناسک و چارچوب‌های مذهبی تاثیر تعیین‌کننده‌ای بر سبک زندگی، ارتباطات یا تصمیم‌گیری‌های روزمره‌شان ندارد، هرچند ممکن است احترامی کلی برای مذهب قائل باشند.", "### Family Religious Atmosphere Options * **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life. * **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society. * **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation. * **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### راهنمای گزینه‌های فضای مذهبی خانواده * **مذهبی و کاملاً مقید:** خانواده‌ای که تقید بسیار بالایی به انجام تمام واجبات دارد، حدود شرعی (مانند محرم و نامحرم) را به شدت رعایت می‌کند و آداب و مناسک مذهبی در تمام شئون زندگی آن‌ها جریان دارد. * **مذهبی (مقید به واجبات):** خانواده‌ای که متعهد به واجبات اصلی مذهبی (مانند نماز و روزه) و اخلاق اسلامی است و در چارچوب‌های متعارف یک جامعه متدین زندگی می‌کند. * **سنتی (محترم به ارزش‌های دینی):** خانواده‌ای که به ارزش‌های اخلاقی پایبند است و به دین احترام می‌گذارد، اما ممکن است تمام احکام و واجبات مذهبی را به طور دقیق و کامل اجرا نکند. * **غیرمذهبی / عرفی:** خانواده‌ای که مناسک و چارچوب‌های مذهبی تاثیر تعیین‌کننده‌ای بر سبک زندگی، ارتباطات یا تصمیم‌گیری‌های روزمره‌شان ندارد، هرچند ممکن است احترامی کلی برای مذهب قائل باشند.",
"(Complete Required Forms)": "(تکمیل فرم‌های ضروری)", "(Complete Required Forms)": "(تکمیل فرم‌های ضروری)",
"(after 2 days)": "(بعد از ۲ روز)", "(after 2 days)": "(بعد از ۲ روز)",
@ -11,9 +14,7 @@
"1. Eligibility and Membership Requirements": "۱. شرایط عضویت و صلاحیت", "1. Eligibility and Membership Requirements": "۱. شرایط عضویت و صلاحیت",
"160 to 170": "۱۶۰ تا ۱۷۰", "160 to 170": "۱۶۰ تا ۱۷۰",
"170 to 180": "۱۷۰ تا ۱۸۰", "170 to 180": "۱۷۰ تا ۱۸۰",
"175": "۱۷۵",
"180 to 190": "۱۸۰ تا ۱۹۰", "180 to 190": "۱۸۰ تا ۱۹۰",
"2": "۲",
"2 minutes": "۲ دقیقه", "2 minutes": "۲ دقیقه",
"2. Privacy and Data Management": "۲. حریم خصوصی و مدیریت داده‌ها", "2. Privacy and Data Management": "۲. حریم خصوصی و مدیریت داده‌ها",
"25-30": "۲۵-۳۰", "25-30": "۲۵-۳۰",
@ -24,7 +25,6 @@
"5 minutes": "۵ دقیقه", "5 minutes": "۵ دقیقه",
"50 Coins": "۵۰ سکه", "50 Coins": "۵۰ سکه",
"6 minutes": "۶ دقیقه", "6 minutes": "۶ دقیقه",
"70": "۷۰",
"8 minutes": "۸ دقیقه", "8 minutes": "۸ دقیقه",
"A Path to Heavenly Marriage": "مسیری برای ازدواج آسمانی", "A Path to Heavenly Marriage": "مسیری برای ازدواج آسمانی",
"A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "نیازی به آدرس دقیق نیست. فقط محدوده کلی محل زندگی کافی است؛ مثلاً نام شهر، منطقه، ناحیه یا نزدیکترین شهر بزرگ.", "A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "نیازی به آدرس دقیق نیست. فقط محدوده کلی محل زندگی کافی است؛ مثلاً نام شهر، منطقه، ناحیه یا نزدیکترین شهر بزرگ.",
@ -817,5 +817,16 @@
"Buy Habib Coins": "خرید سکه حبیب", "Buy Habib Coins": "خرید سکه حبیب",
"Subscription required": "اشتراک الزامی است", "Subscription required": "اشتراک الزامی است",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "برای مشاهده پروفایل‌ها، نیاز به اشتراک دارید. هر اشتراک شامل حداکثر {count} همسان‌یابی است.", "To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "برای مشاهده پروفایل‌ها، نیاز به اشتراک دارید. هر اشتراک شامل حداکثر {count} همسان‌یابی است.",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "برای مشاهده پروفایل‌ها، اشتراک الزامی است. این هزینه صرف خدمات پشتیبانی و همسان‌یابی می‌شود و فقط شامل کاربران مرد است"
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "برای مشاهده پروفایل‌ها، اشتراک الزامی است. این هزینه صرف خدمات پشتیبانی و همسان‌یابی می‌شود و فقط شامل کاربران مرد است",
"{count} case introductions remaining of your subscription.": "{count} معرفی از اعتبار اشتراک شما باقی مانده است.",
"{days} days and {count} case introductions remaining of your subscription.": "{days} روز و {count} معرفی از اعتبار اشتراک شما باقی مانده است.",
"You do not have enough Habib Coins": "شما حبیب کوین کافی ندارید",
"Please add more Habib Coins to your balance (or use discount code), so that you can use them to access this content": "لطفاً حبیب کوین بیشتری به موجودی خود اضافه کنید (یا از کد تخفیف استفاده کنید) تا بتوانید به این محتوا دسترسی پیدا کنید",
"Do you have a discount code?": "آیا کد تخفیف دارید؟",
"Enter discount code...": "کد تخفیف را وارد کنید...",
"Apply": "اعمال",
"Clear": "پاک کردن",
"This field is required": "این فیلد ضروری است",
"Habib Coins": "حبیب کوین",
"{} Habib Coins": "{} حبیب کوین"
} }

20
src/translations/locales/fr.json

@ -1,4 +1,7 @@
{ {
"2": "2",
"70": "70",
"175": "175",
"### Family Religious Atmosphere Options\n\n* **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life.\n* **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society.\n* **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation.\n* **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### Options d'ambiance religieuse familiale * **Religieux et strictement observant :** Ceci spécifie une famille hautement dévouée à l'accomplissement de toutes les **tâches obligatoires**, au maintien strict des **limites religieuses** (telles que les règles du Mahram) et au respect des **rituels et enseignements religieux** dans tous les aspects de la vie. * **Religieux (observateur des obligations) :** Cela indique une famille engagée dans les **devoirs religieux** fondamentaux (tels que la prière et le jeûne) et dans l'**éthique islamique**, vivant dans les cadres standard d'une société religieuse. * **Traditionnel (respectueux des valeurs religieuses) :** Ceci décrit une famille qui est attachée aux valeurs morales et **respecte la religion**, mais ne peut pas exécuter strictement chaque **loi religieuse** ou obligation spécifique. * **Non religieux/laïc :** Cela représente une famille dans laquelle les **rituels et cadres religieux** n'influencent pas de manière significative leur **mode de vie, leurs relations ou leurs décisions** quotidiennes, malgré un respect général pour la religion.", "### Family Religious Atmosphere Options\n\n* **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life.\n* **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society.\n* **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation.\n* **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### Options d'ambiance religieuse familiale * **Religieux et strictement observant :** Ceci spécifie une famille hautement dévouée à l'accomplissement de toutes les **tâches obligatoires**, au maintien strict des **limites religieuses** (telles que les règles du Mahram) et au respect des **rituels et enseignements religieux** dans tous les aspects de la vie. * **Religieux (observateur des obligations) :** Cela indique une famille engagée dans les **devoirs religieux** fondamentaux (tels que la prière et le jeûne) et dans l'**éthique islamique**, vivant dans les cadres standard d'une société religieuse. * **Traditionnel (respectueux des valeurs religieuses) :** Ceci décrit une famille qui est attachée aux valeurs morales et **respecte la religion**, mais ne peut pas exécuter strictement chaque **loi religieuse** ou obligation spécifique. * **Non religieux/laïc :** Cela représente une famille dans laquelle les **rituels et cadres religieux** n'influencent pas de manière significative leur **mode de vie, leurs relations ou leurs décisions** quotidiennes, malgré un respect général pour la religion.",
"(Complete Required Forms)": "(Complete Required Forms)", "(Complete Required Forms)": "(Complete Required Forms)",
"(after 2 days)": "(after 2 days)", "(after 2 days)": "(after 2 days)",
@ -11,9 +14,7 @@
"1. Eligibility and Membership Requirements": "1. Eligibility and Membership Requirements", "1. Eligibility and Membership Requirements": "1. Eligibility and Membership Requirements",
"160 to 170": "160 à 170", "160 to 170": "160 à 170",
"170 to 180": "170 à 180", "170 to 180": "170 à 180",
"175": "175",
"180 to 190": "180 à 190", "180 to 190": "180 à 190",
"2": "2",
"2 minutes": "2 minutes", "2 minutes": "2 minutes",
"2. Privacy and Data Management": "2. Privacy and Data Management", "2. Privacy and Data Management": "2. Privacy and Data Management",
"25-30": "25-30", "25-30": "25-30",
@ -24,7 +25,6 @@
"5 minutes": "5 minutes", "5 minutes": "5 minutes",
"50 Coins": "50 Coins", "50 Coins": "50 Coins",
"6 minutes": "6 minutes", "6 minutes": "6 minutes",
"70": "70",
"8 minutes": "8 minutes", "8 minutes": "8 minutes",
"A Path to Heavenly Marriage": "A Path to Heavenly Marriage", "A Path to Heavenly Marriage": "A Path to Heavenly Marriage",
"A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "Une adresse précise n’est pas requise. Seule la zone générale où vous habitez est suffisante, comme la ville, la région, le quartier ou la grande ville la plus proche.", "A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "Une adresse précise n’est pas requise. Seule la zone générale où vous habitez est suffisante, comme la ville, la région, le quartier ou la grande ville la plus proche.",
@ -767,5 +767,17 @@
"Buy Habib Coins": "Acheter des pièces Habib", "Buy Habib Coins": "Acheter des pièces Habib",
"Subscription required": "Abonnement requis", "Subscription required": "Abonnement requis",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "Pour voir les profils, vous avez besoin d'un abonnement. Chaque abonnement comprend jusqu'à {count} correspondances.", "To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "Pour voir les profils, vous avez besoin d'un abonnement. Chaque abonnement comprend jusqu'à {count} correspondances.",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "Pour voir les profils, un abonnement est requis. Cela aide à couvrir nos services de support et de mise en relation et s'applique uniquement aux utilisateurs masculins"
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "Pour voir les profils, un abonnement est requis. Cela aide à couvrir nos services de support et de mise en relation et s'applique uniquement aux utilisateurs masculins",
"More detail": "Plus de détails",
"{count} case introductions remaining of your subscription.": "Il reste {count} présentations dans votre abonnement.",
"{days} days and {count} case introductions remaining of your subscription.": "Il reste {days} jours et {count} présentations dans votre abonnement.",
"You do not have enough Habib Coins": "Vous n'avez pas assez de Pièces Habib",
"Please add more Habib Coins to your balance (or use discount code), so that you can use them to access this content": "Veuillez ajouter plus de Pièce Habib à votre solde (ou utilisez un code de réduction) pour pouvoir accéder à ce contenu",
"Do you have a discount code?": "Avez-vous un code de réduction ?",
"Enter discount code...": "Entrez le code de réduction...",
"Apply": "Appliquer",
"Clear": "Effacer",
"This field is required": "Ce champ est requis",
"Habib Coins": "Pièces Habib",
"{} Habib Coins": "{} Pièces Habib"
} }

20
src/translations/locales/gu.json

@ -1,4 +1,7 @@
{ {
"2": "2",
"70": "70",
"175": "175",
"### Family Religious Atmosphere Options\n\n* **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life.\n* **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society.\n* **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation.\n* **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### કૌટુંબિક ધાર્મિક વાતાવરણના વિકલ્પો * **ધાર્મિક અને ચુસ્તપણે પાલન કરનાર:** આ એક કુટુંબનો ઉલ્લેખ કરે છે જે તમામ **ફરજિયાત ફરજો** કરવા માટે ખૂબ જ સમર્પિત હોય, **ધાર્મિક સીમાઓ** (જેમ કે મહરમના નિયમો)નું સખતપણે પાલન કરે અને જીવનના **ધાર્મિક વિધિઓ અને ઉપદેશોનું પાલન કરે**. * **ધાર્મિક (જવાબદારીઓનું પાલન કરનાર):** આ ધાર્મિક સમાજના માનક માળખામાં રહેતા **ધાર્મિક ફરજો** (જેમ કે પ્રાર્થના અને ઉપવાસ) અને **ઈસ્લામિક નૈતિકતા** માટે પ્રતિબદ્ધ કુટુંબ સૂચવે છે. * **પરંપરાગત (ધાર્મિક મૂલ્યોનું સન્માન):** આ એવા કુટુંબનું વર્ણન કરે છે જે નૈતિક મૂલ્યો પ્રત્યે નિષ્ઠા ધરાવે છે અને **ધર્મનું સન્માન કરે છે**, પરંતુ દરેક ચોક્કસ **ધાર્મિક કાયદા** અથવા જવાબદારીને સખત રીતે ચલાવી શકતા નથી. * **બિન-ધાર્મિક / બિનસાંપ્રદાયિક:** આ એવા કુટુંબનું પ્રતિનિધિત્વ કરે છે જ્યાં **ધાર્મિક ધાર્મિક વિધિઓ અને માળખા** તેમની દૈનિક **જીવનશૈલી, સંબંધો અથવા નિર્ણયો**ને નોંધપાત્ર રીતે પ્રભાવિત કરતા નથી, ધર્મ પ્રત્યે સામાન્ય સન્માન હોવા છતાં.", "### Family Religious Atmosphere Options\n\n* **Religious and Strictly Observant:** This specifies a family highly dedicated to performing all **obligatory duties**, strictly maintaining **religious boundaries** (such as Mahram rules), and upholding **religious rituals and teachings** across all aspects of life.\n* **Religious (Observant of Obligations):** This indicates a family committed to core **religious duties** (such as prayer and fasting) and **Islamic ethics**, living within the standard frameworks of a religious society.\n* **Traditional (Respectful of Religious Values):** This describes a family that holds devotion to moral values and **respects religion**, but may not strictly execute every specific **religious law** or obligation.\n* **Non-religious / Secular:** This represents a family where **religious rituals and frameworks** do not significantly influence their daily **lifestyle, relationships, or decisions**, despite holding a general respect for religion.": "### કૌટુંબિક ધાર્મિક વાતાવરણના વિકલ્પો * **ધાર્મિક અને ચુસ્તપણે પાલન કરનાર:** આ એક કુટુંબનો ઉલ્લેખ કરે છે જે તમામ **ફરજિયાત ફરજો** કરવા માટે ખૂબ જ સમર્પિત હોય, **ધાર્મિક સીમાઓ** (જેમ કે મહરમના નિયમો)નું સખતપણે પાલન કરે અને જીવનના **ધાર્મિક વિધિઓ અને ઉપદેશોનું પાલન કરે**. * **ધાર્મિક (જવાબદારીઓનું પાલન કરનાર):** આ ધાર્મિક સમાજના માનક માળખામાં રહેતા **ધાર્મિક ફરજો** (જેમ કે પ્રાર્થના અને ઉપવાસ) અને **ઈસ્લામિક નૈતિકતા** માટે પ્રતિબદ્ધ કુટુંબ સૂચવે છે. * **પરંપરાગત (ધાર્મિક મૂલ્યોનું સન્માન):** આ એવા કુટુંબનું વર્ણન કરે છે જે નૈતિક મૂલ્યો પ્રત્યે નિષ્ઠા ધરાવે છે અને **ધર્મનું સન્માન કરે છે**, પરંતુ દરેક ચોક્કસ **ધાર્મિક કાયદા** અથવા જવાબદારીને સખત રીતે ચલાવી શકતા નથી. * **બિન-ધાર્મિક / બિનસાંપ્રદાયિક:** આ એવા કુટુંબનું પ્રતિનિધિત્વ કરે છે જ્યાં **ધાર્મિક ધાર્મિક વિધિઓ અને માળખા** તેમની દૈનિક **જીવનશૈલી, સંબંધો અથવા નિર્ણયો**ને નોંધપાત્ર રીતે પ્રભાવિત કરતા નથી, ધર્મ પ્રત્યે સામાન્ય સન્માન હોવા છતાં.",
"(Complete Required Forms)": "(Complete Required Forms)", "(Complete Required Forms)": "(Complete Required Forms)",
"(after 2 days)": "(after 2 days)", "(after 2 days)": "(after 2 days)",
@ -11,9 +14,7 @@
"1. Eligibility and Membership Requirements": "1. Eligibility and Membership Requirements", "1. Eligibility and Membership Requirements": "1. Eligibility and Membership Requirements",
"160 to 170": "160 થી 170", "160 to 170": "160 થી 170",
"170 to 180": "170 થી 180", "170 to 180": "170 થી 180",
"175": "175",
"180 to 190": "180 થી 190", "180 to 190": "180 થી 190",
"2": "2",
"2 minutes": "2 મિનિટ", "2 minutes": "2 મિનિટ",
"2. Privacy and Data Management": "2. Privacy and Data Management", "2. Privacy and Data Management": "2. Privacy and Data Management",
"25-30": "25-30", "25-30": "25-30",
@ -24,7 +25,6 @@
"5 minutes": "5 મિનિટ", "5 minutes": "5 મિનિટ",
"50 Coins": "50 Coins", "50 Coins": "50 Coins",
"6 minutes": "6 મિનિટ", "6 minutes": "6 મિનિટ",
"70": "70",
"8 minutes": "8 મિનિટ", "8 minutes": "8 મિનિટ",
"A Path to Heavenly Marriage": "A Path to Heavenly Marriage", "A Path to Heavenly Marriage": "A Path to Heavenly Marriage",
"A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "ચોક્કસ સરનામું જરૂરી નથી. તમે જ્યાં રહો છો તેનો સામાન્ય વિસ્તાર પૂરતો છે, જેમ કે શહેર, પ્રદેશ, પડોશ અથવા નજીકનું મોટું શહેર.", "A precise address is not required. Just the general area of where you live is sufficient, such as the city, region, neighborhood, or nearest major city.": "ચોક્કસ સરનામું જરૂરી નથી. તમે જ્યાં રહો છો તેનો સામાન્ય વિસ્તાર પૂરતો છે, જેમ કે શહેર, પ્રદેશ, પડોશ અથવા નજીકનું મોટું શહેર.",
@ -767,5 +767,17 @@
"Buy Habib Coins": "હબીબ સિક્કા ખરીદો", "Buy Habib Coins": "હબીબ સિક્કા ખરીદો",
"Subscription required": "સબ્સ્ક્રિપ્શન જરૂરી", "Subscription required": "સબ્સ્ક્રિપ્શન જરૂરી",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "પ્રોફાઇલ જોવા માટે તમારે સબ્સ્ક્રિપ્શનની જરૂર છે. દરેક સબ્સ્ક્રિપ્શનમાં {count} મેચ સુધીનો સમાવેશ થાય છે.", "To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "પ્રોફાઇલ જોવા માટે તમારે સબ્સ્ક્રિપ્શનની જરૂર છે. દરેક સબ્સ્ક્રિપ્શનમાં {count} મેચ સુધીનો સમાવેશ થાય છે.",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "પ્રોફાઇલ જોવા માટે સબ્સ્ક્રિપ્શન જરૂરી છે. આ અમારી સપોર્ટ અને મેચિંગ સેવાઓને આવરી લેવામાં મદદ કરે છે અને ફક્ત પુરુષ વપરાશકર્તાઓને લાગુ પડે છે"
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "પ્રોફાઇલ જોવા માટે સબ્સ્ક્રિપ્શન જરૂરી છે. આ અમારી સપોર્ટ અને મેચિંગ સેવાઓને આવરી લેવામાં મદદ કરે છે અને ફક્ત પુરુષ વપરાશકર્તાઓને લાગુ પડે છે",
"More detail": "વધુ વિગતો",
"{count} case introductions remaining of your subscription.": "તમારા સબ્સ્ક્રિપ્શનના {count} પરિચય બાકી છે.",
"{days} days and {count} case introductions remaining of your subscription.": "તમારા સબ્સ્ક્રિપ્શનના {days} દિવસ અને {count} પરિચય બાકી છે.",
"You do not have enough Habib Coins": "તમારી પાસે પૂરતા હબીબ કોઈન્સ નથી",
"Please add more Habib Coins to your balance (or use discount code), so that you can use them to access this content": "કૃપા કરીને તમારા બેલેન્સમાં વધુ હબીબ સિક્કો ઉમેરો (અથવા ડિસ્કાઉન્ટ કોડ વાપરો) જેથી તમે આ સામગ્રી ઍક્સેસ કરી શકો",
"Do you have a discount code?": "શું તમારી પાસે ડિસ્કાઉન્ટ કોડ છે?",
"Enter discount code...": "ડિસ્કાઉન્ટ કોડ દાખલ કરો...",
"Apply": "લાગુ કરો",
"Clear": "સાફ કરો",
"This field is required": "આ ક્ષેત્ર આવશ્યક છે",
"Habib Coins": "હબીબ સિક્કા",
"{} Habib Coins": "{} હબીબ કોઈન્સ"
} }

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save