diff --git a/src/app/candidate-contact/candidate-contact-client.tsx b/src/app/candidate-contact/candidate-contact-client.tsx
index 96ab1e2..be5c42b 100644
--- a/src/app/candidate-contact/candidate-contact-client.tsx
+++ b/src/app/candidate-contact/candidate-contact-client.tsx
@@ -3,6 +3,7 @@
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
+import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { LoadingThreeDot } from "@/components/Componentes/loading-three-dot";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import CallResultSheet from "@/components/Componentes/call-result-sheet";
@@ -50,11 +51,7 @@ export default function CandidateContactClient() {
}, [profile, router, locale]);
// 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(() => {
if (!profile) return false;
diff --git a/src/app/finding-match/finding-match-client.tsx b/src/app/finding-match/finding-match-client.tsx
index 21ae336..edae5af 100644
--- a/src/app/finding-match/finding-match-client.tsx
+++ b/src/app/finding-match/finding-match-client.tsx
@@ -3,6 +3,7 @@
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
+import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { FaLock, FaPen } from "react-icons/fa6";
import { IoAlertCircle } from "react-icons/io5";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
@@ -44,11 +45,7 @@ export default function FindingMatchClient() {
}, [profile, locale, router]);
// 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(() => {
if (!profile) return false;
diff --git a/src/app/intro/intro-client.tsx b/src/app/intro/intro-client.tsx
new file mode 100644
index 0000000..bcf6d05
--- /dev/null
+++ b/src/app/intro/intro-client.tsx
@@ -0,0 +1,197 @@
+"use client";
+
+import Image from "next/image";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import Button from "@/components/Componentes/button";
+import NetworkImage from "@/components/Componentes/network-image";
+import PageHeader from "@/components/Componentes/page-header";
+import ReportActionsSheet from "@/components/Componentes/report-actions-sheet";
+import VideoPlayer from "@/components/Componentes/video-player";
+import { useMarriageConfigQuery } from "@/hooks/marriage/use-marriage-config";
+import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
+import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
+import { authBridge } from "@/lib/auth-bridge";
+import { getSubmitPath } from "@/lib/get-submit-path";
+import { localizePath } from "@/translations/config";
+import { useI18n } from "@/translations/provider";
+
+export default function IntroClient() {
+ const router = useRouter();
+ const { dictionary: t, locale } = useI18n();
+ const { data: profile, refetch } = useMarriageProfileQuery({
+ enabled: false,
+ retry: false,
+ });
+ const [isReportSheetOpen, setIsReportSheetOpen] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isPlayerOpen, setIsPlayerOpen] = useState(false);
+
+ const { data: config } = useMarriageConfigQuery();
+
+ // Signal Flutter that the Intro UI is ready. Config has a local fallback
+ // image so we don't need to wait for it — announce immediately on mount.
+ useHabibWebReady(true);
+
+ const handleSubmit = async () => {
+ if (isSubmitting) {
+ return;
+ }
+
+ setIsSubmitting(true);
+
+ try {
+ if (!authBridge.isAuthenticated()) {
+ const token = await authBridge.ensureToken();
+ if (!token) {
+ console.warn("No token from bridge – login was not completed");
+ return;
+ }
+ }
+
+ let profileResponse = profile;
+ try {
+ const { data: freshProfile } = await refetch();
+ profileResponse = freshProfile ?? profile;
+ } catch (refetchError) {
+ console.warn(
+ "Could not refetch profile data – using fallback",
+ refetchError,
+ );
+ }
+
+ const submitPath = getSubmitPath(profileResponse);
+ const nextPath = localizePath(
+ submitPath === "/intro" ? "/terms" : submitPath,
+ locale,
+ );
+ router.push(nextPath);
+ } catch (error) {
+ console.error("Submission/redirect failed", error);
+ router.push(localizePath("/terms", locale));
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+ {isReportSheetOpen && (
+
setIsReportSheetOpen(false)} />
+ )}
+ setIsReportSheetOpen(true),
+ }}
+ />
+
+
+
+
+ {t["A Path to Heavenly Marriage"]}
+
+
+ {
+ t[
+ 'We have come together with the goal of creating a secure and confidential path for "permanent marriage" among Muslims'
+ ]
+ }
+
+
+
+
+
+
+
120
+
+ {t["user profiles"]}
+
+
+
+
+
+
+
14
+
+ {t["matches"]}
+
+
+
+
+
+
+
14
+
+ {t["marriages"]}
+
+
+
+
+ setIsPlayerOpen(true)}
+ >
+
+
+
+
+
+ setIsPlayerOpen(false)}
+ videoUrl={config?.intro_video_url}
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/intro/page.tsx b/src/app/intro/page.tsx
index 87a8d1f..89223f9 100644
--- a/src/app/intro/page.tsx
+++ b/src/app/intro/page.tsx
@@ -1,192 +1,42 @@
-"use client";
-
-import Image from "next/image";
-import { useRouter } from "next/navigation";
-import { useState } from "react";
-import Button from "@/components/Componentes/button";
-import NetworkImage from "@/components/Componentes/network-image";
-import PageHeader from "@/components/Componentes/page-header";
-import ReportActionsSheet from "@/components/Componentes/report-actions-sheet";
-import VideoPlayer from "@/components/Componentes/video-player";
-import { useMarriageConfigQuery } from "@/hooks/marriage/use-marriage-config";
-import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
-import { authBridge } from "@/lib/auth-bridge";
-import { getSubmitPath } from "@/lib/get-submit-path";
-import { localizePath } from "@/translations/config";
-import { useI18n } from "@/translations/provider";
-
-export default function Intro() {
- const router = useRouter();
- const { dictionary: t, locale } = useI18n();
- const { data: profile, refetch } = useMarriageProfileQuery({
- enabled: false,
- retry: false,
+import {
+ dehydrate,
+ HydrationBoundary,
+ QueryClient,
+} from "@tanstack/react-query";
+import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
+import { fetchConfigSSR } from "@/lib/ssr-fetch";
+import IntroClient from "./intro-client";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * Server Component wrapper for the Intro page.
+ *
+ * Follows the same SSR-prefetch pattern as questions-list/page.tsx:
+ * create a server QueryClient, prefetch critical data, dehydrate, and
+ * wrap the client component in HydrationBoundary so TanStack Query
+ * hydrates the cache instantly — no client-side waterfall.
+ *
+ * Config is the only data Intro needs. Even if the fetch fails, IntroClient
+ * has a local fallback image so the UI is never broken.
+ */
+export default async function IntroPage() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { staleTime: 30 * 1000 },
+ },
});
- const [isReportSheetOpen, setIsReportSheetOpen] = useState(false);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [isPlayerOpen, setIsPlayerOpen] = useState(false);
-
- const { data: config } = useMarriageConfigQuery();
-
- const handleSubmit = async () => {
- if (isSubmitting) {
- return;
- }
-
- setIsSubmitting(true);
- try {
- if (!authBridge.isAuthenticated()) {
- const token = await authBridge.ensureToken();
- if (!token) {
- console.warn("No token from bridge – login was not completed");
- return;
- }
- }
-
- let profileResponse = profile;
- try {
- const { data: freshProfile } = await refetch();
- profileResponse = freshProfile ?? profile;
- } catch (refetchError) {
- console.warn(
- "Could not refetch profile data – using fallback",
- refetchError,
- );
- }
-
- const submitPath = getSubmitPath(profileResponse);
- const nextPath = localizePath(
- submitPath === "/intro" ? "/terms" : submitPath,
- locale,
- );
- router.push(nextPath);
- } catch (error) {
- console.error("Submission/redirect failed", error);
- router.push(localizePath("/terms", locale));
- } finally {
- setIsSubmitting(false);
- }
- };
+ // Prefetch marriage config — Intro uses it for the video thumbnail.
+ // Failure is non-blocking because IntroClient has a fallbackSrc.
+ await queryClient.prefetchQuery({
+ queryKey: marriageQueryKeys.config(),
+ queryFn: () => fetchConfigSSR(),
+ });
return (
-
- {isReportSheetOpen && (
-
setIsReportSheetOpen(false)} />
- )}
- setIsReportSheetOpen(true),
- }}
- />
-
-
-
-
- {t["A Path to Heavenly Marriage"]}
-
-
- {
- t[
- 'We have come together with the goal of creating a secure and confidential path for "permanent marriage" among Muslims'
- ]
- }
-
-
-
-
-
-
-
120
-
- {t["user profiles"]}
-
-
-
-
-
-
-
14
-
- {t["matches"]}
-
-
-
-
-
-
-
14
-
- {t["marriages"]}
-
-
-
-
- setIsPlayerOpen(true)}
- >
-
-
-
-
-
- setIsPlayerOpen(false)}
- videoUrl={config?.intro_video_url}
- />
-
-
-
-
-
-
-
+
+
+
);
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 2e00657..a9de196 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -236,27 +236,33 @@ export default async function RootLayout({
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;
if (!configApplied) {
root.dataset.webBootstrap = 'pending';
}
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() {
if (root.dataset.webBootstrap === 'pending') {
root.dataset.webBootstrap = 'ready';
@@ -265,28 +271,33 @@ export default async function RootLayout({
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() {
- tryAnnounce();
+ requestWebReady();
}, 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) {
var attempts = 0;
- var timer = setInterval(function() {
+ var bridgePollTimer = setInterval(function() {
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);
}
diff --git a/src/app/new-match/new-match-client.tsx b/src/app/new-match/new-match-client.tsx
index 7a5c142..37064f8 100644
--- a/src/app/new-match/new-match-client.tsx
+++ b/src/app/new-match/new-match-client.tsx
@@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
+import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { FaLock } from "react-icons/fa6";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import PageHeader from "@/components/Componentes/page-header";
@@ -280,11 +281,7 @@ export default function NewMatchClient() {
}, [profile, locale, router]);
// 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(() => {
if (!profile) return false;
diff --git a/src/app/questions-list/questions-list-client.tsx b/src/app/questions-list/questions-list-client.tsx
index 6967f59..39138ba 100644
--- a/src/app/questions-list/questions-list-client.tsx
+++ b/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 { IoClose } from "react-icons/io5";
import Button from "@/components/Componentes/button";
+import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import DataErrorState from "@/components/Componentes/data-error-state";
import ErrorToast from "@/components/Componentes/error-toast";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
@@ -77,11 +78,7 @@ export default function QuestionsListClient() {
// Signal Flutter to lift its loading cover once the profile is available
// (either from SSR hydration or client-side fetch).
- useEffect(() => {
- if (profile && !isProfileLoading) {
- window.__announceHabibWebReady?.();
- }
- }, [profile, isProfileLoading]);
+ useHabibWebReady(!!profile && !isProfileLoading);
const startMatchMutation = useStartMarriageMatchMutation({
onSuccess: () => {
diff --git a/src/app/request-accepted/request-accepted-client.tsx b/src/app/request-accepted/request-accepted-client.tsx
index 801aa03..f9ca364 100644
--- a/src/app/request-accepted/request-accepted-client.tsx
+++ b/src/app/request-accepted/request-accepted-client.tsx
@@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
+import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import CallResultSheet from "@/components/Componentes/call-result-sheet";
import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet";
@@ -207,11 +208,7 @@ export default function RequestAcceptedClient() {
}, [profile, router, locale, noContactReportedSuccess]);
// 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(() => {
if (!profile) return false;
diff --git a/src/app/request-sent/request-sent-client.tsx b/src/app/request-sent/request-sent-client.tsx
index d72fed1..d368841 100644
--- a/src/app/request-sent/request-sent-client.tsx
+++ b/src/app/request-sent/request-sent-client.tsx
@@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
+import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card";
import PageHeader from "@/components/Componentes/page-header";
@@ -37,11 +38,7 @@ export default function RequestSentClient() {
}, [profile, locale, router]);
// 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(() => {
if (!profile) return false;
diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx
index c6fee7d..0f096ce 100644
--- a/src/app/terms/page.tsx
+++ b/src/app/terms/page.tsx
@@ -7,6 +7,7 @@ import {
getSubmitPath,
hasCompletedMarriageProfileBasics,
} from "@/lib/get-submit-path";
+import { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import SliderPage from "@/components/Componentes/slider-page";
import Button from "@/components/Componentes/button";
import { useI18n } from "@/translations/provider";
@@ -17,6 +18,10 @@ export default function TermsRoute() {
const router = useRouter();
const { locale, dictionary: t } = useI18n();
+ // Signal Flutter when profile data is available (or failed — the page
+ // has its own loading/error UI so the cover can safely be removed).
+ useHabibWebReady(!isLoading);
+
useEffect(() => {
if (!isLoading && profile) {
if (
diff --git a/src/components/Componentes/entry-route-resolver.tsx b/src/components/Componentes/entry-route-resolver.tsx
index bda5667..acd97f1 100644
--- a/src/components/Componentes/entry-route-resolver.tsx
+++ b/src/components/Componentes/entry-route-resolver.tsx
@@ -19,6 +19,17 @@ type EntryRouteResolverProps = {
anonymousEntryVisible?: boolean;
};
+/**
+ * Client-side fallback entry route resolver.
+ *
+ * Used when SSR profile fetch failed and the server couldn't determine the
+ * correct destination. Resolves the route client-side and navigates.
+ *
+ * IMPORTANT: This component must NEVER call __announceHabibWebReady().
+ * It renders null and navigates to a destination page — the destination
+ * page owns visual readiness. Announcing ready here would expose a blank
+ * frame to the user before the destination renders.
+ */
export default function EntryRouteResolver({
anonymousEntryVisible = false,
}: EntryRouteResolverProps) {
@@ -33,14 +44,7 @@ export default function EntryRouteResolver({
useEffect(() => {
let isActive = true;
- const announceReady = () => {
- if (typeof window !== "undefined") {
- window.__announceHabibWebReady?.();
- }
- };
-
const goToIntro = () => {
- announceReady();
if (!anonymousEntryVisible) {
router.replace(localizePath("/intro", locale));
}
@@ -56,7 +60,6 @@ export default function EntryRouteResolver({
const cachedEntryPath = getCachedMarriageEntryPath();
if (cachedEntryPath) {
- announceReady();
router.replace(localizePath(cachedEntryPath, locale));
return;
}
@@ -75,7 +78,6 @@ export default function EntryRouteResolver({
return;
}
- announceReady();
router.replace(localizePath(getSubmitPath(profile), locale));
} catch (error) {
console.warn("Could not resolve entry route", error);
diff --git a/src/components/Componentes/navigation-button.tsx b/src/components/Componentes/navigation-button.tsx
index 21065a8..bd11d0a 100644
--- a/src/components/Componentes/navigation-button.tsx
+++ b/src/components/Componentes/navigation-button.tsx
@@ -69,7 +69,17 @@ export function NavigationButton({
const [isSubscriptionInfoOpen, setIsSubscriptionInfoOpen] = useState(false);
const [isSubscriptionLoading, setIsSubscriptionLoading] = useState(false);
const dropdownRef = useRef(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 queryClient = useQueryClient();
const [toastMessage, setToastMessage] = useState(null);
diff --git a/src/components/Componentes/page-header.tsx b/src/components/Componentes/page-header.tsx
index 0585a59..6a6c4ce 100644
--- a/src/components/Componentes/page-header.tsx
+++ b/src/components/Componentes/page-header.tsx
@@ -14,22 +14,42 @@ type PageHeaderProps = {
leftButton?: NavigationButtonProps;
/** Props for the right button. Defaults to icon="support" with support label. */
rightButton?: NavigationButtonProps;
+ /**
+ * When false, skip the profile query entirely. Useful on pages where the
+ * right button has an explicit onClick and no icon resolution is needed
+ * (e.g. Intro's "support" icon that opens a report sheet).
+ * Defaults to true.
+ */
+ enableProfileQuery?: boolean;
};
export function PageHeader({
className,
leftButton,
rightButton,
+ enableProfileQuery = true,
}: PageHeaderProps) {
const { dictionary: t } = useI18n();
- const { data: profile } = useMarriageProfileQuery();
+ // Only fetch profile when needed for icon resolution (subscription/support
+ // visibility depends on profile data). Pages that pass an explicit onClick
+ // for the right button can set enableProfileQuery=false to avoid an
+ // unnecessary API call (e.g. anonymous Intro).
const iconFromProp = rightButton?.icon;
+ const needsProfile =
+ enableProfileQuery &&
+ !rightButton?.onClick &&
+ (iconFromProp === undefined || iconFromProp === "subscription");
+
+ const { data: profile } = useMarriageProfileQuery({
+ enabled: needsProfile,
+ });
+
const isDefaultOrSubscription =
iconFromProp === undefined || iconFromProp === "subscription";
let finalIcon: NavigationButtonProps["icon"] = iconFromProp || "support";
- if (isDefaultOrSubscription) {
+ if (isDefaultOrSubscription && needsProfile) {
if (hasSupportAccess(profile)) {
finalIcon = "support";
} else if (profile?.gender === "male") {
@@ -63,3 +83,4 @@ export function PageHeader({
}
export default PageHeader;
+
diff --git a/src/hooks/marriage/query-keys.ts b/src/hooks/marriage/query-keys.ts
index 144e1e0..4e7ea7c 100644
--- a/src/hooks/marriage/query-keys.ts
+++ b/src/hooks/marriage/query-keys.ts
@@ -2,6 +2,7 @@ import type { CaseId } from "./types";
export const marriageQueryKeys = {
all: ["marriage"] as const,
+ config: () => [...marriageQueryKeys.all, "config"] as const,
contactInfo: (caseId: CaseId | "") =>
[
...marriageQueryKeys.all,
diff --git a/src/hooks/marriage/use-marriage-config.ts b/src/hooks/marriage/use-marriage-config.ts
index 4d03281..0e6c188 100644
--- a/src/hooks/marriage/use-marriage-config.ts
+++ b/src/hooks/marriage/use-marriage-config.ts
@@ -2,6 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { http } from "@/lib/http";
+import { marriageQueryKeys } from "./query-keys";
export type MarriageConfig = {
intro_video_url: string;
@@ -16,7 +17,7 @@ export async function getMarriageConfig() {
export function useMarriageConfigQuery() {
return useQuery({
- queryKey: ["marriage", "config"],
+ queryKey: marriageQueryKeys.config(),
queryFn: getMarriageConfig,
});
}
diff --git a/src/hooks/use-habib-web-ready.ts b/src/hooks/use-habib-web-ready.ts
new file mode 100644
index 0000000..21f6ebf
--- /dev/null
+++ b/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]);
+}
diff --git a/src/hooks/useFlutterBridge.ts b/src/hooks/useFlutterBridge.ts
index bf613bd..a21480e 100644
--- a/src/hooks/useFlutterBridge.ts
+++ b/src/hooks/useFlutterBridge.ts
@@ -219,53 +219,34 @@ export function useFlutterBridge(
};
}, [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(() => {
if (typeof window === "undefined") return;
- let interval: ReturnType | undefined;
- let timeout: ReturnType | undefined;
-
- const markReadyAndAnnounce = () => {
- if (!window.HabibApp?.postMessage) return false;
-
+ if (window.HabibApp?.postMessage) {
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 | undefined;
+ let timeout: ReturnType | 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);
- }, 5000);
- }
+ }
+ }, 100);
+ timeout = setTimeout(() => {
+ if (interval) clearInterval(interval);
+ }, 5000);
return () => {
if (interval) clearInterval(interval);
if (timeout) clearTimeout(timeout);
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// گوشدادن به پاسخهای واقعی Flutter از مسیر window.onFlutterResponse.
diff --git a/src/lib/ssr-fetch.ts b/src/lib/ssr-fetch.ts
index 473d7e9..8fe6da3 100644
--- a/src/lib/ssr-fetch.ts
+++ b/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.
* It reads from NEXT_PUBLIC_API_BASE_URL.
@@ -48,3 +50,40 @@ export async function fetchProfileSSR(
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 {
+ 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);
+ }
+}
+
diff --git a/src/types/window.d.ts b/src/types/window.d.ts
index 20fdafa..631a738 100644
--- a/src/types/window.d.ts
+++ b/src/types/window.d.ts
@@ -87,7 +87,7 @@ declare global {
sendToFlutter?: (action: string, data?: Record) => void;
__HABIB_BOOTSTRAP__?: NonNullable;
__habibWebReadySent?: boolean;
- __announceHabibWebReady?: () => boolean;
+ __announceHabibWebReady?: () => void;
}
}