parent
commit
68f7c47b06
  1. 1331
      .playwright-mcp/console-2026-08-15T21-43-01-507Z.log
  2. 192
      .playwright-mcp/console-2026-08-15T21-58-02-063Z.log
  3. 30
      .playwright-mcp/console-2026-08-15T22-06-02-559Z.log
  4. 8
      .playwright-mcp/page-2026-08-15T21-43-05-290Z.yml
  5. 8
      .playwright-mcp/page-2026-08-15T21-58-15-641Z.yml
  6. 8
      .playwright-mcp/page-2026-08-15T21-58-30-755Z.yml
  7. 43
      WEBVIEW_ACTION_DOWNLOAD.md
  8. 33
      src/app/globals.css
  9. 30
      src/app/new-match/page.tsx
  10. 105
      src/app/questions-list/[slug]/question-detail-client.tsx
  11. 57
      src/app/questions-list/page.tsx
  12. 27
      src/app/request-accepted/page.tsx
  13. 48
      src/components/Componentes/advisor-actions-card.tsx
  14. 271
      src/components/Componentes/data-error-state.tsx
  15. 54
      src/components/Componentes/navigation-button.tsx
  16. 101
      src/components/Componentes/question-sheet.test.tsx
  17. 29
      src/components/Componentes/question-sheet.tsx
  18. 186
      src/components/Componentes/question-snap-list.test.tsx
  19. 73
      src/components/Componentes/question-snap-list.tsx
  20. 257
      src/components/Componentes/question-viewport-coordinator.ts
  21. 63
      src/components/Componentes/subscription-required-sheet.tsx
  22. 136
      src/components/Componentes/use-sheet-scroll-lock.ts
  23. 191
      src/lib/webview-actions.test.ts
  24. 18
      src/lib/webview-actions.ts
  25. 8
      src/translations/locales/ar.json
  26. 8
      src/translations/locales/az.json
  27. 8
      src/translations/locales/bn.json
  28. 8
      src/translations/locales/da.json
  29. 8
      src/translations/locales/de.json
  30. 8
      src/translations/locales/en.json
  31. 8
      src/translations/locales/es.json
  32. 8
      src/translations/locales/fa.json
  33. 8
      src/translations/locales/fr.json
  34. 8
      src/translations/locales/gu.json
  35. 8
      src/translations/locales/ha.json
  36. 8
      src/translations/locales/he.json
  37. 8
      src/translations/locales/hi.json
  38. 8
      src/translations/locales/id.json
  39. 8
      src/translations/locales/ks.json
  40. 8
      src/translations/locales/pt.json
  41. 8
      src/translations/locales/ru.json
  42. 8
      src/translations/locales/sw.json
  43. 8
      src/translations/locales/tg.json
  44. 8
      src/translations/locales/tr.json
  45. 8
      src/translations/locales/ul.json
  46. 8
      src/translations/locales/ur.json
  47. 8
      src/translations/locales/uz.json
  48. 8
      src/translations/locales/zh.json

1331
.playwright-mcp/console-2026-08-15T21-43-01-507Z.log
File diff suppressed because it is too large
View File

192
.playwright-mcp/console-2026-08-15T21-58-02-063Z.log

@ -0,0 +1,192 @@
[ 2312ms] [WARNING] The resource http://localhost:3001/_next/static/css/app/layout.css?v=1786830299174 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:3001/en/questions-list:0
[ 2313ms] [WARNING] The resource http://localhost:3001/_next/static/css/app/layout.css?v=1786830306438 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:3001/en/questions-list:0
[ 2313ms] [WARNING] The resource http://localhost:3001/_next/static/css/app/layout.css?v=1786830345862 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:3001/en/questions-list:0
[ 3843ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 5505ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 8367ms] Invalid or unexpected token
[ 12931ms] [LOG] ⏱️ Load time: -72 ms @ :133
[ 13275ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 13533ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 14733ms] [ERROR] Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have a side-effect in your render function that asynchronously tries to update the component. Move this work to useEffect instead. @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/userspace/app/errors/intercept-console-error.js:57
[ 14825ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 17526ms] [LOG] [Fast Refresh] done in 2677ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 17599ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 19569ms] [LOG] [Fast Refresh] done in 1966ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 20291ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 21005ms] [LOG] [Fast Refresh] done in 714ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 22079ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 22518ms] [LOG] [Fast Refresh] done in 438ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 23531ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 23993ms] [LOG] [Fast Refresh] done in 461ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 25019ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 25545ms] [LOG] [Fast Refresh] done in 526ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 26409ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 26752ms] [LOG] [Fast Refresh] done in 343ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 27962ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 29256ms] [LOG] [Fast Refresh] done in 1295ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 30598ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 31276ms] [LOG] [Fast Refresh] done in 677ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 32373ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 32898ms] [LOG] [Fast Refresh] done in 525ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 33845ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 34324ms] [LOG] [Fast Refresh] done in 479ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 35084ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 35402ms] [LOG] [Fast Refresh] done in 317ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 36586ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 37194ms] [LOG] [Fast Refresh] done in 608ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 38517ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 39774ms] [LOG] [Fast Refresh] done in 1258ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 43870ms] Invalid or unexpected token
[ 54089ms] [LOG] ⏱️ Load time: -282 ms @ :133
[ 54442ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 55063ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 57980ms] [ERROR] Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have a side-effect in your render function that asynchronously tries to update the component. Move this work to useEffect instead. @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/userspace/app/errors/intercept-console-error.js:57
[ 58065ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 60207ms] [LOG] [Fast Refresh] done in 2141ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 60239ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 62575ms] [LOG] [Fast Refresh] done in 2335ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 62668ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 65667ms] [LOG] [Fast Refresh] done in 2998ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 68471ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 69333ms] [LOG] [Fast Refresh] done in 861ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 70653ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 71289ms] [LOG] [Fast Refresh] done in 636ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 72826ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 73894ms] [LOG] [Fast Refresh] done in 1068ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 75146ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 75523ms] [LOG] [Fast Refresh] done in 377ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 76615ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 77399ms] [LOG] [Fast Refresh] done in 783ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 79266ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 80538ms] [LOG] [Fast Refresh] done in 1272ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 81933ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 82728ms] [LOG] [Fast Refresh] done in 795ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 84305ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 84835ms] [LOG] [Fast Refresh] done in 531ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 85995ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 86367ms] [LOG] [Fast Refresh] done in 371ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 87461ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 88244ms] [LOG] [Fast Refresh] done in 782ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 89525ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 89942ms] [LOG] [Fast Refresh] done in 416ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 101433ms] [LOG] ⏱️ Load time: -277 ms @ :133
[ 101767ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 102024ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 102286ms] [ERROR] In HTML, text nodes cannot be a child of <%s>.
This will cause a hydration error.%s html
...
<HotReload globalError={[...]} webSocket={WebSocket} staticIndicatorState={{pathname:null, ...}}>
<AppDevOverlayErrorBoundary globalError={[...]}>
<ReplaySsrOnlyErrors>
<DevRootHTTPAccessFallbackBoundary>
<HTTPAccessFallbackBoundary notFound={<NotAllowedRootHTTPFallbackError>}>
<HTTPAccessFallbackErrorBoundary pathname="/en/questi..." notFound={<NotAllowedRootHTTPFallbackError>} ...>
<RedirectBoundary>
<RedirectErrorBoundary router={{...}}>
<Head>
<__next_root_layout_boundary__>
<SegmentViewNode type="layout" pagePath="layout.tsx">
<SegmentTrieNode>
<link>
<RootLayout>
> <html
> lang="en"
> dir="ltr"
> data-locator="/home/alireza/Desktop/main/habbib/marriage/src/app/layout.tsx:61:4"
> >
<head>
> {"گ "}
...
...
@ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/userspace/app/errors/intercept-console-error.js:57
[ 105477ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 107967ms] [LOG] [Fast Refresh] done in 2498ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 108059ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3001/api/proxy?__proxyPath=%2Fapi%2Fmarriage%2Fprofile%2Fmain%2F:0
[ 108091ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3001/api/proxy?__proxyPath=%2Fapi%2Fmarriage%2Fforms%2Fprofile%2F%3Flang%3Den:0
[ 108133ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 112893ms] [LOG] [Fast Refresh] done in 4760ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 112899ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 112949ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 113552ms] [LOG] [Fast Refresh] done in 603ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 115756ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3001/api/remote-network:0
[ 117651ms] [LOG] [Fast Refresh] done in 4703ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 118373ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3001/api/proxy?__proxyPath=%2Fapi%2Fmarriage%2Fprofile%2Fmain%2F:0
[ 118495ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 118521ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3001/api/proxy?__proxyPath=%2Fapi%2Fmarriage%2Fforms%2Fprofile%2F%3Flang%3Den:0
[ 119352ms] [LOG] [Fast Refresh] done in 857ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 119978ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3001/api/proxy?__proxyPath=%2Fapi%2Fmarriage%2Fprofile%2Fmain%2F:0
[ 120162ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3001/api/proxy?__proxyPath=%2Fapi%2Fmarriage%2Fforms%2Fprofile%2F%3Flang%3Den:0
[ 120850ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 121792ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3001/api/proxy?__proxyPath=%2Fapi%2Fmarriage%2Fprofile%2Fmain%2F:0
[ 121882ms] [LOG] [Fast Refresh] done in 1032ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 122286ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3001/api/proxy?__proxyPath=%2Fapi%2Fmarriage%2Fforms%2Fprofile%2F%3Flang%3Den:0
[ 122998ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 123013ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3001/api/proxy?__proxyPath=%2Fapi%2Fmarriage%2Fforms%2Fprofile%2F%3Flang%3Den:0
[ 123046ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3001/api/proxy?__proxyPath=%2Fapi%2Fmarriage%2Fprofile%2Fmain%2F:0
[ 123567ms] [LOG] [Fast Refresh] done in 568ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 124530ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3001/api/proxy?__proxyPath=%2Fapi%2Fmarriage%2Fforms%2Fprofile%2F%3Flang%3Den:0
[ 124660ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3001/api/proxy?__proxyPath=%2Fapi%2Fmarriage%2Fprofile%2Fmain%2F:0
[ 124838ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 126241ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3001/api/proxy?__proxyPath=%2Fapi%2Fmarriage%2Fprofile%2Fmain%2F:0
[ 126307ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3001/api/proxy?__proxyPath=%2Fapi%2Fmarriage%2Fforms%2Fprofile%2F%3Flang%3Den:0
[ 126370ms] [LOG] [Fast Refresh] done in 1530ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 128199ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 128506ms] [LOG] [Fast Refresh] done in 307ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 131511ms] [WARNING] The resource http://localhost:3001/_next/static/css/app/layout.css?v=1786831205299 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:3001/en/questions-list:0
[ 131516ms] [WARNING] The resource http://localhost:3001/_next/static/css/app/layout.css?v=1786831198662 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:3001/en/questions-list:0
[ 131516ms] [WARNING] The resource http://localhost:3001/_next/static/css/app/layout.css?v=1786831203335 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:3001/en/questions-list:0
[ 131516ms] [WARNING] The resource http://localhost:3001/_next/static/css/app/layout.css?v=1786831200775 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:3001/en/questions-list:0
[ 139682ms] [WARNING] The resource http://localhost:3001/_next/static/css/app/layout.css?v=1786831205299 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:3001/en/questions-list:0
[ 139682ms] [WARNING] The resource http://localhost:3001/_next/static/css/app/layout.css?v=1786831198662 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:3001/en/questions-list:0
[ 139682ms] [WARNING] The resource http://localhost:3001/_next/static/css/app/layout.css?v=1786831203335 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:3001/en/questions-list:0
[ 139682ms] [WARNING] The resource http://localhost:3001/_next/static/css/app/layout.css?v=1786831200775 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:3001/en/questions-list:0
[ 140129ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3001/api/proxy?__proxyPath=%2Fapi%2Fmarriage%2Fprofile%2Fmain%2F:0
[ 140191ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3001/api/proxy?__proxyPath=%2Fapi%2Fmarriage%2Fforms%2Fprofile%2F%3Flang%3Den:0
[ 143590ms] [WARNING] The resource http://localhost:3001/_next/static/css/app/layout.css?v=1786831205299 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:3001/en/questions-list:0
[ 143590ms] [WARNING] The resource http://localhost:3001/_next/static/css/app/layout.css?v=1786831198662 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:3001/en/questions-list:0
[ 143590ms] [WARNING] The resource http://localhost:3001/_next/static/css/app/layout.css?v=1786831203335 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:3001/en/questions-list:0
[ 143590ms] [WARNING] The resource http://localhost:3001/_next/static/css/app/layout.css?v=1786831200775 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://localhost:3001/en/questions-list:0
[ 159789ms] [LOG] ⏱️ Load time: -162 ms @ :133
[ 161142ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 171022ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 297120ms] Error: Internal Next.js error: Router action dispatched before initialization.
at dispatchAppRouterAction (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/use-action-queue.js:50:37)
at eval (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/app-router-instance.js:329:61)
at startTransition (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react/cjs/react.development.js:556:27)
at Object.hmrRefresh (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/app-router-instance.js:328:40)
at eval (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/dev/hot-reloader/app/hot-reloader-app.js:292:64)
at startTransition (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/compiled/react/cjs/react.development.js:556:27)
at processMessage (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/dev/hot-reloader/app/hot-reloader-app.js:291:44)
at WebSocket.handleMessage (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/dev/hot-reloader/app/web-socket.js:95:52)
[ 298019ms] [ERROR] In HTML, text nodes cannot be a child of <%s>.
This will cause a hydration error.%s html
...
<HotReload globalError={[...]} webSocket={WebSocket} staticIndicatorState={{pathname:null, ...}}>
<AppDevOverlayErrorBoundary globalError={[...]}>
<ReplaySsrOnlyErrors>
<DevRootHTTPAccessFallbackBoundary>
<HTTPAccessFallbackBoundary notFound={<NotAllowedRootHTTPFallbackError>}>
<HTTPAccessFallbackErrorBoundary pathname="/en/questi..." notFound={<NotAllowedRootHTTPFallbackError>} ...>
<RedirectBoundary>
<RedirectErrorBoundary router={{...}}>
<Head>
<__next_root_layout_boundary__>
<SegmentViewNode type="layout" pagePath="layout.tsx">
<SegmentTrieNode>
<link>
<RootLayout>
> <html
> lang="en"
> dir="ltr"
> data-locator="/home/alireza/Desktop/main/habbib/marriage/src/app/layout.tsx:61:4"
> >
<head>
> {"گ "}
...
...
@ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/userspace/app/errors/intercept-console-error.js:57
[ 425863ms] [LOG] ⏱️ Load time: -264 ms @ :133
[ 426503ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 434299ms] [LOG] [HMR] connected @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27

30
.playwright-mcp/console-2026-08-15T22-06-02-559Z.log

@ -0,0 +1,30 @@
[ 58786ms] [LOG] [Fast Refresh] rebuilding @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27
[ 97017ms] [ERROR] In HTML, text nodes cannot be a child of <%s>.
This will cause a hydration error.%s html
...
<HotReload globalError={[...]} webSocket={WebSocket} staticIndicatorState={{pathname:null, ...}}>
<AppDevOverlayErrorBoundary globalError={[...]}>
<ReplaySsrOnlyErrors>
<DevRootHTTPAccessFallbackBoundary>
<HTTPAccessFallbackBoundary notFound={<NotAllowedRootHTTPFallbackError>}>
<HTTPAccessFallbackErrorBoundary pathname="/en/questi..." notFound={<NotAllowedRootHTTPFallbackError>} ...>
<RedirectBoundary>
<RedirectErrorBoundary router={{...}}>
<Head>
<__next_root_layout_boundary__>
<SegmentViewNode type="layout" pagePath="layout.tsx">
<SegmentTrieNode>
<link>
<RootLayout>
> <html
> lang="en"
> dir="ltr"
> data-locator="/home/alireza/Desktop/main/habbib/marriage/src/app/layout.tsx:61:4"
> >
<head>
> {"گ "}
...
...
@ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/userspace/app/errors/intercept-console-error.js:57
[ 138485ms] [LOG] [Fast Refresh] done in 79848ms @ webpack-internal:///(app-pages-browser)/./node_modules/next/dist/next-devtools/shared/forward-logs-shared.js:27

8
.playwright-mcp/page-2026-08-15T21-43-05-290Z.yml

@ -0,0 +1,8 @@
- generic [active] [ref=e1]:
- text: گ
- main [ref=e4]:
- generic [ref=e5]:
- button "Close questions list" [ref=e6] [cursor=pointer]
- heading "Profile registration" [level=1] [ref=e9]
- button "Support" [ref=e10] [cursor=pointer]
- button "Send LocalStorage to Terminal API" [ref=e78] [cursor=pointer]

8
.playwright-mcp/page-2026-08-15T21-58-15-641Z.yml

@ -0,0 +1,8 @@
- generic [active] [ref=f1e1]:
- text: گ
- main [ref=f1e4]:
- generic [ref=f1e5]:
- button "Close questions list" [ref=f1e6] [cursor=pointer]
- heading "Profile registration" [level=1] [ref=f1e9]
- button "Support" [ref=f1e10] [cursor=pointer]
- button "Send LocalStorage to Terminal API" [ref=f1e78] [cursor=pointer]

8
.playwright-mcp/page-2026-08-15T21-58-30-755Z.yml

@ -0,0 +1,8 @@
- generic [active] [ref=f1e1]:
- text: گ
- main [ref=f1e4]:
- generic [ref=f1e5]:
- button "Close questions list" [ref=f1e6] [cursor=pointer]
- heading "Profile registration" [level=1] [ref=f1e9]
- button "Support" [ref=f1e10] [cursor=pointer]
- button "Send LocalStorage to Terminal API" [ref=f1e78] [cursor=pointer]

43
WEBVIEW_ACTION_DOWNLOAD.md

@ -973,6 +973,47 @@ void _handleUploadFile(dynamic payload) async {
---
## ۵. خرید حبیب کوین (`buy_coin`)
### هدف
وقتی کاربر برای پرداخت هزینه اشتراک یا معرفی مورد کوین کافی ندارد (دریافت خطای `Not enough coins` از سرور)، وب باید به کاربر دکمه‌ای برای شارژ حساب نمایش دهد. با کلیک کاربر، وب یک پیام JSON با اکشن `buy_coin` به Flutter ارسال می‌کند تا فلاتر به‌صورت native صفحه لیست پکیج‌های حبیب کوین (`PackagesListPage`) را روی WebView باز کند. کاربر پس از خرید پکیج و زدن دکمه بازگشت (Back)، مجدداً به همان صفحه وب بازمی‌گردد و می‌تواند پرداخت را تکرار کند.
### Action اصلی
```text
buy_coin
```
### پیام ارسالی از Web به Flutter
```json
{
"action": "buy_coin"
}
```
### امضای تابع در Web (src/lib/webview-actions.ts)
```ts
/**
* Ask Flutter to open the Habib Coin packages list page (Add Coin).
*/
export function buyHabibCoinPackages(): boolean {
return postActionToFlutter("buy_coin");
}
```
### رفتار سمت Flutter
فلاتر بدون نیاز به payload اضافی اکشن `buy_coin` را دریافت کرده و صفحه خرید پکیج را باز می‌کند:
```dart
'buy_coin' => context.pushPage(const PackagesListPage()),
```
---
## جمع‌بندی actionها
| Action | کاربرد |
@ -981,3 +1022,5 @@ void _handleUploadFile(dynamic payload) async {
| `upload_file` | انتخاب فایل/تصویر/ویدیو از دستگاه و آپلود به سرور (یا بازگرداندن base64) |
| `copy_to_clipboard` | کپی متن در clipboard دستگاه |
| `open_external_url` | باز کردن URL در مرورگر یا اپ خارجی |
| `buy_coin` | باز کردن صفحه خرید پکیج‌های حبیب کوین (`PackagesListPage`) در فلاتر |

33
src/app/globals.css

@ -253,31 +253,14 @@ body.dropdown-open .app-shell {
margin 300ms ease-in-out;
}
body.question-keyboard-open .app-shell .question-snap-list {
padding-top: 0;
padding-bottom: 0;
}
body.question-keyboard-open .app-shell .question-snap-item[aria-current="step"] {
top: 0;
bottom: 0;
}
body.question-keyboard-open
.app-shell
.question-snap-item[aria-current="step"]
.question-snap-content {
/* Preserve the normal centered layout and only lift it by measured height. */
margin-top: auto;
margin-bottom: auto;
transform: translateY(calc(var(--question-keyboard-shift, 0px) * -1));
}
body.question-keyboard-open
.app-shell
.question-snap-item[aria-current="step"]
.question-snap-spacer {
display: none;
.question-snap-content {
transform: translate3d(
0,
calc(var(--question-lift-y, 0px) * -1),
0
);
transition: transform 340ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
.page-background-none,

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

@ -22,6 +22,10 @@ import type {
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";
@ -219,6 +223,7 @@ export default function NewMatchPage() {
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;
@ -234,6 +239,7 @@ export default function NewMatchPage() {
try {
setPaymentError(null);
setIsInsufficientCoins(false);
await paymentMutation.mutateAsync(recommendedPlanId);
setIsPaymentSheetOpen(false);
router.push(localizePath("/new-match/profile", locale));
@ -243,6 +249,7 @@ export default function NewMatchPage() {
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.",
@ -527,6 +534,7 @@ export default function NewMatchPage() {
onClick={() => {
setIsPaymentSheetOpen(false);
setPaymentError(null);
setIsInsufficientCoins(false);
}}
>
<IoClose className="text-[22px]" />
@ -577,6 +585,28 @@ export default function NewMatchPage() {
</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"

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

@ -3,6 +3,7 @@
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import Button from "@/components/Componentes/button";
import DataErrorState from "@/components/Componentes/data-error-state";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import NavigationButton from "@/components/Componentes/navigation-button";
@ -190,11 +191,11 @@ export default function QuestionDetailClient({
const isCattellSlug = itemSlug === "personality_test";
const isGlasserSlug = itemSlug === "glasser_5_needs_test";
const isAssessment = isCattellSlug || isGlasserSlug;
const { data: overview, isLoading: isOverviewLoading } = useFormOverviewQuery(
const { data: overview, isLoading: isOverviewLoading, isError: isOverviewError, refetch: refetchOverview } = useFormOverviewQuery(
"profile",
locale,
);
const { data: sectionResponse, isLoading: isSectionLoading } =
const { data: sectionResponse, isLoading: isSectionLoading, isError: isSectionError, refetch: refetchSection } =
useFormSectionQuery(
"profile",
itemSlug,
@ -218,6 +219,9 @@ export default function QuestionDetailClient({
const isSchemaLoading = isAssessment
? isOverviewLoading
: !sectionResponse && (isOverviewLoading || isSectionLoading);
const isSchemaError = isAssessment
? isOverviewError
: (isOverviewError || isSectionError) && !sectionResponse;
const cattellQuery = useCattellQuestionsQuery(locale, {
enabled: isCattellSlug && isTestStarted,
@ -333,10 +337,10 @@ export default function QuestionDetailClient({
);
useEffect(() => {
if (!isSchemaLoading && !item) {
if (!isSchemaLoading && !isSchemaError && !item) {
router.replace(questionsListHref);
}
}, [isSchemaLoading, item, questionsListHref, router]);
}, [isSchemaLoading, isSchemaError, item, questionsListHref, router]);
if (isSchemaLoading) {
if (!isAssessment) {
@ -388,7 +392,98 @@ export default function QuestionDetailClient({
}
return <PageLoadingSkeleton compact variant="test" />;
} else if (!item) {
}
if (isSchemaError) {
const errorTitle = overviewItem?.title || title;
if (!isAssessment) {
return (
<>
<PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader
sticky={false}
className="question-detail-header shrink-0"
>
<div className="flex items-center gap-4">
<NavigationButton
className="shrink-0"
variant="transparent"
icon="close"
iconLabel={closeLabel}
onClick={() => router.push(questionsListHref)}
/>
<h1 className="min-w-0 flex-1 truncate text-center text-[14px] font-semibold text-white">
{errorTitle}
</h1>
<NavigationButton
className="shrink-0"
variant="transparent"
icon="info"
iconLabel={informationLabel}
helpTitle={errorTitle}
helpDescription={description}
/>
</div>
</StickyHeader>
<div className="flex min-h-0 flex-1 flex-col">
<DataErrorState
onRetry={() => {
if (isOverviewError) refetchOverview();
if (isSectionError) refetchSection();
}}
/>
</div>
</main>
</>
);
}
return (
<>
<PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader
sticky={false}
className="question-detail-header shrink-0"
>
<div className="flex items-center gap-4">
<NavigationButton
className="shrink-0"
variant="transparent"
icon="close"
iconLabel={closeLabel}
onClick={() => router.push(questionsListHref)}
/>
<h1 className="min-w-0 flex-1 truncate text-center text-[14px] font-semibold text-white">
{errorTitle}
</h1>
<NavigationButton
className="shrink-0"
variant="transparent"
icon="info"
iconLabel={informationLabel}
helpTitle={errorTitle}
helpDescription={description}
/>
</div>
</StickyHeader>
<div className="flex min-h-0 flex-1 flex-col">
<DataErrorState
onRetry={() => {
if (isOverviewError) refetchOverview();
}}
/>
</div>
</main>
</>
);
}
if (!item) {
return null;
}

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

@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IoClose } from "react-icons/io5";
import Button from "@/components/Componentes/button";
import 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";
@ -49,9 +50,9 @@ export default function QuestionsListPage() {
const { dictionary: t, locale } = useI18n();
const router = useRouter();
const queryClient = useQueryClient();
const { data: profile, isLoading: isProfileLoading } =
const { data: profile, isLoading: isProfileLoading, isError: isProfileError, refetch: refetchProfile } =
useMarriageProfileQuery();
const { data: overview, isLoading: isSchemaLoading } = useFormOverviewQuery(
const { data: overview, isLoading: isSchemaLoading, isError: isSchemaError, refetch: refetchOverview } = useFormOverviewQuery(
"profile",
locale,
);
@ -547,6 +548,58 @@ export default function QuestionsListPage() {
);
}
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 && (

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

@ -178,6 +178,8 @@ export default function RequestAcceptedPage() {
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] =
@ -286,6 +288,8 @@ export default function RequestAcceptedPage() {
}
try {
setPaymentError(null);
setIsInsufficientCoins(false);
const paymentResponse =
await paymentMutation.mutateAsync(recommendedPlanId);
const paymentUrl = extractHabcoinPaymentUrl(paymentResponse);
@ -301,8 +305,19 @@ export default function RequestAcceptedPage() {
await contactInfoQuery.refetch();
setIsContactInfoSheetOpen(true);
}
} catch (error) {
console.error("Habcoin payment request failed", error);
} 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);
}
}
};
@ -456,9 +471,15 @@ export default function RequestAcceptedPage() {
{isSubscriptionSheetOpen ? (
<SubscriptionRequiredSheet
onClose={() => setIsSubscriptionSheetOpen(false)}
onClose={() => {
setIsSubscriptionSheetOpen(false);
setPaymentError(null);
setIsInsufficientCoins(false);
}}
onPayment={handlePayment}
isPaymentPending={!recommendedPlanId || paymentMutation.isPending}
errorMessage={paymentError}
showBuyCoins={isInsufficientCoins}
/>
) : null}

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

@ -1,7 +1,10 @@
"use client";
import Image from "next/image";
import { useMarriageAdvisorsQuery } from "@/hooks/marriage/use-marriage-advisors";
import Button from "./button";
import NetworkImage from "./network-image";
const FALLBACK_AVATAR = "/assets/images/Avatar Image.png";
export type AdvisorAvatar = {
id: string;
@ -11,6 +14,7 @@ export type AdvisorAvatar = {
type AdvisorActionsCardProps = {
title: string;
description: string;
/** Static fallback avatars — used only while the API hasn't responded yet. */
avatars: AdvisorAvatar[];
extraCount: number;
getAdvisorLabel: string;
@ -21,12 +25,33 @@ type AdvisorActionsCardProps = {
export function AdvisorActionsCard({
title,
description,
avatars,
extraCount,
avatars: fallbackAvatars,
extraCount: fallbackExtraCount,
getAdvisorLabel,
getAdvisorHref,
className,
}: AdvisorActionsCardProps) {
const { data, isLoading } = useMarriageAdvisorsQuery();
const advisors = data?.results ?? [];
// Derive real avatars from API response (pick first 3 with an avatar).
const realAvatars: AdvisorAvatar[] = advisors
.filter((a) => a.avatar_url)
.slice(0, 3)
.map((a) => ({
id: a.username,
src: a.avatar_url ?? FALLBACK_AVATAR,
}));
const displayAvatars =
realAvatars.length > 0 ? realAvatars : fallbackAvatars;
// Show total remaining advisors (capped at advisor count minus shown avatars).
const displayExtraCount =
advisors.length > 0
? Math.max(0, advisors.length - displayAvatars.length)
: fallbackExtraCount;
return (
<section className={["space-y-3", className].filter(Boolean).join(" ")}>
<div className="rounded-[13px] border border-white/80 bg-white/72 px-3 py-3.5 text-left shadow-[0_18px_45px_rgba(15,23,42,0.06)] backdrop-blur-sm">
@ -39,13 +64,22 @@ export function AdvisorActionsCard({
<div className="mt-4 flex items-center justify-between gap-3">
<div className="flex items-center pl-1">
{avatars.map((avatar) => (
{isLoading
? /* ── Shimmer circles while API loads ── */
Array.from({ length: 3 }).map((_, i) => (
<span
key={`shimmer-${i}`}
className="-ml-1.5 flex h-[30px] w-[30px] overflow-hidden rounded-full border-2 border-white first:ml-0 shimmer-bg"
/>
))
: displayAvatars.map((avatar) => (
<span
key={avatar.id}
className="-ml-1.5 flex h-[30px] w-[30px] overflow-hidden rounded-full border-2 border-white bg-[#E7E7E7] first:ml-0"
>
<Image
<NetworkImage
src={avatar.src}
fallbackSrc={FALLBACK_AVATAR}
alt=""
width={30}
height={30}
@ -53,9 +87,11 @@ export function AdvisorActionsCard({
/>
</span>
))}
{!isLoading && displayExtraCount > 0 && (
<span className="-ml-1.5 flex h-[30px] w-[30px] items-center justify-center rounded-full border-2 border-white bg-[#EDEEF1] group-10 font-semibold text-[#1C1C1C]">
+{extraCount}
+{displayExtraCount}
</span>
)}
</div>
<Button

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

@ -0,0 +1,271 @@
"use client";
import { useI18n } from "@/translations/provider";
type DataErrorStateProps = {
/** Override the default title */
title?: string;
/** Override the default subtitle / message */
message?: string;
/** Called when the user taps "Try Again". Hide the button when omitted. */
onRetry?: () => void;
/** Optional className for the outermost wrapper */
className?: string;
};
/**
* Full-screen data-loading error state.
*
* Shown when a critical API call fails (profile, section list, section
* detail, etc.) **not** for form-submit failures which keep their
* existing toast / inline feedback.
*
* Design references:
* Najm `NoInternetErrorWidget` (disconnected-plug illustration + title
* + subtitle + "Try Again" button)
* Hosseinieh `ImportErrorState` (retry button with refresh icon)
*/
export default function DataErrorState({
title,
message,
onRetry,
className = "",
}: DataErrorStateProps) {
const { dictionary: t } = useI18n();
const displayTitle =
title ?? t["Connection error"] ?? "Connection error";
const displayMessage =
message ??
t[
"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";
return (
<div
className={`flex min-h-[60svh] flex-col items-center justify-center gap-0 px-6 text-center ${className}`}
>
{/* ── Disconnected-plug illustration (inline SVG — no network request) ── */}
<DisconnectedIllustration />
{/* ── Title ── */}
<h2 className="mt-8 text-[18px] font-bold leading-snug text-[#1B1B1B]">
{displayTitle}
</h2>
{/* ── Subtitle / message ── */}
<p className="mt-2 max-w-[280px] text-[13px] font-semibold leading-[1.55] text-[#8B8B8B]">
{displayMessage}
</p>
{/* ── Retry button ── */}
{onRetry && (
<button
type="button"
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)]"
>
<svg
aria-hidden="true"
className="size-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 4v5h-5" />
</svg>
{retryLabel}
</button>
)}
</div>
);
}
/*
* Disconnected-plug SVG illustration
*
* Adapted from Najm's `disconnected.svg` a split electrical plug with a
* soft blob background. Rendered inline so it's paint-ready on the first
* frame without a network round-trip.
* */
function DisconnectedIllustration() {
return (
<svg
aria-hidden="true"
className="w-[180px] h-[180px]"
viewBox="0 0 206 206"
fill="none"
>
{/* Background blob */}
<path
d="M53.7056 43.7398C53.7056 43.7398 80.7952 64.2725 121.979 25.8801C158.553 -8.21513 188.417 45.5796 188.626 71.3414C188.896 104.712 152.051 131.414 169.932 153.332C187.813 175.251 134.471 211.446 105.723 180.122C69.9607 141.155 60.2726 172.816 39.8883 172.816C25.2583 172.816 -4.77848 136.507 15.5051 109.495C32.5734 86.7652 23.2646 79.2214 18.7568 71.3414C12.254 59.9758 27.6967 29.1275 53.7056 43.7398Z"
fill="url(#de-blob)"
/>
{/* Right cable */}
<path
d="M150.891 91.4016C152.078 91.843 153.375 91.8724 154.61 91.5884C159.782 90.4017 177.566 87.258 174.476 101.039C173.803 103.773 163.388 119.087 178.22 124.676C179.308 125.091 180.457 125.317 181.62 125.345C186.187 125.439 195.237 124.361 199.025 120.506C203.601 115.848 198.602 118.056 198.602 118.056C198.602 118.056 181.207 125.648 175.062 117.594C174.378 116.698 173.943 115.396 173.819 114.276C173.602 112.333 174.464 109.766 175.597 107.531C177.131 104.507 184.494 90.9013 168.508 86.938C164.826 86.061 159.477 85.2738 152.581 88.1769C148.558 89.8705 149.449 90.866 150.891 91.4016Z"
fill="url(#de-cable)"
/>
<path
d="M154.45 86.3221L149.82 88.0554C148.222 88.6536 147.411 90.434 148.009 92.032L148.239 92.6456C148.837 94.2435 150.617 95.0543 152.215 94.4562L156.846 92.7236C158.444 92.1254 159.255 90.345 158.657 88.7463L158.427 88.1334C157.829 86.5347 156.049 85.724 154.45 86.3221Z"
fill="#DADADA"
/>
{/* Right plug lines */}
<path
d="M126.027 91.3367L110.758 97.0517C109.958 97.3504 109.553 98.2406 109.853 99.0396L109.913 99.2007C110.212 99.9997 111.102 100.405 111.901 100.106L127.171 94.3913C127.97 94.0919 128.376 93.2017 128.076 92.4027L128.016 92.2416C127.717 91.4426 126.827 91.0372 126.027 91.3367Z"
fill="white"
/>
<path
d="M130.289 102.723L115.019 108.438C114.22 108.737 113.815 109.627 114.114 110.426L114.174 110.587C114.473 111.386 115.363 111.792 116.162 111.492L131.432 105.778C132.231 105.479 132.637 104.588 132.338 103.789L132.278 103.628C131.979 102.829 131.088 102.424 130.289 102.723Z"
fill="white"
/>
{/* Right plug body */}
<path
d="M144.388 83.566L132.012 88.1973C130.413 88.7955 129.603 90.5766 130.201 92.1746L134.427 103.464C135.025 105.062 136.805 105.873 138.403 105.275L150.779 100.643C152.377 100.045 153.188 98.2648 152.59 96.6661L148.365 85.3766C147.767 83.7786 145.986 82.9679 144.388 83.566Z"
fill="url(#de-plug-r1)"
/>
<path
d="M133.29 86.5274L120.914 91.1595C119.316 91.7576 118.505 93.538 119.103 95.1367L124.144 108.605C124.742 110.203 126.523 111.014 128.121 110.416L140.497 105.785C142.095 105.187 142.906 103.406 142.308 101.807L137.266 88.3387C136.668 86.74 134.888 85.9293 133.29 86.5274Z"
fill="url(#de-plug-r2)"
/>
<path
d="M135.229 83.7943L135.131 83.8303C134.066 84.2291 133.525 85.4165 133.924 86.4819L140.986 105.351C141.385 106.416 142.572 106.957 143.637 106.558L143.734 106.521C144.8 106.122 145.34 104.936 144.942 103.87L137.88 85.0016C137.481 83.9363 136.294 83.3955 135.229 83.7943Z"
fill="#DADADA"
/>
{/* Left cable */}
<path
d="M9.03455 95.8576C9.03455 95.8576 33.8893 88.8727 30.2808 104.966C29.6541 107.512 20.6335 120.622 31.3784 126.917C33.6856 128.269 36.3482 128.868 39.022 128.854C43.8159 128.829 51.4157 127.906 54.8299 124.431C59.4055 119.773 54.4068 121.982 54.4068 121.982C54.4068 121.982 40.1323 127.854 32.7886 123.173C30.2811 121.575 29.1445 118.481 29.8254 115.586C30.1735 114.148 30.7034 112.761 31.4025 111.457C32.9996 108.465 40.2989 94.8268 24.3133 90.8635C20.6308 89.9866 15.2823 89.1993 8.38595 92.1025C1.48959 95.0063 9.03455 95.8576 9.03455 95.8576Z"
fill="url(#de-cable-l)"
/>
<path
d="M58.1729 118.381L53.5429 120.114C51.9446 120.712 51.1338 122.493 51.7321 124.092L51.9616 124.704C52.5597 126.303 54.3403 127.114 55.9386 126.515L60.5686 124.782C62.167 124.184 62.9777 122.404 62.3795 120.805L62.15 120.192C61.5518 118.594 59.7712 117.783 58.1729 118.381Z"
fill="#DADADA"
/>
{/* Left plug body */}
<path
d="M65.9947 110.763L59.1328 113.332C57.5344 113.93 56.7237 115.71 57.3219 117.309L60.6307 126.149C61.2289 127.748 63.0095 128.559 64.6077 127.961L71.4697 125.392C73.068 124.794 73.8786 123.014 73.2806 121.415L69.9718 112.575C69.3736 110.976 67.593 110.165 65.9947 110.763Z"
fill="url(#de-plug-l1)"
/>
<path
d="M77.4203 103.956L67.1874 107.786C65.5891 108.385 64.7784 110.165 65.3766 111.764L70.4175 125.232C71.0157 126.831 72.7963 127.642 74.3943 127.044L84.6274 123.214C86.2261 122.616 87.0368 120.835 86.4387 119.236L81.3976 105.768C80.7994 104.169 79.019 103.358 77.4203 103.956Z"
fill="url(#de-plug-l2)"
/>
<path
d="M79.5881 101.167L79.4909 101.203C78.4249 101.602 77.8849 102.79 78.2836 103.855L85.345 122.724C85.7438 123.789 86.9312 124.33 87.9965 123.931L88.0937 123.894C89.159 123.496 89.6997 122.309 89.301 121.243L82.2388 102.375C81.8401 101.309 80.6534 100.769 79.5881 101.167Z"
fill="white"
/>
{/* Spark lines (top) */}
<path
d="M105.719 89.1464C105.35 89.1052 105.012 88.9235 104.775 88.638C104.539 88.3525 104.422 87.9869 104.449 87.6168L105.213 73.6706C105.258 72.8376 105.962 72.2046 106.76 72.2783L107.872 72.3813C108.671 72.4549 109.242 73.2053 109.131 74.0326L107.254 87.8758C107.212 88.2444 107.028 88.5821 106.742 88.819C106.455 89.0559 106.089 89.1729 105.719 89.1464Z"
fill="#BEBEBE"
/>
<path
d="M99.1255 90.3488C99.0108 90.3826 98.8908 90.3936 98.7717 90.3811C98.6532 90.3679 98.5377 90.3318 98.4332 90.2745C98.328 90.2178 98.2361 90.1406 98.161 90.0471C98.086 89.9544 98.0301 89.847 97.9962 89.733L95.2697 81.0574C95.1902 80.811 95.2123 80.5424 95.33 80.3122C95.4484 80.0811 95.653 79.9068 95.9002 79.8273L96.6057 79.614C96.7271 79.5786 96.8544 79.5691 96.9794 79.5853C97.1045 79.6015 97.2245 79.6434 97.3326 79.7089C97.4408 79.7736 97.5342 79.8604 97.607 79.9634C97.6799 80.0664 97.7306 80.1834 97.7556 80.307L99.7766 89.1959C99.8333 89.4343 99.7972 89.6859 99.6766 89.9C99.5559 90.1133 99.3595 90.2737 99.1255 90.3488Z"
fill="#BEBEBE"
/>
<path
d="M95.4472 95.3515C94.9587 95.957 93.9757 95.9497 93.2194 95.3353L79.7919 84.4254C78.9893 83.7736 78.7531 82.695 79.271 82.0535L79.9935 81.1589C80.5122 80.5166 81.576 80.5695 82.3353 81.2744L95.0411 93.0796C95.7569 93.744 95.9357 94.7468 95.4472 95.3515Z"
fill="#BEBEBE"
/>
{/* Spark lines (bottom) */}
<path
d="M101.655 123.531C102 123.665 102.281 123.927 102.437 124.264C102.593 124.6 102.612 124.984 102.492 125.334L98.1915 138.623C97.9348 139.416 97.0931 139.848 96.3397 139.573L95.2899 139.19C94.5372 138.915 94.176 138.043 94.4953 137.273L99.8447 124.367C99.9808 124.022 100.244 123.742 100.582 123.586C100.919 123.43 101.303 123.41 101.655 123.531Z"
fill="#BEBEBE"
/>
<path
d="M108.335 124.052C108.455 124.049 108.574 124.069 108.686 124.112C108.797 124.155 108.899 124.219 108.986 124.301C109.073 124.383 109.143 124.481 109.191 124.59C109.24 124.699 109.267 124.817 109.27 124.937L109.691 134.021C109.704 134.28 109.615 134.533 109.441 134.725C109.268 134.919 109.026 135.035 108.767 135.049L108.03 135.075C107.904 135.078 107.779 135.055 107.662 135.007C107.545 134.959 107.439 134.888 107.351 134.798C107.264 134.707 107.195 134.6 107.151 134.481C107.108 134.363 107.088 134.237 107.095 134.111L107.411 125.001C107.418 124.756 107.517 124.522 107.688 124.346C107.859 124.17 108.09 124.066 108.335 124.052Z"
fill="#BEBEBE"
/>
<path
d="M113.168 120.155C113.795 119.694 114.743 119.952 115.317 120.74L125.514 134.717C126.122 135.552 126.076 136.655 125.411 137.143L124.484 137.824C123.819 138.312 122.803 137.989 122.249 137.114L112.979 122.455C112.458 121.631 112.541 120.615 113.168 120.155Z"
fill="#BEBEBE"
/>
<defs>
<linearGradient
id="de-blob"
x1="64.16"
y1="22"
x2="150.74"
y2="209.05"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F1F1F1" />
<stop offset="0.389" stopColor="#EAEAEA" />
<stop offset="1" stopColor="#F3F3F3" />
</linearGradient>
<linearGradient
id="de-cable"
x1="175.3"
y1="86.1"
x2="175.3"
y2="125.4"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#BCBCBC" />
<stop offset="1" stopColor="#DADADA" />
</linearGradient>
<linearGradient
id="de-plug-r1"
x1="141.4"
y1="83.4"
x2="141.4"
y2="105.5"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#BCBCBC" />
<stop offset="1" stopColor="#DADADA" />
</linearGradient>
<linearGradient
id="de-plug-r2"
x1="130.7"
y1="86.3"
x2="130.7"
y2="110.6"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#BCBCBC" />
<stop offset="1" stopColor="#DADADA" />
</linearGradient>
<linearGradient
id="de-cable-l"
x1="31.1"
y1="90.1"
x2="31.1"
y2="128.9"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#BCBCBC" />
<stop offset="1" stopColor="#DADADA" />
</linearGradient>
<linearGradient
id="de-plug-l1"
x1="65.3"
y1="110.6"
x2="65.3"
y2="128.2"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#BCBCBC" />
<stop offset="1" stopColor="#DADADA" />
</linearGradient>
<linearGradient
id="de-plug-l2"
x1="75.9"
y1="103.8"
x2="75.9"
y2="127.2"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#BCBCBC" />
<stop offset="1" stopColor="#DADADA" />
</linearGradient>
</defs>
</svg>
);
}

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

@ -1,25 +1,29 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import Image from "next/image";
import { useRouter } from "next/navigation";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { useState, useRef, useEffect } from "react";
import { useEffect, useRef, useState } from "react";
import { GoArrowLeft } from "react-icons/go";
import { HiEllipsisVertical } from "react-icons/hi2";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import {
extractHabcoinPaymentUrl,
useHabcoinPaymentMutation,
} from "@/hooks/marriage/use-habcoin-payment";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import {
buyHabibCoinPackages,
isInFlutterWebView,
} from "@/lib/webview-actions";
import { useI18n } from "@/translations/provider";
import ErrorToast from "./error-toast";
import HelpModal from "./help-modal";
import SupportSheet from "./support-sheet";
import InformationSheet from "./information-sheet";
import { useQueryClient } from "@tanstack/react-query";
import { UiIcon } from "./ui-icon";
import ErrorToast from "./error-toast";
import { hasSupportAccess } from "./support-access";
import {
useHabcoinPaymentMutation,
extractHabcoinPaymentUrl,
} from "@/hooks/marriage/use-habcoin-payment";
import SupportSheet from "./support-sheet";
import { UiIcon } from "./ui-icon";
type NavigationButtonIcon =
| "back"
@ -73,6 +77,7 @@ export function NavigationButton({
"error",
);
const [isRenewing, setIsRenewing] = useState(false);
const [isInsufficientCoins, setIsInsufficientCoins] = useState(false);
const renderSubscriptionModal = () => {
if (!isSubscriptionInfoOpen) return null;
@ -95,6 +100,28 @@ export function NavigationButton({
)}
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"
@ -116,6 +143,7 @@ export function NavigationButton({
setIsRenewing(true);
setToastMessage(null);
setToastVariant("error");
setIsInsufficientCoins(false);
try {
const paymentResponse =
await paymentMutation.mutateAsync(planId);
@ -144,6 +172,7 @@ export function NavigationButton({
errMessage.includes("Not enough coins") ||
err?.response?.status === 400
) {
setIsInsufficientCoins(true);
setToastMessage(
t[
"Insufficient coin balance. Please recharge your account."
@ -181,6 +210,7 @@ export function NavigationButton({
</div>
</button>
</div>
</div>
)}
/>
</>
@ -242,9 +272,7 @@ export function NavigationButton({
case "info":
return <UiIcon name="info" aria-hidden="true" className="size-6" />;
case "document":
return (
<UiIcon name="document" aria-hidden="true" className="size-6" />
);
return <UiIcon name="document" aria-hidden="true" className="size-6" />;
case "subscription":
return hasActiveSubscription ? (
<Image

101
src/components/Componentes/question-sheet.test.tsx

@ -214,14 +214,14 @@ describe("QuestionSheet component", () => {
pushState.mockRestore();
});
it("opens a searchable sheet without focusing search", () => {
it("opens a searchable sheet for 7+ options without focusing search", () => {
const question = {
id: "q_country",
title: "کشور",
type: "dropdown",
required: true,
extras: { placeHolder: "انتخاب کشور" },
options: Array.from({ length: 6 }, (_, index) => ({
options: Array.from({ length: 7 }, (_, index) => ({
id: `country-${index}`,
value: `country-${index}`,
label: `کشور ${index + 1}`,
@ -246,7 +246,7 @@ describe("QuestionSheet component", () => {
);
});
it("sizes a short options sheet to its content", () => {
it("sizes a short options sheet to its content (4 options)", () => {
const question = {
id: "q_short",
title: "انتخاب کوتاه",
@ -277,4 +277,99 @@ describe("QuestionSheet component", () => {
"max-h-[82svh]",
);
});
it("compact boundary: 5 options → compact, no search", () => {
const question = {
id: "q_boundary_5",
title: "پنج گزینه",
type: "dropdown",
required: true,
extras: { placeHolder: "انتخاب" },
options: Array.from({ length: 5 }, (_, i) => ({
id: `opt-${i}`,
value: `opt-${i}`,
label: `گزینه ${i + 1}`,
order: i + 1,
})),
ui_config: {},
} as QuestionField;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "انتخاب" }));
const section = screen.getByRole("dialog").querySelector("section");
expect(section).toHaveClass("h-auto", "max-h-[82svh]");
expect(screen.queryByPlaceholderText("جستجو...")).toBeNull();
});
it("compact boundary: 6 options → compact, no search", () => {
const question = {
id: "q_boundary_6",
title: "شش گزینه",
type: "dropdown",
required: true,
extras: { placeHolder: "انتخاب" },
options: Array.from({ length: 6 }, (_, i) => ({
id: `opt-${i}`,
value: `opt-${i}`,
label: `گزینه ${i + 1}`,
order: i + 1,
})),
ui_config: {},
} as QuestionField;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "انتخاب" }));
const section = screen.getByRole("dialog").querySelector("section");
expect(section).toHaveClass("h-auto", "max-h-[82svh]");
expect(section).not.toHaveClass("h-[82svh]");
expect(screen.queryByPlaceholderText("جستجو...")).toBeNull();
});
it("compact boundary: 7 options → large sheet with search", () => {
const question = {
id: "q_boundary_7",
title: "هفت گزینه",
type: "dropdown",
required: true,
extras: { placeHolder: "انتخاب" },
options: Array.from({ length: 7 }, (_, i) => ({
id: `opt-${i}`,
value: `opt-${i}`,
label: `گزینه ${i + 1}`,
order: i + 1,
})),
ui_config: {},
} as QuestionField;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "انتخاب" }));
const section = screen.getByRole("dialog").querySelector("section");
expect(section).toHaveClass("h-[82svh]");
expect(section).not.toHaveClass("h-auto");
expect(screen.getByPlaceholderText("جستجو...")).toBeDefined();
});
});

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

@ -8,6 +8,7 @@ import { Button } from "./button";
import { ExplanationUiFont } from "./explanation-ui-font";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
import { registerCompactQuestionSheet } from "./question-viewport-coordinator";
import { useSheetScrollLock } from "./use-sheet-scroll-lock";
const EXIT_ANIMATION_MS = 300;
@ -38,6 +39,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const [isClosing, setIsClosing] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const sheetRef = useRef<HTMLElement>(null);
const closeSheet = useCallback(() => {
setIsClosing(true);
window.setTimeout(() => {
@ -71,11 +73,27 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const options = question.options || [];
const showSearch = (() => {
if (question.extras?.noSearch) return false;
return options.length > 5;
})();
const isCompact = options.length <= 5;
const COMPACT_OPTIONS_MAX = 6;
const isCompact = options.length <= COMPACT_OPTIONS_MAX;
const showSearch =
!question.extras?.noSearch && options.length > COMPACT_OPTIONS_MAX;
useEffect(() => {
if (!isOpen || isClosing || !isCompact) return;
let unregister: (() => void) | undefined;
const frame = window.requestAnimationFrame(() => {
if (sheetRef.current) {
unregister = registerCompactQuestionSheet(sheetRef.current);
}
});
return () => {
window.cancelAnimationFrame(frame);
unregister?.();
};
}, [isCompact, isClosing, isOpen]);
const filteredOptions = options.filter((option) =>
option.label.toLowerCase().includes(searchQuery.toLowerCase()),
@ -200,6 +218,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
}}
>
<section
ref={sheetRef}
className={[
"flex 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",
isCompact ? "h-auto max-h-[82svh]" : "h-[82svh]",

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

@ -0,0 +1,186 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QuestionSnapList } from "./question-snap-list";
import {
computeQuestionLift,
updateQuestionKeyboardHeight,
} from "./question-viewport-coordinator";
vi.mock("@/translations/provider", () => ({
useI18n: () => ({ dictionary: {} }),
}));
describe("QuestionSnapList keyboard interaction", () => {
afterEach(() => {
cleanup();
document.body.classList.remove("question-keyboard-open");
document.documentElement.style.removeProperty("--question-lift-y");
});
it("computes a stable occlusion lift without overshoot", () => {
expect(
computeQuestionLift({
baseTop: 300,
baseBottom: 600,
visibleTop: 100,
visibleBottom: 500,
}),
).toBe(150);
expect(
computeQuestionLift({
baseTop: 300,
baseBottom: 600,
visibleTop: 100,
visibleBottom: 800,
}),
).toBe(0);
});
it("does not treat pointer contact as input focus", () => {
render(
<QuestionSnapList>
<input aria-label="Question one" type="text" />
</QuestionSnapList>,
);
fireEvent.pointerDown(
screen.getByRole("textbox", { name: "Question one" }),
);
expect(document.body).not.toHaveClass("question-keyboard-open");
});
it("activates only on real focus and resets before changing questions", async () => {
render(
<QuestionSnapList>
<input aria-label="Question one" type="text" />
<input aria-label="Question two" type="text" />
</QuestionSnapList>,
);
const firstInput = screen.getByRole("textbox", { name: "Question one" });
const secondInput = document.querySelector<HTMLInputElement>(
'input[aria-label="Question two"]',
);
const content = firstInput.closest<HTMLElement>(".question-snap-content");
vi.spyOn(content as HTMLElement, "getBoundingClientRect").mockReturnValue({
top: 300,
bottom: 600,
left: 0,
right: 300,
width: 300,
height: 300,
x: 0,
y: 300,
toJSON: () => ({}),
});
firstInput.focus();
updateQuestionKeyboardHeight(300);
await waitFor(() => {
expect(document.body).toHaveClass("question-keyboard-open");
});
fireEvent.wheel(screen.getByRole("region", { name: "Questions" }), {
deltaY: 100,
});
await waitFor(() => {
expect(document.body).not.toHaveClass("question-keyboard-open");
expect(firstInput).not.toHaveFocus();
expect(secondInput).not.toHaveFocus();
expect(
document.documentElement.style.getPropertyValue("--question-lift-y"),
).toBe("0px");
});
});
it("does not keep question keyboard state for a dialog input", async () => {
render(
<>
<QuestionSnapList>
<input aria-label="Question input" type="text" />
</QuestionSnapList>
<div role="dialog">
<input aria-label="Dialog search" type="text" />
</div>
</>,
);
const questionInput = screen.getByRole("textbox", {
name: "Question input",
});
const content = questionInput.closest<HTMLElement>(
".question-snap-content",
);
vi.spyOn(content as HTMLElement, "getBoundingClientRect").mockReturnValue({
top: 300,
bottom: 600,
left: 0,
right: 300,
width: 300,
height: 300,
x: 0,
y: 300,
toJSON: () => ({}),
});
questionInput.focus();
updateQuestionKeyboardHeight(300);
await waitFor(() => {
expect(document.body).toHaveClass("question-keyboard-open");
});
screen.getByRole("textbox", { name: "Dialog search" }).focus();
await waitFor(() => {
expect(document.body).not.toHaveClass("question-keyboard-open");
});
});
it("raises again when Flutter reopens the keyboard without another focusin", async () => {
render(
<QuestionSnapList>
<input aria-label="Persistent input" type="text" />
</QuestionSnapList>,
);
const input = screen.getByRole("textbox", { name: "Persistent input" });
const content = input.closest<HTMLElement>(".question-snap-content");
vi.spyOn(content as HTMLElement, "getBoundingClientRect").mockReturnValue({
top: 300,
bottom: 600,
left: 0,
right: 300,
width: 300,
height: 300,
x: 0,
y: 300,
toJSON: () => ({}),
});
input.focus();
updateQuestionKeyboardHeight(300);
await waitFor(() =>
expect(document.body).toHaveClass("question-keyboard-open"),
);
updateQuestionKeyboardHeight(0);
await waitFor(() => {
expect(document.body).not.toHaveClass("question-keyboard-open");
expect(input).toHaveFocus();
});
updateQuestionKeyboardHeight(300);
await waitFor(() => {
expect(document.body).toHaveClass("question-keyboard-open");
expect(input).toHaveFocus();
});
});
});

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

@ -9,9 +9,10 @@ import {
useRef,
useState,
} from "react";
import { useI18n } from "@/translations/provider";
import { useQuestionProgress } from "./question-progress-tracker";
import { useQuestionInputFocusSync } from "./use-sheet-scroll-lock";
import {
resetQuestionKeyboardState,
useQuestionViewportCoordinator,
} from "./question-viewport-coordinator";
const WHEEL_GESTURE_IDLE_MS = 320;
const TOUCH_MIN_DISTANCE = 8;
@ -21,11 +22,6 @@ const DRAG_FLICK_VELOCITY = 0.55;
const SNAP_ANIMATION_MS = 340;
const SNAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const RUBBER_BAND_RESISTANCE = 0.4;
const AUTO_FOCUS_SELECTOR = [
"textarea:not([disabled]):not([data-no-auto-focus])",
'input[type="text"]:not([disabled]):not([data-no-auto-focus])',
'input[type="number"]:not([disabled]):not([data-no-auto-focus])',
].join(", ");
const DRAG_IGNORE_SELECTOR = [
"input",
"textarea",
@ -69,9 +65,7 @@ export function QuestionSnapList({
onActiveIndexChange,
alignTop,
}: QuestionSnapListProps) {
const { dictionary: t } = useI18n();
const { isCompleted } = useQuestionProgress();
useQuestionInputFocusSync();
useQuestionViewportCoordinator();
const questions = Children.toArray(children);
const wheelLockedRef = useRef(false);
const wheelUnlockTimeoutRef = useRef<number | null>(null);
@ -113,6 +107,7 @@ export function QuestionSnapList({
return;
}
resetQuestionKeyboardState();
onQuestionExit?.(activeIndex, nextIndex);
onQuestionTransition?.(activeIndex, nextIndex);
@ -218,61 +213,6 @@ export function QuestionSnapList({
previousActiveIndexRef.current = activeIndex;
}, [activeIndex, onQuestionTransition]);
useEffect(() => {
if (activeIndex === 0) {
return;
}
const activeQuestion = questionRefs.current[activeIndex];
if (!activeQuestion) {
return;
}
const focusFrame = window.requestAnimationFrame(() => {
if (document.querySelector('[role="dialog"]')) {
return;
}
const firstFocusableInput = activeQuestion.querySelector<
HTMLInputElement | HTMLTextAreaElement
>(AUTO_FOCUS_SELECTOR);
if (!firstFocusableInput) {
return;
}
const isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);
firstFocusableInput.focus({ preventScroll: !isMobile });
if (isMobile) {
setTimeout(() => {
firstFocusableInput.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, 150);
}
const valueLength = firstFocusableInput.value.length;
if (
valueLength > 0 &&
typeof firstFocusableInput.setSelectionRange === "function"
) {
firstFocusableInput.setSelectionRange(valueLength, valueLength);
}
});
return () => {
window.cancelAnimationFrame(focusFrame);
};
}, [activeIndex]);
useEffect(() => {
return () => {
if (wheelUnlockTimeoutRef.current !== null) {
@ -538,6 +478,7 @@ export function QuestionSnapList({
}
const nextIndex = index + direction;
resetQuestionKeyboardState();
snapPanelsTo(direction * drag.height);
onQuestionExit?.(index, nextIndex);
onQuestionTransition?.(index, nextIndex);

257
src/components/Componentes/question-viewport-coordinator.ts

@ -0,0 +1,257 @@
"use client";
import { useEffect } from "react";
import { viewPaddingsBridge } from "@/lib/view-paddings";
const KEYBOARD_THRESHOLD = 80;
const QUESTION_GAP = 16;
const LIFT_VARIABLE = "--question-lift-y";
let activeQuestionInput: HTMLElement | null = null;
let keyboardVisible = false;
let keyboardHeight = 0;
let lastKeyboardHeight = 0;
let compactSheetTop: number | null = null;
let currentLift = 0;
let geometryFrame: number | null = null;
let closedViewportHeight = 0;
const NON_KEYBOARD_INPUT_TYPES = new Set([
"button",
"checkbox",
"color",
"file",
"hidden",
"image",
"radio",
"range",
"reset",
"submit",
]);
export type QuestionLiftGeometry = {
baseTop: number;
baseBottom: number;
visibleTop: number;
visibleBottom: number;
gap?: number;
};
export function computeQuestionLift({
baseTop,
baseBottom,
visibleTop,
visibleBottom,
gap = QUESTION_GAP,
}: QuestionLiftGeometry) {
const contentHeight = baseBottom - baseTop;
const availableTop = visibleTop + gap;
const availableBottom = visibleBottom - gap;
const availableCenter = (availableTop + availableBottom) / 2;
const contentCenter = baseTop + contentHeight / 2;
const centerShift = Math.max(0, contentCenter - availableCenter);
const overlapShift = Math.max(0, baseBottom - availableBottom);
const maxShift = Math.max(0, baseTop - availableTop);
return Math.min(Math.max(centerShift, overlapShift), maxShift);
}
function isKeyboardInputTarget(
target: EventTarget | null,
): target is HTMLElement {
if (target instanceof HTMLTextAreaElement) {
return !target.disabled && !target.readOnly;
}
if (target instanceof HTMLInputElement) {
const type = (target.getAttribute("type") ?? "text").toLowerCase();
return (
!NON_KEYBOARD_INPUT_TYPES.has(type) &&
!target.disabled &&
!target.readOnly
);
}
return target instanceof HTMLElement && target.isContentEditable;
}
export function isActiveQuestionKeyboardInput(
target: EventTarget | null,
): target is HTMLElement {
return (
isKeyboardInputTarget(target) &&
target.closest('.question-snap-item[aria-current="step"]') !== null
);
}
function setLift(nextLift: number) {
currentLift = Math.max(0, nextLift);
document.documentElement.style.setProperty(LIFT_VARIABLE, `${currentLift}px`);
document.body.classList.toggle(
"question-keyboard-open",
currentLift > 0 && keyboardVisible && activeQuestionInput !== null,
);
}
function getKeyboardTop() {
if (!keyboardVisible || !activeQuestionInput) return null;
const flutterTop = window.innerHeight - keyboardHeight;
const viewport = window.visualViewport;
const viewportBottom = viewport
? viewport.offsetTop + viewport.height
: window.innerHeight;
return Math.min(flutterTop, viewportBottom);
}
function updateLift() {
const content = document.querySelector<HTMLElement>(
'.question-snap-item[aria-current="step"] .question-snap-content',
);
if (!content) {
setLift(0);
return;
}
const keyboardTop = getKeyboardTop();
const visibleBottomCandidates = [keyboardTop, compactSheetTop].filter(
(value): value is number => value !== null,
);
if (visibleBottomCandidates.length === 0) {
setLift(0);
return;
}
const rect = content.getBoundingClientRect();
const snapList = content.closest<HTMLElement>(".question-snap-list");
const baseTop = rect.top + currentLift;
const baseBottom = rect.bottom + currentLift;
const safeTop = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--safe-top"),
);
setLift(
computeQuestionLift({
baseTop,
baseBottom,
visibleTop: Math.max(
Number.isFinite(safeTop) ? safeTop : 0,
snapList?.getBoundingClientRect().top ?? 0,
),
visibleBottom: Math.min(...visibleBottomCandidates),
}),
);
}
function scheduleLiftUpdate() {
if (geometryFrame !== null) window.cancelAnimationFrame(geometryFrame);
geometryFrame = window.requestAnimationFrame(() => {
geometryFrame = null;
updateLift();
});
}
export function updateQuestionKeyboardHeight(height: number) {
keyboardHeight = Math.max(0, height);
keyboardVisible = keyboardHeight > KEYBOARD_THRESHOLD;
if (keyboardVisible) {
lastKeyboardHeight = keyboardHeight;
if (isActiveQuestionKeyboardInput(document.activeElement)) {
activeQuestionInput = document.activeElement;
}
}
scheduleLiftUpdate();
}
export function registerCompactQuestionSheet(element: HTMLElement) {
compactSheetTop = element.getBoundingClientRect().top;
scheduleLiftUpdate();
return () => {
compactSheetTop = null;
scheduleLiftUpdate();
};
}
export function resetQuestionKeyboardState() {
if (geometryFrame !== null) {
window.cancelAnimationFrame(geometryFrame);
geometryFrame = null;
}
if (isActiveQuestionKeyboardInput(document.activeElement)) {
document.activeElement.blur();
}
activeQuestionInput = null;
keyboardVisible = false;
keyboardHeight = 0;
compactSheetTop = null;
setLift(0);
}
export function useQuestionViewportCoordinator() {
useEffect(() => {
closedViewportHeight = window.visualViewport?.height ?? window.innerHeight;
const handleFocusIn = (event: FocusEvent) => {
if (!isActiveQuestionKeyboardInput(event.target)) return;
activeQuestionInput = event.target;
// Start with the last known keyboard geometry so lifting begins in the
// same frame as focus; Flutter then replaces it with the exact height.
if (!keyboardVisible && lastKeyboardHeight > KEYBOARD_THRESHOLD) {
keyboardHeight = lastKeyboardHeight;
keyboardVisible = true;
}
scheduleLiftUpdate();
};
const handleFocusOut = () => {
window.setTimeout(() => {
activeQuestionInput = isActiveQuestionKeyboardInput(
document.activeElement,
)
? document.activeElement
: null;
if (!activeQuestionInput) scheduleLiftUpdate();
}, 0);
};
const unsubscribeConfig = viewPaddingsBridge.subscribeConfig((config) => {
updateQuestionKeyboardHeight(config.keyboardHeight);
});
const handleViewportResize = () => {
const viewportHeight =
window.visualViewport?.height ?? window.innerHeight;
const reduction = closedViewportHeight - viewportHeight;
if (reduction > KEYBOARD_THRESHOLD) {
if (isActiveQuestionKeyboardInput(document.activeElement)) {
activeQuestionInput = document.activeElement;
keyboardVisible = true;
keyboardHeight = Math.max(keyboardHeight, reduction);
lastKeyboardHeight = keyboardHeight;
scheduleLiftUpdate();
}
} else if (keyboardHeight <= KEYBOARD_THRESHOLD) {
keyboardVisible = false;
keyboardHeight = 0;
closedViewportHeight = viewportHeight;
scheduleLiftUpdate();
}
};
document.addEventListener("focusin", handleFocusIn);
document.addEventListener("focusout", handleFocusOut);
window.visualViewport?.addEventListener("resize", handleViewportResize);
return () => {
document.removeEventListener("focusin", handleFocusIn);
document.removeEventListener("focusout", handleFocusOut);
window.visualViewport?.removeEventListener(
"resize",
handleViewportResize,
);
unsubscribeConfig();
resetQuestionKeyboardState();
};
}, []);
}

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

@ -1,6 +1,11 @@
"use client";
import Image from "next/image";
import {
buyHabibCoinPackages,
isInFlutterWebView,
} from "@/lib/webview-actions";
import { useI18n } from "@/translations/provider";
import InformationSheet from "./information-sheet";
import { LoadingThreeDot } from "./loading-three-dot";
@ -8,38 +13,83 @@ type SubscriptionRequiredSheetProps = {
onClose: () => void;
onPayment: () => void;
isPaymentPending?: boolean;
/** Payment error to display above the buttons (e.g. "Not enough coins"). */
errorMessage?: string | null;
/** Show the "Buy Habib Coins" button (Flutter opens its packages page). */
showBuyCoins?: boolean;
};
export function SubscriptionRequiredSheet({
onClose,
onPayment,
isPaymentPending = false,
errorMessage = null,
showBuyCoins = false,
}: SubscriptionRequiredSheetProps) {
const { dictionary: t } = useI18n();
return (
<InformationSheet
icon="coin"
title="Subscription required"
title={t["Subscription required"] || "Subscription required"}
description={
<div className="space-y-4 text-left">
<p className="text-sm text-center leading-[1.45] font-medium text-[#2B2B2B]">
{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.
</>
)}
</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]">
To view profiles, a subscription is required. This helps cover our
support and matching services and applies only to male users
{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>
}
onClose={onClose}
buttons={({ close }) => (
<div className="space-y-3">
{errorMessage && (
<div className="w-full bg-[#FEF2F2] text-[#B91C1C] text-xs p-3 rounded-xl text-center font-medium">
{errorMessage}
</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">
<button
type="button"
@ -47,7 +97,7 @@ export function SubscriptionRequiredSheet({
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">Back</span>
<span className="truncate">{t["Back"] || "Back"}</span>
</div>
</button>
@ -62,7 +112,9 @@ export function SubscriptionRequiredSheet({
<LoadingThreeDot />
) : (
<>
<span className="truncate">Payment</span>
<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"
@ -79,6 +131,7 @@ export function SubscriptionRequiredSheet({
</div>
</button>
</div>
</div>
)}
/>
);

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

@ -1,11 +1,8 @@
"use client";
import { useEffect, useRef } from "react";
import { viewPaddingsBridge } from "@/lib/view-paddings";
let activeSheetCount = 0;
let hasFocusedKeyboardInput = false;
let keyboardHeight = 0;
let bodyHadDropdownClass = false;
let initialBodyOverflow = "";
let initialHtmlOverflow = "";
@ -92,136 +89,3 @@ export function useSheetScrollLock(
};
}, [isOpen]);
}
const NON_KEYBOARD_INPUT_TYPES = new Set([
"button",
"checkbox",
"color",
"file",
"hidden",
"image",
"radio",
"range",
"reset",
"submit",
]);
function isKeyboardInputTarget(target: EventTarget | null): boolean {
if (target instanceof HTMLTextAreaElement) {
return !target.disabled && !target.readOnly;
}
if (target instanceof HTMLInputElement) {
const type = (target.getAttribute("type") ?? "text").toLowerCase();
return (
!NON_KEYBOARD_INPUT_TYPES.has(type) &&
!target.disabled &&
!target.readOnly
);
}
if (target instanceof HTMLElement) {
return target.isContentEditable;
}
return false;
}
function syncQuestionInputOpenClass() {
if (hasFocusedKeyboardInput) {
document.body.classList.add("question-keyboard-open");
} else {
document.body.classList.remove("question-keyboard-open");
}
}
function syncKeyboardShift(isOpen: boolean) {
const root = document.documentElement;
if (!isOpen) {
root.style.setProperty("--question-keyboard-shift", "0px");
return;
}
const activeContent = document.querySelector<HTMLElement>(
'.question-snap-item[aria-current="step"] .question-snap-content',
);
const snapList = document.querySelector<HTMLElement>(".question-snap-list");
if (!activeContent || !snapList) return;
const contentTop = activeContent.getBoundingClientRect().top;
const targetTop = snapList.getBoundingClientRect().top + 8;
const requiredShift = Math.max(0, contentTop - targetTop);
root.style.setProperty("--question-keyboard-shift", `${requiredShift}px`);
}
/**
* Moves the active question up (same animation the dropdown sheet uses) while
* a keyboard input is focused, so the mobile keyboard cannot cover it.
*/
export function useQuestionInputFocusSync() {
useEffect(() => {
const activateFocusedQuestionInput = (event: Event) => {
if (!isKeyboardInputTarget(event.target)) return;
const target = event.target as HTMLElement;
if (!target.closest('.question-snap-item[aria-current="step"]')) return;
hasFocusedKeyboardInput = true;
syncQuestionInputOpenClass();
window.requestAnimationFrame(() => syncKeyboardShift(true));
};
const handleFocusIn = (event: FocusEvent) => {
activateFocusedQuestionInput(event);
};
const handlePointerDown = (event: PointerEvent) => {
activateFocusedQuestionInput(event);
};
const handleFocusOut = (event: FocusEvent) => {
if (!isKeyboardInputTarget(event.target)) return;
window.setTimeout(() => {
hasFocusedKeyboardInput = isKeyboardInputTarget(document.activeElement);
syncQuestionInputOpenClass();
if (!hasFocusedKeyboardInput) syncKeyboardShift(false);
}, 0);
};
const unsubscribeConfig = viewPaddingsBridge.subscribeConfig((config) => {
keyboardHeight = config.keyboardHeight;
if (keyboardHeight === 0) {
// Android Back can hide Flutter's keyboard without dispatching blur.
hasFocusedKeyboardInput = false;
}
syncQuestionInputOpenClass();
if (keyboardHeight === 0) syncKeyboardShift(false);
});
const initialViewportHeight = window.visualViewport?.height ?? 0;
const handleViewportResize = () => {
const viewportHeight = window.visualViewport?.height ?? 0;
if (
initialViewportHeight > 0 &&
viewportHeight >= initialViewportHeight - 40
) {
hasFocusedKeyboardInput = false;
syncQuestionInputOpenClass();
}
};
document.addEventListener("focusin", handleFocusIn);
document.addEventListener("focusout", handleFocusOut);
document.addEventListener("pointerdown", handlePointerDown, true);
window.visualViewport?.addEventListener("resize", handleViewportResize);
return () => {
document.removeEventListener("focusin", handleFocusIn);
document.removeEventListener("focusout", handleFocusOut);
document.removeEventListener("pointerdown", handlePointerDown, true);
window.visualViewport?.removeEventListener(
"resize",
handleViewportResize,
);
unsubscribeConfig();
hasFocusedKeyboardInput = false;
keyboardHeight = 0;
document.documentElement.style.setProperty(
"--question-keyboard-shift",
"0px",
);
document.body.classList.remove("question-keyboard-open");
};
}, []);
}

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

@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buyHabibCoinPackages,
copyToClipboard,
downloadFile,
isInFlutterWebView,
openExternalUrl,
postActionToFlutter,
uploadFile,
} from "./webview-actions";
describe("webview-actions", () => {
const originalHabibApp = window.HabibApp;
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
window.HabibApp = originalHabibApp;
});
describe("isInFlutterWebView", () => {
it("returns true when window.HabibApp.postMessage is defined", () => {
window.HabibApp = { postMessage: vi.fn() };
expect(isInFlutterWebView()).toBe(true);
});
it("returns false when window.HabibApp is undefined", () => {
delete (window as any).HabibApp;
expect(isInFlutterWebView()).toBe(false);
});
});
describe("postActionToFlutter", () => {
it("sends message without data when data is omitted", () => {
const mockPostMessage = vi.fn();
window.HabibApp = { postMessage: mockPostMessage };
const result = postActionToFlutter("custom_action");
expect(result).toBe(true);
expect(mockPostMessage).toHaveBeenCalledTimes(1);
expect(mockPostMessage).toHaveBeenCalledWith(
JSON.stringify({ action: "custom_action" }),
);
});
it("sends message with data when data is provided", () => {
const mockPostMessage = vi.fn();
window.HabibApp = { postMessage: mockPostMessage };
const result = postActionToFlutter("custom_action", { foo: "bar" });
expect(result).toBe(true);
expect(mockPostMessage).toHaveBeenCalledWith(
JSON.stringify({ action: "custom_action", data: { foo: "bar" } }),
);
});
it("returns false and does not throw when not in flutter webview", () => {
delete (window as any).HabibApp;
const result = postActionToFlutter("custom_action");
expect(result).toBe(false);
});
});
describe("buyHabibCoinPackages", () => {
it("dispatches buy_coin action to Flutter and returns true", () => {
const mockPostMessage = vi.fn();
window.HabibApp = { postMessage: mockPostMessage };
const result = buyHabibCoinPackages();
expect(result).toBe(true);
expect(mockPostMessage).toHaveBeenCalledTimes(1);
expect(mockPostMessage).toHaveBeenCalledWith(
JSON.stringify({ action: "buy_coin" }),
);
});
it("returns false when not running in Flutter WebView", () => {
delete (window as any).HabibApp;
const result = buyHabibCoinPackages();
expect(result).toBe(false);
});
});
describe("downloadFile", () => {
it("dispatches download_file action with options", () => {
const mockPostMessage = vi.fn();
window.HabibApp = { postMessage: mockPostMessage };
const result = downloadFile({
url: "https://example.com/file.pdf",
fileName: "file.pdf",
mimeType: "application/pdf",
fileType: "document",
});
expect(result).toBe(true);
expect(mockPostMessage).toHaveBeenCalledWith(
JSON.stringify({
action: "download_file",
data: {
url: "https://example.com/file.pdf",
fileName: "file.pdf",
mimeType: "application/pdf",
fileType: "document",
},
}),
);
});
});
describe("copyToClipboard", () => {
it("dispatches copy_to_clipboard in Flutter WebView", async () => {
const mockPostMessage = vi.fn();
window.HabibApp = { postMessage: mockPostMessage };
const result = await copyToClipboard("sample text", "invite_code");
expect(result).toBe(true);
expect(mockPostMessage).toHaveBeenCalledWith(
JSON.stringify({
action: "copy_to_clipboard",
data: {
text: "sample text",
label: "invite_code",
showToast: true,
},
}),
);
});
it("falls back to navigator.clipboard.writeText when outside WebView", async () => {
delete (window as any).HabibApp;
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, {
clipboard: { writeText: writeTextMock },
});
const result = await copyToClipboard("sample text");
expect(result).toBe(true);
expect(writeTextMock).toHaveBeenCalledWith("sample text");
});
});
describe("openExternalUrl", () => {
it("dispatches open_external_url action to Flutter", () => {
const mockPostMessage = vi.fn();
window.HabibApp = { postMessage: mockPostMessage };
const result = openExternalUrl({ url: "https://habibapp.com" });
expect(result).toBe(true);
expect(mockPostMessage).toHaveBeenCalledWith(
JSON.stringify({
action: "open_external_url",
data: {
mode: "externalApplication",
showErrorToast: true,
url: "https://habibapp.com",
},
}),
);
});
});
describe("uploadFile", () => {
it("dispatches upload_file action with options to Flutter", () => {
const mockPostMessage = vi.fn();
window.HabibApp = { postMessage: mockPostMessage };
const result = uploadFile({
mediaType: "image",
source: "gallery",
returnAs: "upload",
uploadUrl: "/api/upload",
});
expect(result).toBe(true);
expect(mockPostMessage).toHaveBeenCalledWith(
JSON.stringify({
action: "upload_file",
data: {
mediaType: "image",
source: "gallery",
returnAs: "upload",
uploadUrl: "/api/upload",
},
}),
);
});
});
});

18
src/lib/webview-actions.ts

@ -2,7 +2,7 @@
* WebView Utility Actions
*
* Helper module for sending utility actions to Flutter via HabibApp channel.
* Actions: download_file, copy_to_clipboard, open_external_url, upload_file
* Actions: download_file, copy_to_clipboard, open_external_url, upload_file, buy_coin
*
* Docs: WEBVIEW_ACTION_DOWNLOAD.md
*/
@ -150,3 +150,19 @@ export function uploadFile(options: UploadFileOptions): boolean {
options as unknown as Record<string, unknown>,
);
}
// ─── buy_coin ────────────────────────────────────────────
/**
* Ask Flutter to open the Habib Coin packages list page (Add Coin).
*
* Used when a payment fails with "Not enough coins": the user tops up
* natively, returns to the WebView and retries the payment.
*
* ```ts
* buyHabibCoinPackages();
* ```
*/
export function buyHabibCoinPackages(): boolean {
return postActionToFlutter("buy_coin");
}

8
src/translations/locales/ar.json

@ -761,5 +761,11 @@
"Year": "السنة",
"Failed to load advisors.": "تعذّر تحميل المستشارين.",
"Try again": "حاول مرة أخرى",
"No advisors are currently available.": "لا يوجد مستشارون متاحون حاليًا."
"No advisors are currently available.": "لا يوجد مستشارون متاحون حاليًا.",
"Connection error": "خطأ في الاتصال",
"Something went wrong. Please check your internet connection and try again.": "حدث خطأ ما. يرجى التحقق من اتصالك بالإنترنت والمحاولة مرة أخرى.",
"Buy Habib Coins": "شراء عملات حبيب",
"Subscription required": "الاشتراك مطلوب",
"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": "لعرض الملفات الشخصية، يلزم الاشتراك. يساعد ذلك في تغطية خدمات الدعم والتوفيق ويسري فقط على المستخدمين الذكور"
}

8
src/translations/locales/az.json

@ -761,5 +761,11 @@
"Year": "İl",
"Failed to load advisors.": "Məsləhətçiləri yükləmək alınmadı.",
"Try again": "Yenidən cəhd edin",
"No advisors are currently available.": "Hazırda mövcud məsləhətçi yoxdur."
"No advisors are currently available.": "Hazırda mövcud məsləhətçi yoxdur.",
"Connection error": "Bağlantı xətası",
"Something went wrong. Please check your internet connection and try again.": "Xəta baş verdi. İnternet bağlantınızı yoxlayın və yenidən cəhd edin.",
"Buy Habib Coins": "Həbib Sikkələri Al",
"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, 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"
}

8
src/translations/locales/bn.json

@ -761,5 +761,11 @@
"Year": "বছর",
"Failed to load advisors.": "উপদেষ্টাদের লোড করা যায়নি।",
"Try again": "আবার চেষ্টা করুন",
"No advisors are currently available.": "বর্তমানে কোনো উপদেষ্টা নেই।"
"No advisors are currently available.": "বর্তমানে কোনো উপদেষ্টা নেই।",
"Connection error": "সংযোগ ত্রুটি",
"Something went wrong. Please check your internet connection and try again.": "কিছু ভুল হয়েছে। আপনার ইন্টারনেট সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন।",
"Buy Habib Coins": "হাবিব কয়েন কিনুন",
"Subscription required": "সাবস্ক্রিপশন প্রয়োজন",
"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": "প্রোফাইল দেখতে সাবস্ক্রিপশন প্রয়োজন। এটি আমাদের সহায়তা এবং ম্যাচিং পরিষেবার খরচ বহন করতে সাহায্য করে এবং শুধুমাত্র পুরুষ ব্যবহারকারীদের জন্য প্রযোজ্য"
}

8
src/translations/locales/da.json

@ -761,5 +761,11 @@
"Year": "År",
"Failed to load advisors.": "Kunne ikke indlæse rådgivere.",
"Try again": "Prøv igen",
"No advisors are currently available.": "Der er i øjeblikket ingen tilgængelige rådgivere."
"No advisors are currently available.": "Der er i øjeblikket ingen tilgængelige rådgivere.",
"Connection error": "Forbindelsesfejl",
"Something went wrong. Please check your internet connection and try again.": "Noget gik galt. Tjek din internetforbindelse og prøv igen.",
"Buy Habib Coins": "Køb Habib-mønter",
"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, 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"
}

8
src/translations/locales/de.json

@ -761,5 +761,11 @@
"Year": "Jahr",
"Failed to load advisors.": "Berater konnten nicht geladen werden.",
"Try again": "Erneut versuchen",
"No advisors are currently available.": "Derzeit sind keine Berater verfügbar."
"No advisors are currently available.": "Derzeit sind keine Berater verfügbar.",
"Connection error": "Verbindungsfehler",
"Something went wrong. Please check your internet connection and try again.": "Etwas ist schiefgelaufen. Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.",
"Buy Habib Coins": "Habib-Münzen kaufen",
"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, 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"
}

8
src/translations/locales/en.json

@ -811,5 +811,11 @@
"Year": "Year",
"Failed to load advisors.": "Failed to load advisors.",
"Try again": "Try again",
"No advisors are currently available.": "No advisors are currently available."
"No advisors are currently available.": "No advisors are currently available.",
"Connection error": "Connection error",
"Something went wrong. Please check your internet connection and try again.": "Something went wrong. Please check your internet connection and try again.",
"Buy Habib Coins": "Buy Habib Coins",
"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, 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"
}

8
src/translations/locales/es.json

@ -761,5 +761,11 @@
"Year": "Año",
"Failed to load advisors.": "No se pudieron cargar los asesores.",
"Try again": "Intentar de nuevo",
"No advisors are currently available.": "Actualmente no hay asesores disponibles."
"No advisors are currently available.": "Actualmente no hay asesores disponibles.",
"Connection error": "Error de conexión",
"Something went wrong. Please check your internet connection and try again.": "Algo salió mal. Revisa tu conexión a Internet e inténtalo de nuevo.",
"Buy Habib Coins": "Comprar monedas Habib",
"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, 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"
}

8
src/translations/locales/fa.json

@ -811,5 +811,11 @@
"Year": "سال",
"Failed to load advisors.": "بارگیری مشاوران ناموفق بود.",
"Try again": "تلاش مجدد",
"No advisors are currently available.": "در حال حاضر مشاوری در دسترس نیست."
"No advisors are currently available.": "در حال حاضر مشاوری در دسترس نیست.",
"Connection error": "خطا در اتصال",
"Something went wrong. Please check your internet connection and try again.": "مشکلی پیش آمده است. لطفاً اتصال اینترنت خود را بررسی و دوباره تلاش کنید.",
"Buy Habib Coins": "خرید سکه حبیب",
"Subscription required": "اشتراک الزامی است",
"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": "برای مشاهده پروفایل‌ها، اشتراک الزامی است. این هزینه صرف خدمات پشتیبانی و همسان‌یابی می‌شود و فقط شامل کاربران مرد است"
}

8
src/translations/locales/fr.json

@ -761,5 +761,11 @@
"Year": "Année",
"Failed to load advisors.": "Échec du chargement des conseillers.",
"Try again": "Réessayer",
"No advisors are currently available.": "Aucun conseiller n'est disponible actuellement."
"No advisors are currently available.": "Aucun conseiller n'est disponible actuellement.",
"Connection error": "Erreur de connexion",
"Something went wrong. Please check your internet connection and try again.": "Un problème est survenu. Veuillez vérifier votre connexion Internet et réessayer.",
"Buy Habib Coins": "Acheter des pièces Habib",
"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, 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"
}

8
src/translations/locales/gu.json

@ -761,5 +761,11 @@
"Year": "વર્ષ",
"Failed to load advisors.": "સલાહકારોને લોડ કરવામાં નિષ્ફળ.",
"Try again": "ફરી પ્રયાસ કરો",
"No advisors are currently available.": "હાલમાં કોઈ સલાહકાર ઉપલબ્ધ નથી."
"No advisors are currently available.": "હાલમાં કોઈ સલાહકાર ઉપલબ્ધ નથી.",
"Connection error": "કનેક્શન ભૂલ",
"Something went wrong. Please check your internet connection and try again.": "કંઈક ખોટું થયું. કૃપા કરીને તમારું ઇન્ટરનેટ કનેક્શન તપાસો અને ફરી પ્રયાસ કરો.",
"Buy Habib Coins": "હબીબ સિક્કા ખરીદો",
"Subscription required": "સબ્સ્ક્રિપ્શન જરૂરી",
"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": "પ્રોફાઇલ જોવા માટે સબ્સ્ક્રિપ્શન જરૂરી છે. આ અમારી સપોર્ટ અને મેચિંગ સેવાઓને આવરી લેવામાં મદદ કરે છે અને ફક્ત પુરુષ વપરાશકર્તાઓને લાગુ પડે છે"
}

8
src/translations/locales/ha.json

@ -761,5 +761,11 @@
"Year": "Shekara",
"Failed to load advisors.": "Gaza loda masu ba da shawara.",
"Try again": "Sake gwadawa",
"No advisors are currently available.": "Babu masu ba da shawara a halin yanzu."
"No advisors are currently available.": "Babu masu ba da shawara a halin yanzu.",
"Connection error": "Kuskuren haɗi",
"Something went wrong. Please check your internet connection and try again.": "Wani abu ya faru ba daidai ba. Da fatan za a bincika haɗin intanet ɗinku kuma a sake gwadawa.",
"Buy Habib Coins": "Sayi Tsabar Kuɗin Habib",
"Subscription required": "Ana buƙatar biyan kuɗi",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "Don ganin bayanan mutane, kuna buƙatar biyan kuɗi. Kowane biyan kuɗi ya ƙunshi har zuwa {count} daidaituwa.",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "Don ganin bayanan mutane, ana buƙatar biyan kuɗi. Wannan yana taimakawa wajen biyan kuɗin sabis na tallafi da daidaitawa kuma yana shafi maza kawai"
}

8
src/translations/locales/he.json

@ -289,5 +289,11 @@
"Confirm": "אישור",
"Failed to load advisors.": "טעינת היועצים נכשלה.",
"Try again": "נסה שוב",
"No advisors are currently available.": "אין כרגע יועצים זמינים."
"No advisors are currently available.": "אין כרגע יועצים זמינים.",
"Connection error": "שגיאת חיבור",
"Something went wrong. Please check your internet connection and try again.": "משהו השתבש. אנא בדוק את חיבור האינטרנט שלך ונסה שוב.",
"Buy Habib Coins": "קנה מטבעות חביב",
"Subscription required": "נדרש מנוי",
"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": "כדי לצפות בפרופילים, נדרש מנוי. הדבר עוזר לכסות את שירותי התמיכה וההתאמה שלנו וחל רק על משתמשים גברים"
}

8
src/translations/locales/hi.json

@ -761,5 +761,11 @@
"Year": "साल",
"Failed to load advisors.": "सलाहकारों को लोड करने में विफल।",
"Try again": "फिर से प्रयास करें",
"No advisors are currently available.": "इस समय कोई सलाहकार उपलब्ध नहीं है।"
"No advisors are currently available.": "इस समय कोई सलाहकार उपलब्ध नहीं है।",
"Connection error": "कनेक्शन त्रुटि",
"Something went wrong. Please check your internet connection and try again.": "कुछ गलत हो गया। कृपया अपना इंटरनेट कनेक्शन जांचें और पुनः प्रयास करें।",
"Buy Habib Coins": "हबीब सिक्के खरीदें",
"Subscription required": "सदस्यता आवश्यक",
"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": "प्रोफाइल देखने के लिए सदस्यता आवश्यक है। यह हमारी सहायता और मिलान सेवाओं को कवर करने में मदद करती है और केवल पुरुष उपयोगकर्ताओं पर लागू होती है"
}

8
src/translations/locales/id.json

@ -289,5 +289,11 @@
"Confirm": "Konfirmasi",
"Failed to load advisors.": "Gagal memuat penasihat.",
"Try again": "Coba lagi",
"No advisors are currently available.": "Saat ini tidak ada penasihat yang tersedia."
"No advisors are currently available.": "Saat ini tidak ada penasihat yang tersedia.",
"Connection error": "Kesalahan koneksi",
"Something went wrong. Please check your internet connection and try again.": "Terjadi kesalahan. Silakan periksa koneksi internet Anda dan coba lagi.",
"Buy Habib Coins": "Beli Koin Habib",
"Subscription required": "Langganan diperlukan",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "Untuk melihat profil, Anda memerlukan langganan. Setiap langganan mencakup hingga {count} kecocokan.",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "Untuk melihat profil, diperlukan langganan. Ini membantu menutupi layanan dukungan dan pencocokan kami dan hanya berlaku untuk pengguna pria"
}

8
src/translations/locales/ks.json

@ -289,5 +289,11 @@
"Confirm": "تصدیق کریں",
"Failed to load advisors.": "مشیرن لوڈ کرنأک ناکام۔",
"Try again": "دوبآ کوشش کریو",
"No advisors are currently available.": "فی الحأل کاہن مشیر دستیاب چھُنہِ۔"
"No advisors are currently available.": "فی الحأل کاہن مشیر دستیاب چھُنہِ۔",
"Connection error": "رابطٕ غلطی",
"Something went wrong. Please check your internet connection and try again.": "کانٛہہ غلط ہوگیو۔ مہربٲنی کٔرتھ اپنؠ اِنٹرنیٹ کنکشن چیک کٔرِو تہٕ بیاکھ کوشِش کٔرِو۔",
"Buy Habib Coins": "حبیب سِکّہٕ خریٖد کٔرِو",
"Subscription required": "رکنیت ضروٗری ہے",
"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": "پروفایلس وُچھنہٕ خٲطرٕ رکنیت ضروٗری ہے۔ یِہ ساہٕ سپورٹ تہٕ میچِنگ خِدمات ہٕنٛد کَرنٕ مدد کَرِتھ تہٕ صرف مَرد صارفین پؠٹھ لاگوٗ ہے"
}

8
src/translations/locales/pt.json

@ -289,5 +289,11 @@
"Confirm": "Confirmar",
"Failed to load advisors.": "Falha ao carregar os consultores.",
"Try again": "Tentar novamente",
"No advisors are currently available.": "Nenhum consultor disponível no momento."
"No advisors are currently available.": "Nenhum consultor disponível no momento.",
"Connection error": "Erro de conexão",
"Something went wrong. Please check your internet connection and try again.": "Algo deu errado. Verifique sua conexão com a internet e tente novamente.",
"Buy Habib Coins": "Comprar moedas Habib",
"Subscription required": "Assinatura necessária",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "Para ver perfis, você precisa de uma assinatura. Cada assinatura inclui até {count} correspondências.",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "Para ver perfis, é necessária uma assinatura. Isso ajuda a cobrir nossos serviços de suporte e correspondência e se aplica apenas a usuários masculinos"
}

8
src/translations/locales/ru.json

@ -766,5 +766,11 @@
"Year": "Год",
"Failed to load advisors.": "Не удалось загрузить консультантов.",
"Try again": "Попробовать снова",
"No advisors are currently available.": "На данный момент нет доступных консультантов."
"No advisors are currently available.": "На данный момент нет доступных консультантов.",
"Connection error": "Ошибка соединения",
"Something went wrong. Please check your internet connection and try again.": "Что-то пошло не так. Проверьте подключение к интернету и попробуйте снова.",
"Buy Habib Coins": "Купить монеты Habib",
"Subscription required": "Требуется подписка",
"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": "Для просмотра профилей требуется подписка. Это помогает покрыть наши услуги поддержки и подбора и применяется только к мужчинам"
}

8
src/translations/locales/sw.json

@ -289,5 +289,11 @@
"Confirm": "Thibitisha",
"Failed to load advisors.": "Imeshindwa kupakia washauri.",
"Try again": "Jaribu tena",
"No advisors are currently available.": "Hakuna washauri wanaopatikana kwa sasa."
"No advisors are currently available.": "Hakuna washauri wanaopatikana kwa sasa.",
"Connection error": "Hitilafu ya muunganisho",
"Something went wrong. Please check your internet connection and try again.": "Kuna hitilafu fulani. Tafadhali angalia muunganisho wako wa intaneti na ujaribu tena.",
"Buy Habib Coins": "Nunua Sarafu za Habib",
"Subscription required": "Usajili unahitajika",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "Ili kuona wasifu, unahitaji usajili. Kila usajili unajumuisha hadi mechi {count}.",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "Ili kuona wasifu, usajili unahitajika. Hii husaidia kugharamia huduma zetu za usaidizi na kuoanisha na inatumika kwa watumiaji wa kiume pekee"
}

8
src/translations/locales/tg.json

@ -289,5 +289,11 @@
"Confirm": "Тасдиқ кунед",
"Failed to load advisors.": "Бор кардани маслиҳатдиҳондагон ноком буд.",
"Try again": "Дубора кӯшиш кунед",
"No advisors are currently available.": "Дар айни замон маслиҳатдиҳон дастрас нест."
"No advisors are currently available.": "Дар айни замон маслиҳатдиҳон дастрас нест.",
"Connection error": "Хатои пайвастшавӣ",
"Something went wrong. Please check your internet connection and try again.": "Чизе нодуруст рух дод. Лутфан пайвастшавии интернети худро санҷед ва дубора кӯшиш кунед.",
"Buy Habib Coins": "Харидани тангаҳои Ҳабиб",
"Subscription required": "Обуна лозим аст",
"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": "Барои дидани профилҳо, обуна лозим аст. Ин барои пардохти хидматҳои дастгирӣ ва мувофиқат кӯмак мекунад ва танҳо ба корбарони мард дахл дорад"
}

8
src/translations/locales/tr.json

@ -289,5 +289,11 @@
"Confirm": "Onayla",
"Failed to load advisors.": "Danışmanlar yüklenemedi.",
"Try again": "Tekrar dene",
"No advisors are currently available.": "Şu anda uygun danışman yok."
"No advisors are currently available.": "Şu anda uygun danışman yok.",
"Connection error": "Bağlantı hatası",
"Something went wrong. Please check your internet connection and try again.": "Bir şeyler yanlış gitti. Lütfen internet bağlantınızı kontrol edin ve tekrar deneyin.",
"Buy Habib Coins": "Habib Jetonu Satın Al",
"Subscription required": "Abonelik gerekli",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "Profilleri görüntülemek için aboneliğe ihtiyacınız var. Her abonelik {count} eşleşme içerir.",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "Profilleri görüntülemek için abonelik gereklidir. Bu, destek ve eşleştirme hizmetlerimizi karşılamaya yardımcı olur ve yalnızca erkek kullanıcılar için geçerlidir"
}

8
src/translations/locales/ul.json

@ -289,5 +289,11 @@
"Confirm": "جەزملەش",
"Failed to load advisors.": "مشاورین لوڈ نہیں ہو سکے۔",
"Try again": "دوبارہ کوشش کریں",
"No advisors are currently available.": "اس وقت کوئی مشاور دستیاب نہیں ہے۔"
"No advisors are currently available.": "اس وقت کوئی مشاور دستیاب نہیں ہے۔",
"Connection error": "د ارتباط تېروتنه",
"Something went wrong. Please check your internet connection and try again.": "یو څه غلط شو. مهرباني وکړئ خپل انټرنیټ ارتباط وګورئ او بیا هڅه وکړئ.",
"Buy Habib Coins": "د حبیب سکې واخلئ",
"Subscription required": "ګډون ته اړتیا ده",
"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": "د پروفایلونو لیدلو لپاره ګډون ته اړتیا ده. دا زموږ د ملاتړ او میچ کولو خدماتو لپاره مرسته کوي او یوازې د نارینه کاروونکو لپاره پلي کیږي"
}

8
src/translations/locales/ur.json

@ -289,5 +289,11 @@
"Confirm": "تصدیق کریں",
"Failed to load advisors.": "مشاورین لوڈ نہیں ہو سکے۔",
"Try again": "دوبارہ کوشش کریں",
"No advisors are currently available.": "اس وقت کوئی مشاور دستیاب نہیں ہے۔"
"No advisors are currently available.": "اس وقت کوئی مشاور دستیاب نہیں ہے۔",
"Connection error": "رابطے کی خرابی",
"Something went wrong. Please check your internet connection and try again.": "کچھ غلط ہو گیا۔ براہ کرم اپنا انٹرنیٹ کنکشن چیک کریں اور دوبارہ کوشش کریں۔",
"Buy Habib Coins": "حبیب سکے خریدیں",
"Subscription required": "رکنیت ضروری ہے",
"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": "پروفائلز دیکھنے کے لیے رکنیت ضروری ہے۔ یہ ہماری سپورٹ اور میچنگ سروسز کی لاگت پوری کرنے میں مدد کرتا ہے اور صرف مرد صارفین پر لاگو ہوتا ہے"
}

8
src/translations/locales/uz.json

@ -289,5 +289,11 @@
"Confirm": "Tasdiqlash",
"Failed to load advisors.": "Maslahatchilarni yuklab bo‘lmadi.",
"Try again": "Qayta urinib ko‘ring",
"No advisors are currently available.": "Hozircha mavjud maslahatchi yo‘q."
"No advisors are currently available.": "Hozircha mavjud maslahatchi yo‘q.",
"Connection error": "Ulanish xatosi",
"Something went wrong. Please check your internet connection and try again.": "Nimadir noto'g'ri ketdi. Iltimos, internet ulanishingizni tekshiring va qayta urinib ko'ring.",
"Buy Habib Coins": "Habib tangalarini sotib oling",
"Subscription required": "Obuna talab qilinadi",
"To view profiles, you need a subscription. Each subscription includes up to {count} matches.": "Profillarni ko'rish uchun obuna kerak. Har bir obuna {count}tagacha moslikni o'z ichiga oladi.",
"To view profiles, a subscription is required. This helps cover our support and matching services and applies only to male users": "Profillarni ko'rish uchun obuna talab qilinadi. Bu bizning qo'llab-quvvatlash va moslashtirish xizmatlarimizni qoplashga yordam beradi va faqat erkak foydalanuvchilarga tegishli"
}

8
src/translations/locales/zh.json

@ -761,5 +761,11 @@
"Year": "年",
"Failed to load advisors.": "加载顾问失败。",
"Try again": "重试",
"No advisors are currently available.": "目前没有可用的顾问。"
"No advisors are currently available.": "目前没有可用的顾问。",
"Connection error": "连接错误",
"Something went wrong. Please check your internet connection and try again.": "出了点问题。请检查您的互联网连接并重试。",
"Buy Habib Coins": "购买Habib币",
"Subscription required": "需要订阅",
"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": "查看个人资料需要订阅。这有助于支付我们的支持和匹配服务费用,仅适用于男性用户"
}
Loading…
Cancel
Save