diff --git a/src/app/candidate-contact/candidate-contact-client.tsx b/src/app/candidate-contact/candidate-contact-client.tsx index be5c42b..7a0cf21 100644 --- a/src/app/candidate-contact/candidate-contact-client.tsx +++ b/src/app/candidate-contact/candidate-contact-client.tsx @@ -11,6 +11,9 @@ import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet"; import OutcomeSelectionSheet from "@/components/Componentes/outcome-selection-sheet"; import PageHeader from "@/components/Componentes/page-header"; import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; +import MarriageAdvisorsOverlay, { + useMarriageAdvisorsOverlay, +} from "@/components/Componentes/marriage-advisors-overlay"; import { PageBackground } from "@/components/Componentes/page-background"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { @@ -34,6 +37,8 @@ export default function CandidateContactClient() { const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false); const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] = useState(false); + const { isAdvisorOpen, openAdvisors, closeAdvisors } = + useMarriageAdvisorsOverlay(); const { data: profile, isLoading: isProfileLoading } = useMarriageProfileQuery({ @@ -273,7 +278,7 @@ export default function CandidateContactClient() { avatars={advisorAvatars} extraCount={7} getAdvisorLabel={t["Get Advisor"]} - getAdvisorHref="/marriage-advisors" + onGetAdvisor={openAdvisors} /> {/* Profile Locked banner */} @@ -298,6 +303,11 @@ export default function CandidateContactClient() { + + ); } diff --git a/src/app/finding-match/finding-match-client.tsx b/src/app/finding-match/finding-match-client.tsx index edae5af..feeae48 100644 --- a/src/app/finding-match/finding-match-client.tsx +++ b/src/app/finding-match/finding-match-client.tsx @@ -7,6 +7,9 @@ import { useHabibWebReady } from "@/hooks/use-habib-web-ready"; import { FaLock, FaPen } from "react-icons/fa6"; import { IoAlertCircle } from "react-icons/io5"; import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; +import MarriageAdvisorsOverlay, { + useMarriageAdvisorsOverlay, +} from "@/components/Componentes/marriage-advisors-overlay"; import Button from "@/components/Componentes/button"; import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; @@ -27,6 +30,8 @@ const advisorAvatars = [ export default function FindingMatchClient() { const router = useRouter(); const { dictionary: t, locale } = useI18n(); + const { isAdvisorOpen, openAdvisors, closeAdvisors } = + useMarriageAdvisorsOverlay(); const { data: profile, isLoading } = useMarriageProfileQuery({ refetchInterval: 3000, }); @@ -157,7 +162,7 @@ export default function FindingMatchClient() { avatars={advisorAvatars} extraCount={7} getAdvisorLabel={copy.getAdvisor} - getAdvisorHref="/marriage-advisors" + onGetAdvisor={openAdvisors} /> @@ -179,6 +184,11 @@ export default function FindingMatchClient() { )} + + ); } diff --git a/src/app/marriage-advisors/page.tsx b/src/app/marriage-advisors/page.tsx index 3ae339a..da57734 100644 --- a/src/app/marriage-advisors/page.tsx +++ b/src/app/marriage-advisors/page.tsx @@ -17,7 +17,11 @@ import { postActionToFlutter } from "@/lib/webview-actions"; const FALLBACK_AVATAR = "/assets/images/Avatar Image.png"; -export default function MarriageAdvisorsPage() { +type MarriageAdvisorsPageProps = { + onClose?: () => void; +}; + +export default function MarriageAdvisorsPage({ onClose }: MarriageAdvisorsPageProps = {}) { const { dictionary: t } = useI18n(); const [isSupportOpen, setIsSupportOpen] = useState(false); @@ -51,6 +55,7 @@ export default function MarriageAdvisorsPage() { variant="transparent" icon="back" iconLabel={t["Go back"]} + onClick={onClose} />

@@ -667,6 +672,11 @@ export default function NewMatchClient() { )} + + ); } diff --git a/src/app/request-accepted/request-accepted-client.tsx b/src/app/request-accepted/request-accepted-client.tsx index f9ca364..4404f5a 100644 --- a/src/app/request-accepted/request-accepted-client.tsx +++ b/src/app/request-accepted/request-accepted-client.tsx @@ -6,6 +6,9 @@ import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import { useHabibWebReady } from "@/hooks/use-habib-web-ready"; import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; +import MarriageAdvisorsOverlay, { + useMarriageAdvisorsOverlay, +} from "@/components/Componentes/marriage-advisors-overlay"; import CallResultSheet from "@/components/Componentes/call-result-sheet"; import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet"; import FemaleConsentSheet from "@/components/Componentes/female-consent-sheet"; @@ -174,6 +177,8 @@ function ContactInfoPhoneCard({ item }: { item: ContactInfoPhoneItem }) { export default function RequestAcceptedClient() { const { dictionary: t, locale } = useI18n(); const router = useRouter(); + const { isAdvisorOpen, openAdvisors, closeAdvisors } = + useMarriageAdvisorsOverlay(); const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false); const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] = useState(false); @@ -727,7 +732,7 @@ export default function RequestAcceptedClient() { avatars={advisorAvatars} extraCount={7} getAdvisorLabel={t["Get Advisor"]} - getAdvisorHref="/marriage-advisors" + onGetAdvisor={openAdvisors} />
@@ -752,6 +757,11 @@ export default function RequestAcceptedClient() { + + ); } diff --git a/src/app/request-sent/request-sent-client.tsx b/src/app/request-sent/request-sent-client.tsx index d368841..9b0a06e 100644 --- a/src/app/request-sent/request-sent-client.tsx +++ b/src/app/request-sent/request-sent-client.tsx @@ -7,6 +7,9 @@ import { useEffect, useMemo } from "react"; import { useHabibWebReady } from "@/hooks/use-habib-web-ready"; import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; +import MarriageAdvisorsOverlay, { + useMarriageAdvisorsOverlay, +} from "@/components/Componentes/marriage-advisors-overlay"; import PageHeader from "@/components/Componentes/page-header"; import { PageBackground } from "@/components/Componentes/page-background"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; @@ -23,6 +26,8 @@ const advisorAvatars = [ export default function RequestSentClient() { const router = useRouter(); const { dictionary: t, locale } = useI18n(); + const { isAdvisorOpen, openAdvisors, closeAdvisors } = + useMarriageAdvisorsOverlay(); const { data: profile, isLoading } = useMarriageProfileQuery({ refetchInterval: 3000, }); @@ -115,7 +120,7 @@ export default function RequestSentClient() { avatars={advisorAvatars} extraCount={7} getAdvisorLabel={copy.getAdvisor} - getAdvisorHref="/marriage-advisors" + onGetAdvisor={openAdvisors} />
@@ -139,6 +144,11 @@ export default function RequestSentClient() { + + ); } diff --git a/src/components/Componentes/advisor-actions-card.tsx b/src/components/Componentes/advisor-actions-card.tsx index 7cb7585..f1722a6 100644 --- a/src/components/Componentes/advisor-actions-card.tsx +++ b/src/components/Componentes/advisor-actions-card.tsx @@ -18,7 +18,8 @@ type AdvisorActionsCardProps = { avatars: AdvisorAvatar[]; extraCount: number; getAdvisorLabel: string; - getAdvisorHref: string; + getAdvisorHref?: string; + onGetAdvisor?: () => void; className?: string; }; @@ -29,6 +30,7 @@ export function AdvisorActionsCard({ extraCount: fallbackExtraCount, getAdvisorLabel, getAdvisorHref, + onGetAdvisor, className, }: AdvisorActionsCardProps) { const { data, isLoading } = useMarriageAdvisorsQuery(); @@ -96,7 +98,8 @@ export function AdvisorActionsCard({ diff --git a/src/components/Componentes/marriage-advisors-overlay.test.tsx b/src/components/Componentes/marriage-advisors-overlay.test.tsx new file mode 100644 index 0000000..4fa1659 --- /dev/null +++ b/src/components/Componentes/marriage-advisors-overlay.test.tsx @@ -0,0 +1,134 @@ +import { act, cleanup, render, renderHook, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { I18nProvider } from "@/translations/provider"; +import { + MarriageAdvisorsOverlay, + useMarriageAdvisorsOverlay, +} from "./marriage-advisors-overlay"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + back: vi.fn(), + }), +})); + +vi.mock("@/hooks/marriage/use-marriage-advisors", () => ({ + useMarriageAdvisorsQuery: () => ({ + data: { results: [] }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), +})); + +vi.mock("@/hooks/marriage/use-profile-main", () => ({ + useMarriageProfileQuery: () => ({ + data: null, + isLoading: false, + refetch: vi.fn(), + }), +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + }; +} + +describe("MarriageAdvisorsOverlay & useMarriageAdvisorsOverlay", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + return setTimeout(() => cb(Date.now()), 0); + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => { + clearTimeout(id); + }); + document.body.className = ""; + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.useRealTimers(); + document.body.className = ""; + }); + + it("hook manages open and close state with history sync", () => { + const { result } = renderHook(() => useMarriageAdvisorsOverlay()); + + expect(result.current.isAdvisorOpen).toBe(false); + + act(() => { + result.current.openAdvisors(); + }); + + expect(result.current.isAdvisorOpen).toBe(true); + + act(() => { + result.current.closeAdvisors(); + }); + + expect(result.current.isAdvisorOpen).toBe(false); + }); + + it("renders overlay dialog and advisors page when open is true", () => { + const handleClose = vi.fn(); + const wrapper = createWrapper(); + + render( + , + { wrapper }, + ); + + act(() => { + vi.advanceTimersByTime(16); + }); + + const overlay = screen.getByRole("dialog"); + expect(overlay).toBeInTheDocument(); + expect(overlay).toHaveClass("section-overlay"); + expect(document.body.classList.contains("section-overlay-open")).toBe(true); + }); + + it("handles closing animation and removes body class when open becomes false", () => { + const handleClose = vi.fn(); + const wrapper = createWrapper(); + + const { rerender } = render( + , + { wrapper }, + ); + + act(() => { + vi.advanceTimersByTime(16); + }); + + expect(screen.getByRole("dialog")).toHaveAttribute("data-state", "open"); + expect(document.body.classList.contains("section-overlay-open")).toBe(true); + + rerender(); + + expect(screen.getByRole("dialog")).toHaveAttribute("data-state", "closing"); + expect(document.body.classList.contains("section-overlay-open")).toBe(false); + + act(() => { + vi.advanceTimersByTime(210); + }); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Componentes/marriage-advisors-overlay.tsx b/src/components/Componentes/marriage-advisors-overlay.tsx new file mode 100644 index 0000000..beb93e5 --- /dev/null +++ b/src/components/Componentes/marriage-advisors-overlay.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import MarriageAdvisorsPage from "@/app/marriage-advisors/page"; +import SectionOverlayHost from "@/components/Componentes/section-overlay-host"; +import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; + +export type MarriageAdvisorsOverlayProps = { + open: boolean; + onClose: () => void; +}; + +/** + * Hook to manage Marriage Advisors slide-in overlay state. + * Syncs with browser history (?advisors=open) and intercepts hardware back in Flutter. + */ +export function useMarriageAdvisorsOverlay() { + const [isAdvisorOpen, setIsAdvisorOpen] = useState(false); + + useEffect(() => { + const readAdvisorsFromUrl = () => { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + setIsAdvisorOpen(params.get("advisors") === "open"); + }; + + readAdvisorsFromUrl(); + window.addEventListener("popstate", readAdvisorsFromUrl); + return () => window.removeEventListener("popstate", readAdvisorsFromUrl); + }, []); + + const openAdvisors = useCallback(() => { + setIsAdvisorOpen(true); + if (typeof window !== "undefined") { + const url = new URL(window.location.href); + url.searchParams.set("advisors", "open"); + window.history.pushState({ advisors: "open" }, "", url.toString()); + } + }, []); + + const closeAdvisors = useCallback(() => { + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + if (params.get("advisors") === "open") { + setIsAdvisorOpen(false); + window.history.back(); + return; + } + } + setIsAdvisorOpen(false); + }, []); + + // Intercept hardware back in Flutter WebView when advisor overlay is open + useHardwareBackHandler(() => { + closeAdvisors(); + return true; // handled: keep WebView screen open + }, isAdvisorOpen); + + return { + isAdvisorOpen, + openAdvisors, + closeAdvisors, + }; +} + +export function MarriageAdvisorsOverlay({ + open, + onClose, +}: MarriageAdvisorsOverlayProps) { + return ( + + + + ); +} + +export default MarriageAdvisorsOverlay;