From 8302f9ac9c81f9927e20e0894d3e869e6e1bd6b5 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Mon, 17 Aug 2026 23:36:03 +0330 Subject: [PATCH] ssr 3 --- .../candidate-contact-client.tsx | 7 +- .../finding-match/finding-match-client.tsx | 7 +- src/app/intro/intro-client.tsx | 197 ++++++++++++++++ src/app/intro/page.tsx | 222 +++--------------- src/app/layout.tsx | 71 +++--- src/app/new-match/new-match-client.tsx | 7 +- .../questions-list/questions-list-client.tsx | 7 +- .../request-accepted-client.tsx | 7 +- src/app/request-sent/request-sent-client.tsx | 7 +- src/app/terms/page.tsx | 5 + .../Componentes/entry-route-resolver.tsx | 20 +- .../Componentes/navigation-button.tsx | 12 +- src/components/Componentes/page-header.tsx | 25 +- src/hooks/marriage/query-keys.ts | 1 + src/hooks/marriage/use-marriage-config.ts | 3 +- src/hooks/use-habib-web-ready.ts | 25 ++ src/hooks/useFlutterBridge.ts | 51 ++-- src/lib/ssr-fetch.ts | 39 +++ src/types/window.d.ts | 2 +- 19 files changed, 420 insertions(+), 295 deletions(-) create mode 100644 src/app/intro/intro-client.tsx create mode 100644 src/hooks/use-habib-web-ready.ts 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["heavenly +

+ {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' + ] + } +

+
+
+
+ {t["user +
+

120

+

+ {t["user profiles"]} +

+
+
+
+ {t["matches"]} +
+

14

+

+ {t["matches"]} +

+
+
+
+ {t["marriages"]} +
+

14

+

+ {t["marriages"]} +

+
+
+
+
setIsPlayerOpen(true)} + > + +
+ {t["play"]} +
+ + 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["heavenly -

- {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' - ] - } -

-
-
-
- {t["user -
-

120

-

- {t["user profiles"]} -

-
-
-
- {t["matches"]} -
-

14

-

- {t["matches"]} -

-
-
-
- {t["marriages"]} -
-

14

-

- {t["marriages"]} -

-
-
-
-
setIsPlayerOpen(true)} - > - -
- {t["play"]} -
- - 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; } }