Browse Source

ssr 3

master
mortezaei 7 days ago
parent
commit
8302f9ac9c
  1. 7
      src/app/candidate-contact/candidate-contact-client.tsx
  2. 7
      src/app/finding-match/finding-match-client.tsx
  3. 197
      src/app/intro/intro-client.tsx
  4. 222
      src/app/intro/page.tsx
  5. 71
      src/app/layout.tsx
  6. 7
      src/app/new-match/new-match-client.tsx
  7. 7
      src/app/questions-list/questions-list-client.tsx
  8. 7
      src/app/request-accepted/request-accepted-client.tsx
  9. 7
      src/app/request-sent/request-sent-client.tsx
  10. 5
      src/app/terms/page.tsx
  11. 20
      src/components/Componentes/entry-route-resolver.tsx
  12. 12
      src/components/Componentes/navigation-button.tsx
  13. 25
      src/components/Componentes/page-header.tsx
  14. 1
      src/hooks/marriage/query-keys.ts
  15. 3
      src/hooks/marriage/use-marriage-config.ts
  16. 25
      src/hooks/use-habib-web-ready.ts
  17. 51
      src/hooks/useFlutterBridge.ts
  18. 39
      src/lib/ssr-fetch.ts
  19. 2
      src/types/window.d.ts

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

@ -3,6 +3,7 @@
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot"; import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import CallResultSheet from "@/components/Componentes/call-result-sheet"; import CallResultSheet from "@/components/Componentes/call-result-sheet";
@ -50,11 +51,7 @@ export default function CandidateContactClient() {
}, [profile, router, locale]); }, [profile, router, locale]);
// Signal Flutter to lift its loading cover once the profile is available. // Signal Flutter to lift its loading cover once the profile is available.
useEffect(() => {
if (profile && !isProfileLoading) {
window.__announceHabibWebReady?.();
}
}, [profile, isProfileLoading]);
useHabibWebReady(!!profile && !isProfileLoading);
const isRedirecting = useMemo(() => { const isRedirecting = useMemo(() => {
if (!profile) return false; if (!profile) return false;

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

@ -3,6 +3,7 @@
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { FaLock, FaPen } from "react-icons/fa6"; import { FaLock, FaPen } from "react-icons/fa6";
import { IoAlertCircle } from "react-icons/io5"; import { IoAlertCircle } from "react-icons/io5";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
@ -44,11 +45,7 @@ export default function FindingMatchClient() {
}, [profile, locale, router]); }, [profile, locale, router]);
// Signal Flutter to lift its loading cover once the profile is available. // Signal Flutter to lift its loading cover once the profile is available.
useEffect(() => {
if (profile && !isLoading) {
window.__announceHabibWebReady?.();
}
}, [profile, isLoading]);
useHabibWebReady(!!profile && !isLoading);
const isRedirecting = useMemo(() => { const isRedirecting = useMemo(() => {
if (!profile) return false; if (!profile) return false;

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

@ -0,0 +1,197 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import Button from "@/components/Componentes/button";
import NetworkImage from "@/components/Componentes/network-image";
import PageHeader from "@/components/Componentes/page-header";
import ReportActionsSheet from "@/components/Componentes/report-actions-sheet";
import VideoPlayer from "@/components/Componentes/video-player";
import { useMarriageConfigQuery } from "@/hooks/marriage/use-marriage-config";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { authBridge } from "@/lib/auth-bridge";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
export default function IntroClient() {
const router = useRouter();
const { dictionary: t, locale } = useI18n();
const { data: profile, refetch } = useMarriageProfileQuery({
enabled: false,
retry: false,
});
const [isReportSheetOpen, setIsReportSheetOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
const { data: config } = useMarriageConfigQuery();
// Signal Flutter that the Intro UI is ready. Config has a local fallback
// image so we don't need to wait for it — announce immediately on mount.
useHabibWebReady(true);
const handleSubmit = async () => {
if (isSubmitting) {
return;
}
setIsSubmitting(true);
try {
if (!authBridge.isAuthenticated()) {
const token = await authBridge.ensureToken();
if (!token) {
console.warn("No token from bridge – login was not completed");
return;
}
}
let profileResponse = profile;
try {
const { data: freshProfile } = await refetch();
profileResponse = freshProfile ?? profile;
} catch (refetchError) {
console.warn(
"Could not refetch profile data – using fallback",
refetchError,
);
}
const submitPath = getSubmitPath(profileResponse);
const nextPath = localizePath(
submitPath === "/intro" ? "/terms" : submitPath,
locale,
);
router.push(nextPath);
} catch (error) {
console.error("Submission/redirect failed", error);
router.push(localizePath("/terms", locale));
} finally {
setIsSubmitting(false);
}
};
return (
<div className="pt-[max(12px,calc(var(--safe-top)+4px))]">
{isReportSheetOpen && (
<ReportActionsSheet onClose={() => setIsReportSheetOpen(false)} />
)}
<PageHeader
rightButton={{
icon: "support",
onClick: () => setIsReportSheetOpen(true),
}}
/>
<main className="pb-[calc(90px+var(--safe-bottom))]">
<div className="flex flex-col items-center mt-16">
<Image
src={"/assets/images/Group 1597880466.svg"}
alt={t["heavenly marriage"]}
width={168}
height={155}
/>
<h2 className="group-16 text-[#475569] font-bold mt-5 text-center">
{t["A Path to Heavenly Marriage"]}
</h2>
<p className="text-center mt-2 group-12 text-[#4D4D4D]">
{
t[
'We have come together with the goal of creating a secure and confidential path for "permanent marriage" among Muslims'
]
}
</p>
</div>
<div className="flex items-center justify-between mt-4">
<div className="flex items-center gap-0.5 p-2 border border-[#FD6ABB]/10 rounded-2xl">
<Image
src={"/assets/images/tabler_user-filled.svg"}
alt={t["user profiles"]}
width={37}
height={37}
/>
<div className="flex flex-col items-start justify-start -mb-2">
<p className="text-[#36363C] group-16 font-bold leading-3">120</p>
<p className="text-[#36363C] group-10 font-semibold capitalize">
{t["user profiles"]}
</p>
</div>
</div>
<div className="flex items-center gap-0.5 p-2 border border-[#FD6ABB]/10 rounded-2xl">
<Image
src={"/assets/images/icon-park-solid_success.svg"}
alt={t["matches"]}
width={37}
height={37}
priority
/>
<div className="flex flex-col items-start justify-start -mb-2">
<p className="text-[#36363C] group-16 font-bold leading-3">14</p>
<p className="text-[#36363C] group-10 font-semibold capitalize">
{t["matches"]}
</p>
</div>
</div>
<div className="flex items-center gap-0.5 p-2 border border-[#FD6ABB]/10 rounded-2xl">
<Image
src={"/assets/images/typcn_heart-full-outline.svg"}
alt={t["marriages"]}
width={37}
height={37}
priority
/>
<div className="flex flex-col items-start justify-start -mb-2">
<p className="text-[#36363C] group-16 font-bold leading-3">14</p>
<p className="text-[#36363C] group-10 font-semibold capitalize">
{t["marriages"]}
</p>
</div>
</div>
</div>
<div
className="mt-14 relative cursor-pointer group rounded-2xl overflow-hidden aspect-[344/221] max-w-[344px] w-full mx-auto"
onClick={() => setIsPlayerOpen(true)}
>
<NetworkImage
src={config?.intro_video_thumbnail_url}
fallbackSrc="/assets/images/Frame 2095586523.png"
alt={t["video"]}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
priority
/>
<div className="absolute inset-0 bg-black/10 transition-colors duration-300 group-hover:bg-black/20" />
<Image
src={"/assets/images/Frame 1116607280.svg"}
alt={t["play"]}
width={68}
height={68}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transition-transform duration-300 group-hover:scale-110 active:scale-95"
/>
</div>
<VideoPlayer
isOpen={isPlayerOpen}
onClose={() => setIsPlayerOpen(false)}
videoUrl={config?.intro_video_url}
/>
<div className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]">
<div
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }}
className="pointer-events-auto px-[17px] pt-3"
>
<Button
onClick={handleSubmit}
disabled={isSubmitting}
isLoading={isSubmitting}
>
{t["Submit"]}
</Button>
</div>
</div>
</main>
</div>
);
}

222
src/app/intro/page.tsx

@ -1,192 +1,42 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import Button from "@/components/Componentes/button";
import NetworkImage from "@/components/Componentes/network-image";
import PageHeader from "@/components/Componentes/page-header";
import ReportActionsSheet from "@/components/Componentes/report-actions-sheet";
import VideoPlayer from "@/components/Componentes/video-player";
import { useMarriageConfigQuery } from "@/hooks/marriage/use-marriage-config";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { authBridge } from "@/lib/auth-bridge";
import { getSubmitPath } from "@/lib/get-submit-path";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
export default function Intro() {
const router = useRouter();
const { dictionary: t, locale } = useI18n();
const { data: profile, refetch } = useMarriageProfileQuery({
enabled: false,
retry: false,
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import { fetchConfigSSR } from "@/lib/ssr-fetch";
import IntroClient from "./intro-client";
export const dynamic = "force-dynamic";
/**
* Server Component wrapper for the Intro page.
*
* Follows the same SSR-prefetch pattern as questions-list/page.tsx:
* create a server QueryClient, prefetch critical data, dehydrate, and
* wrap the client component in HydrationBoundary so TanStack Query
* hydrates the cache instantly no client-side waterfall.
*
* Config is the only data Intro needs. Even if the fetch fails, IntroClient
* has a local fallback image so the UI is never broken.
*/
export default async function IntroPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30 * 1000 },
},
}); });
const [isReportSheetOpen, setIsReportSheetOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
const { data: config } = useMarriageConfigQuery();
const handleSubmit = async () => {
if (isSubmitting) {
return;
}
setIsSubmitting(true);
try {
if (!authBridge.isAuthenticated()) {
const token = await authBridge.ensureToken();
if (!token) {
console.warn("No token from bridge – login was not completed");
return;
}
}
let profileResponse = profile;
try {
const { data: freshProfile } = await refetch();
profileResponse = freshProfile ?? profile;
} catch (refetchError) {
console.warn(
"Could not refetch profile data – using fallback",
refetchError,
);
}
const submitPath = getSubmitPath(profileResponse);
const nextPath = localizePath(
submitPath === "/intro" ? "/terms" : submitPath,
locale,
);
router.push(nextPath);
} catch (error) {
console.error("Submission/redirect failed", error);
router.push(localizePath("/terms", locale));
} finally {
setIsSubmitting(false);
}
};
// Prefetch marriage config — Intro uses it for the video thumbnail.
// Failure is non-blocking because IntroClient has a fallbackSrc.
await queryClient.prefetchQuery({
queryKey: marriageQueryKeys.config(),
queryFn: () => fetchConfigSSR(),
});
return ( return (
<div className="pt-[max(12px,calc(var(--safe-top)+4px))]">
{isReportSheetOpen && (
<ReportActionsSheet onClose={() => setIsReportSheetOpen(false)} />
)}
<PageHeader
rightButton={{
icon: "support",
onClick: () => setIsReportSheetOpen(true),
}}
/>
<main className="pb-[calc(90px+var(--safe-bottom))]">
<div className="flex flex-col items-center mt-16">
<Image
src={"/assets/images/Group 1597880466.svg"}
alt={t["heavenly marriage"]}
width={168}
height={155}
/>
<h2 className="group-16 text-[#475569] font-bold mt-5 text-center">
{t["A Path to Heavenly Marriage"]}
</h2>
<p className="text-center mt-2 group-12 text-[#4D4D4D]">
{
t[
'We have come together with the goal of creating a secure and confidential path for "permanent marriage" among Muslims'
]
}
</p>
</div>
<div className="flex items-center justify-between mt-4">
<div className="flex items-center gap-0.5 p-2 border border-[#FD6ABB]/10 rounded-2xl">
<Image
src={"/assets/images/tabler_user-filled.svg"}
alt={t["user profiles"]}
width={37}
height={37}
/>
<div className="flex flex-col items-start justify-start -mb-2">
<p className="text-[#36363C] group-16 font-bold leading-3">120</p>
<p className="text-[#36363C] group-10 font-semibold capitalize">
{t["user profiles"]}
</p>
</div>
</div>
<div className="flex items-center gap-0.5 p-2 border border-[#FD6ABB]/10 rounded-2xl">
<Image
src={"/assets/images/icon-park-solid_success.svg"}
alt={t["matches"]}
width={37}
height={37}
priority
/>
<div className="flex flex-col items-start justify-start -mb-2">
<p className="text-[#36363C] group-16 font-bold leading-3">14</p>
<p className="text-[#36363C] group-10 font-semibold capitalize">
{t["matches"]}
</p>
</div>
</div>
<div className="flex items-center gap-0.5 p-2 border border-[#FD6ABB]/10 rounded-2xl">
<Image
src={"/assets/images/typcn_heart-full-outline.svg"}
alt={t["marriages"]}
width={37}
height={37}
priority
/>
<div className="flex flex-col items-start justify-start -mb-2">
<p className="text-[#36363C] group-16 font-bold leading-3">14</p>
<p className="text-[#36363C] group-10 font-semibold capitalize">
{t["marriages"]}
</p>
</div>
</div>
</div>
<div
className="mt-14 relative cursor-pointer group rounded-2xl overflow-hidden aspect-[344/221] max-w-[344px] w-full mx-auto"
onClick={() => setIsPlayerOpen(true)}
>
<NetworkImage
src={config?.intro_video_thumbnail_url}
fallbackSrc="/assets/images/Frame 2095586523.png"
alt={t["video"]}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
priority
/>
<div className="absolute inset-0 bg-black/10 transition-colors duration-300 group-hover:bg-black/20" />
<Image
src={"/assets/images/Frame 1116607280.svg"}
alt={t["play"]}
width={68}
height={68}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transition-transform duration-300 group-hover:scale-110 active:scale-95"
/>
</div>
<VideoPlayer
isOpen={isPlayerOpen}
onClose={() => setIsPlayerOpen(false)}
videoUrl={config?.intro_video_url}
/>
<div className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]">
<div
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }}
className="pointer-events-auto px-[17px] pt-3"
>
<Button
onClick={handleSubmit}
disabled={isSubmitting}
isLoading={isSubmitting}
>
{t["Submit"]}
</Button>
</div>
</div>
</main>
</div>
<HydrationBoundary state={dehydrate(queryClient)}>
<IntroClient />
</HydrationBoundary>
); );
} }

71
src/app/layout.tsx

@ -236,27 +236,33 @@ export default async function RootLayout({
root.dataset.webBootstrap = 'pending'; root.dataset.webBootstrap = 'pending';
} }
// 4. Deferred web_ready Announcement Bridge
// 4. Queued web_ready Delivery Protocol
// //
// web_ready is NOT sent immediately. Pages call
// window.__announceHabibWebReady() after their critical data
// (profile) is loaded so Flutter removes the cover only when
// the UI is actually ready. A 3-second safety fallback ensures
// the cover is never stuck forever.
function announce() {
if (window.__habibWebReadySent || !window.HabibApp || !window.HabibApp.postMessage) return false;
// Pages call window.__announceHabibWebReady() when their
// destination UI is ready. This only sets a "readyRequested"
// flag and attempts delivery. If HabibApp is not yet injected,
// the request stays pending and is retried when HabibApp
// appears. The 3-second watchdog requests readiness as a
// safety net but does NOT cancel pending delivery attempts.
//
// Contract:
// readyRequested = a destination page says "my UI is ready"
// __habibWebReadySent = postMessage was actually executed
//
var readyRequested = false;
function deliverReadyIfPossible() {
if (window.__habibWebReadySent) return true;
if (!readyRequested) return false;
if (!window.HabibApp || !window.HabibApp.postMessage) return false;
window.__habibWebReadySent = true; window.__habibWebReadySent = true;
if (!configApplied) { if (!configApplied) {
root.dataset.webBootstrap = 'pending'; root.dataset.webBootstrap = 'pending';
} }
window.HabibApp.postMessage(JSON.stringify({ action: 'web_ready' })); window.HabibApp.postMessage(JSON.stringify({ action: 'web_ready' }));
return true;
}
window.__announceHabibWebReady = announce;
function tryAnnounce() {
if (!announce()) return false;
// Safety: release bootstrap-pending if initial_config never arrives
setTimeout(function() { setTimeout(function() {
if (root.dataset.webBootstrap === 'pending') { if (root.dataset.webBootstrap === 'pending') {
root.dataset.webBootstrap = 'ready'; root.dataset.webBootstrap = 'ready';
@ -265,28 +271,33 @@ export default async function RootLayout({
return true; return true;
} }
// Do NOT auto-announce immediately. Pages with SSR-prefetched
// data will call __announceHabibWebReady() once hydrated.
// Safety fallback: auto-announce after 3s if nothing called it.
function requestWebReady() {
readyRequested = true;
deliverReadyIfPossible();
}
window.__announceHabibWebReady = requestWebReady;
// Safety fallback: auto-request readiness after 3s if no page
// called __announceHabibWebReady(). This ensures the cover is
// never stuck forever, even for pages that forgot the call.
var _habibAutoAnnounceTimer = setTimeout(function() { var _habibAutoAnnounceTimer = setTimeout(function() {
tryAnnounce();
requestWebReady();
}, 3000); }, 3000);
// If the page calls announce early, clear the fallback timer.
var _origAnnounce = announce;
window.__announceHabibWebReady = function() {
clearTimeout(_habibAutoAnnounceTimer);
return _origAnnounce();
};
// Also keep polling for HabibApp if it wasn't available at
// parse time (non-WebView or slow bridge injection).
// Poll for HabibApp if it wasn't available at parse time.
// When HabibApp appears, attempt delivery of any pending
// ready request. This closes the race where a page requests
// readiness before the bridge is injected.
if (!window.HabibApp || !window.HabibApp.postMessage) { if (!window.HabibApp || !window.HabibApp.postMessage) {
var attempts = 0; var attempts = 0;
var timer = setInterval(function() {
var bridgePollTimer = setInterval(function() {
attempts += 1; attempts += 1;
if ((window.HabibApp && window.HabibApp.postMessage) || attempts >= 40) {
clearInterval(timer);
if (window.HabibApp && window.HabibApp.postMessage) {
clearInterval(bridgePollTimer);
deliverReadyIfPossible();
} else if (attempts >= 100) {
clearInterval(bridgePollTimer);
} }
}, 50); }, 50);
} }

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

@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { FaLock } from "react-icons/fa6"; import { FaLock } from "react-icons/fa6";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import PageHeader from "@/components/Componentes/page-header"; import PageHeader from "@/components/Componentes/page-header";
@ -280,11 +281,7 @@ export default function NewMatchClient() {
}, [profile, locale, router]); }, [profile, locale, router]);
// Signal Flutter to lift its loading cover once the profile is available. // Signal Flutter to lift its loading cover once the profile is available.
useEffect(() => {
if (profile && !isLoading) {
window.__announceHabibWebReady?.();
}
}, [profile, isLoading]);
useHabibWebReady(!!profile && !isLoading);
const isRedirecting = useMemo(() => { const isRedirecting = useMemo(() => {
if (!profile) return false; if (!profile) return false;

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

@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IoClose } from "react-icons/io5"; import { IoClose } from "react-icons/io5";
import Button from "@/components/Componentes/button"; import Button from "@/components/Componentes/button";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import DataErrorState from "@/components/Componentes/data-error-state"; import DataErrorState from "@/components/Componentes/data-error-state";
import ErrorToast from "@/components/Componentes/error-toast"; import ErrorToast from "@/components/Componentes/error-toast";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
@ -77,11 +78,7 @@ export default function QuestionsListClient() {
// Signal Flutter to lift its loading cover once the profile is available // Signal Flutter to lift its loading cover once the profile is available
// (either from SSR hydration or client-side fetch). // (either from SSR hydration or client-side fetch).
useEffect(() => {
if (profile && !isProfileLoading) {
window.__announceHabibWebReady?.();
}
}, [profile, isProfileLoading]);
useHabibWebReady(!!profile && !isProfileLoading);
const startMatchMutation = useStartMarriageMatchMutation({ const startMatchMutation = useStartMarriageMatchMutation({
onSuccess: () => { onSuccess: () => {

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

@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import CallResultSheet from "@/components/Componentes/call-result-sheet"; import CallResultSheet from "@/components/Componentes/call-result-sheet";
import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet"; import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet";
@ -207,11 +208,7 @@ export default function RequestAcceptedClient() {
}, [profile, router, locale, noContactReportedSuccess]); }, [profile, router, locale, noContactReportedSuccess]);
// Signal Flutter to lift its loading cover once the profile is available. // Signal Flutter to lift its loading cover once the profile is available.
useEffect(() => {
if (profile && !isLoading) {
window.__announceHabibWebReady?.();
}
}, [profile, isLoading]);
useHabibWebReady(!!profile && !isLoading);
const isRedirecting = useMemo(() => { const isRedirecting = useMemo(() => {
if (!profile) return false; if (!profile) return false;

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

@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import PageHeader from "@/components/Componentes/page-header"; import PageHeader from "@/components/Componentes/page-header";
@ -37,11 +38,7 @@ export default function RequestSentClient() {
}, [profile, locale, router]); }, [profile, locale, router]);
// Signal Flutter to lift its loading cover once the profile is available. // Signal Flutter to lift its loading cover once the profile is available.
useEffect(() => {
if (profile && !isLoading) {
window.__announceHabibWebReady?.();
}
}, [profile, isLoading]);
useHabibWebReady(!!profile && !isLoading);
const isRedirecting = useMemo(() => { const isRedirecting = useMemo(() => {
if (!profile) return false; if (!profile) return false;

5
src/app/terms/page.tsx

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

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

@ -19,6 +19,17 @@ type EntryRouteResolverProps = {
anonymousEntryVisible?: boolean; anonymousEntryVisible?: boolean;
}; };
/**
* Client-side fallback entry route resolver.
*
* Used when SSR profile fetch failed and the server couldn't determine the
* correct destination. Resolves the route client-side and navigates.
*
* IMPORTANT: This component must NEVER call __announceHabibWebReady().
* It renders null and navigates to a destination page the destination
* page owns visual readiness. Announcing ready here would expose a blank
* frame to the user before the destination renders.
*/
export default function EntryRouteResolver({ export default function EntryRouteResolver({
anonymousEntryVisible = false, anonymousEntryVisible = false,
}: EntryRouteResolverProps) { }: EntryRouteResolverProps) {
@ -33,14 +44,7 @@ export default function EntryRouteResolver({
useEffect(() => { useEffect(() => {
let isActive = true; let isActive = true;
const announceReady = () => {
if (typeof window !== "undefined") {
window.__announceHabibWebReady?.();
}
};
const goToIntro = () => { const goToIntro = () => {
announceReady();
if (!anonymousEntryVisible) { if (!anonymousEntryVisible) {
router.replace(localizePath("/intro", locale)); router.replace(localizePath("/intro", locale));
} }
@ -56,7 +60,6 @@ export default function EntryRouteResolver({
const cachedEntryPath = getCachedMarriageEntryPath(); const cachedEntryPath = getCachedMarriageEntryPath();
if (cachedEntryPath) { if (cachedEntryPath) {
announceReady();
router.replace(localizePath(cachedEntryPath, locale)); router.replace(localizePath(cachedEntryPath, locale));
return; return;
} }
@ -75,7 +78,6 @@ export default function EntryRouteResolver({
return; return;
} }
announceReady();
router.replace(localizePath(getSubmitPath(profile), locale)); router.replace(localizePath(getSubmitPath(profile), locale));
} catch (error) { } catch (error) {
console.warn("Could not resolve entry route", error); console.warn("Could not resolve entry route", error);

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

@ -69,7 +69,17 @@ export function NavigationButton({
const [isSubscriptionInfoOpen, setIsSubscriptionInfoOpen] = useState(false); const [isSubscriptionInfoOpen, setIsSubscriptionInfoOpen] = useState(false);
const [isSubscriptionLoading, setIsSubscriptionLoading] = useState(false); const [isSubscriptionLoading, setIsSubscriptionLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const { data: profile, refetch } = useMarriageProfileQuery();
// Only fetch profile for icons that actually need profile data.
// Simple icons like back, close, info, document don't need it.
const needsProfile =
icon === "support" ||
icon === "subscription" ||
icon === "more" ||
icon === "consultation";
const { data: profile, refetch } = useMarriageProfileQuery({
enabled: needsProfile,
});
const paymentMutation = useHabcoinPaymentMutation(); const paymentMutation = useHabcoinPaymentMutation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [toastMessage, setToastMessage] = useState<string | null>(null); const [toastMessage, setToastMessage] = useState<string | null>(null);

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

@ -14,22 +14,42 @@ type PageHeaderProps = {
leftButton?: NavigationButtonProps; leftButton?: NavigationButtonProps;
/** Props for the right button. Defaults to icon="support" with support label. */ /** Props for the right button. Defaults to icon="support" with support label. */
rightButton?: NavigationButtonProps; rightButton?: NavigationButtonProps;
/**
* When false, skip the profile query entirely. Useful on pages where the
* right button has an explicit onClick and no icon resolution is needed
* (e.g. Intro's "support" icon that opens a report sheet).
* Defaults to true.
*/
enableProfileQuery?: boolean;
}; };
export function PageHeader({ export function PageHeader({
className, className,
leftButton, leftButton,
rightButton, rightButton,
enableProfileQuery = true,
}: PageHeaderProps) { }: PageHeaderProps) {
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const { data: profile } = useMarriageProfileQuery();
// Only fetch profile when needed for icon resolution (subscription/support
// visibility depends on profile data). Pages that pass an explicit onClick
// for the right button can set enableProfileQuery=false to avoid an
// unnecessary API call (e.g. anonymous Intro).
const iconFromProp = rightButton?.icon; const iconFromProp = rightButton?.icon;
const needsProfile =
enableProfileQuery &&
!rightButton?.onClick &&
(iconFromProp === undefined || iconFromProp === "subscription");
const { data: profile } = useMarriageProfileQuery({
enabled: needsProfile,
});
const isDefaultOrSubscription = const isDefaultOrSubscription =
iconFromProp === undefined || iconFromProp === "subscription"; iconFromProp === undefined || iconFromProp === "subscription";
let finalIcon: NavigationButtonProps["icon"] = iconFromProp || "support"; let finalIcon: NavigationButtonProps["icon"] = iconFromProp || "support";
if (isDefaultOrSubscription) {
if (isDefaultOrSubscription && needsProfile) {
if (hasSupportAccess(profile)) { if (hasSupportAccess(profile)) {
finalIcon = "support"; finalIcon = "support";
} else if (profile?.gender === "male") { } else if (profile?.gender === "male") {
@ -63,3 +83,4 @@ export function PageHeader({
} }
export default PageHeader; export default PageHeader;

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

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

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

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

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

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

51
src/hooks/useFlutterBridge.ts

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

39
src/lib/ssr-fetch.ts

@ -1,3 +1,5 @@
// Server-only module: imported only by Server Components (page.tsx wrappers).
/** /**
* Helper to get the API base URL for server-side fetches. * Helper to get the API base URL for server-side fetches.
* It reads from NEXT_PUBLIC_API_BASE_URL. * It reads from NEXT_PUBLIC_API_BASE_URL.
@ -48,3 +50,40 @@ export async function fetchProfileSSR(
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
} }
/**
* Fetches the marriage config server-side.
* Returns null on failure so the client can fall back to its own fetch or
* local fallback images.
*/
export async function fetchConfigSSR(
timeoutMs = 1500,
): Promise<any | null> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/marriage/config/`;
const response = await fetch(url, {
method: "GET",
cache: "no-store",
signal: controller.signal,
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
return null;
} finally {
clearTimeout(timeoutId);
}
}

2
src/types/window.d.ts

@ -87,7 +87,7 @@ declare global {
sendToFlutter?: (action: string, data?: Record<string, unknown>) => void; sendToFlutter?: (action: string, data?: Record<string, unknown>) => void;
__HABIB_BOOTSTRAP__?: NonNullable<FlutterResponseEvent["data"]>; __HABIB_BOOTSTRAP__?: NonNullable<FlutterResponseEvent["data"]>;
__habibWebReadySent?: boolean; __habibWebReadySent?: boolean;
__announceHabibWebReady?: () => boolean;
__announceHabibWebReady?: () => void;
} }
} }

Loading…
Cancel
Save