Browse Source

feat: implement MarriageAdvisorsOverlay with history synchronization and integrate into client pages

master
mortezaei 6 days ago
parent
commit
4d6ca589ae
  1. 12
      src/app/candidate-contact/candidate-contact-client.tsx
  2. 12
      src/app/finding-match/finding-match-client.tsx
  3. 7
      src/app/marriage-advisors/page.tsx
  4. 12
      src/app/new-match/new-match-client.tsx
  5. 12
      src/app/request-accepted/request-accepted-client.tsx
  6. 12
      src/app/request-sent/request-sent-client.tsx
  7. 7
      src/components/Componentes/advisor-actions-card.tsx
  8. 134
      src/components/Componentes/marriage-advisors-overlay.test.tsx
  9. 77
      src/components/Componentes/marriage-advisors-overlay.tsx

12
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 OutcomeSelectionSheet from "@/components/Componentes/outcome-selection-sheet";
import PageHeader from "@/components/Componentes/page-header"; import PageHeader from "@/components/Componentes/page-header";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; 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 { PageBackground } from "@/components/Componentes/page-background";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { import {
@ -34,6 +37,8 @@ export default function CandidateContactClient() {
const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false); const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false);
const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] = const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] =
useState(false); useState(false);
const { isAdvisorOpen, openAdvisors, closeAdvisors } =
useMarriageAdvisorsOverlay();
const { data: profile, isLoading: isProfileLoading } = const { data: profile, isLoading: isProfileLoading } =
useMarriageProfileQuery({ useMarriageProfileQuery({
@ -273,7 +278,7 @@ export default function CandidateContactClient() {
avatars={advisorAvatars} avatars={advisorAvatars}
extraCount={7} extraCount={7}
getAdvisorLabel={t["Get Advisor"]} getAdvisorLabel={t["Get Advisor"]}
getAdvisorHref="/marriage-advisors"
onGetAdvisor={openAdvisors}
/> />
{/* Profile Locked banner */} {/* Profile Locked banner */}
@ -298,6 +303,11 @@ export default function CandidateContactClient() {
</div> </div>
</div> </div>
</main> </main>
<MarriageAdvisorsOverlay
open={isAdvisorOpen}
onClose={closeAdvisors}
/>
</> </>
); );
} }

12
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 { 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";
import MarriageAdvisorsOverlay, {
useMarriageAdvisorsOverlay,
} from "@/components/Componentes/marriage-advisors-overlay";
import Button from "@/components/Componentes/button"; import Button from "@/components/Componentes/button";
import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton"; import { PageLoadingSkeleton } from "@/components/Componentes/page-loading-skeleton";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
@ -27,6 +30,8 @@ const advisorAvatars = [
export default function FindingMatchClient() { export default function FindingMatchClient() {
const router = useRouter(); const router = useRouter();
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const { isAdvisorOpen, openAdvisors, closeAdvisors } =
useMarriageAdvisorsOverlay();
const { data: profile, isLoading } = useMarriageProfileQuery({ const { data: profile, isLoading } = useMarriageProfileQuery({
refetchInterval: 3000, refetchInterval: 3000,
}); });
@ -157,7 +162,7 @@ export default function FindingMatchClient() {
avatars={advisorAvatars} avatars={advisorAvatars}
extraCount={7} extraCount={7}
getAdvisorLabel={copy.getAdvisor} getAdvisorLabel={copy.getAdvisor}
getAdvisorHref="/marriage-advisors"
onGetAdvisor={openAdvisors}
/> />
<FixToTheEnd> <FixToTheEnd>
@ -179,6 +184,11 @@ export default function FindingMatchClient() {
)} )}
</FixToTheEnd> </FixToTheEnd>
</main> </main>
<MarriageAdvisorsOverlay
open={isAdvisorOpen}
onClose={closeAdvisors}
/>
</> </>
); );
} }

7
src/app/marriage-advisors/page.tsx

@ -17,7 +17,11 @@ import { postActionToFlutter } from "@/lib/webview-actions";
const FALLBACK_AVATAR = "/assets/images/Avatar Image.png"; 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 { dictionary: t } = useI18n();
const [isSupportOpen, setIsSupportOpen] = useState(false); const [isSupportOpen, setIsSupportOpen] = useState(false);
@ -51,6 +55,7 @@ export default function MarriageAdvisorsPage() {
variant="transparent" variant="transparent"
icon="back" icon="back"
iconLabel={t["Go back"]} iconLabel={t["Go back"]}
onClick={onClose}
/> />
<h1 <h1
className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white" className="min-w-0 flex-1 text-center font-semibold text-[14px] leading-[16px] text-white"

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

@ -7,6 +7,9 @@ import { useEffect, useMemo, useState } from "react";
import { useHabibWebReady } from "@/hooks/use-habib-web-ready"; 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 MarriageAdvisorsOverlay, {
useMarriageAdvisorsOverlay,
} from "@/components/Componentes/marriage-advisors-overlay";
import PageHeader from "@/components/Componentes/page-header"; import PageHeader from "@/components/Componentes/page-header";
import { PageBackground } from "@/components/Componentes/page-background"; import { PageBackground } from "@/components/Componentes/page-background";
import { IoClose } from "react-icons/io5"; import { IoClose } from "react-icons/io5";
@ -220,6 +223,8 @@ export default function NewMatchClient() {
const router = useRouter(); const router = useRouter();
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const { top, bottom } = useViewPaddings(); const { top, bottom } = useViewPaddings();
const { isAdvisorOpen, openAdvisors, closeAdvisors } =
useMarriageAdvisorsOverlay();
const { data: profile, isError, isLoading } = useMarriageProfileQuery(); const { data: profile, isError, isLoading } = useMarriageProfileQuery();
const [isPaymentSheetOpen, setIsPaymentSheetOpen] = useState(false); const [isPaymentSheetOpen, setIsPaymentSheetOpen] = useState(false);
@ -502,7 +507,7 @@ export default function NewMatchClient() {
avatars={advisorAvatars} avatars={advisorAvatars}
extraCount={7} extraCount={7}
getAdvisorLabel={t["Get Advisor"]} getAdvisorLabel={t["Get Advisor"]}
getAdvisorHref="/marriage-advisors"
onGetAdvisor={openAdvisors}
/> />
</div> </div>
</div> </div>
@ -667,6 +672,11 @@ export default function NewMatchClient() {
</div> </div>
</div> </div>
)} )}
<MarriageAdvisorsOverlay
open={isAdvisorOpen}
onClose={closeAdvisors}
/>
</> </>
); );
} }

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

@ -6,6 +6,9 @@ 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 { useHabibWebReady } from "@/hooks/use-habib-web-ready";
import AdvisorActionsCard from "@/components/Componentes/advisor-actions-card"; 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 CallResultSheet from "@/components/Componentes/call-result-sheet";
import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet"; import DismissReasonSheet from "@/components/Componentes/dismiss-reason-sheet";
import FemaleConsentSheet from "@/components/Componentes/female-consent-sheet"; import FemaleConsentSheet from "@/components/Componentes/female-consent-sheet";
@ -174,6 +177,8 @@ function ContactInfoPhoneCard({ item }: { item: ContactInfoPhoneItem }) {
export default function RequestAcceptedClient() { export default function RequestAcceptedClient() {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const router = useRouter(); const router = useRouter();
const { isAdvisorOpen, openAdvisors, closeAdvisors } =
useMarriageAdvisorsOverlay();
const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false); const [isCallResultSheetOpen, setIsCallResultSheetOpen] = useState(false);
const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] = const [isDismissReasonSheetOpen, setIsDismissReasonSheetOpen] =
useState(false); useState(false);
@ -727,7 +732,7 @@ export default function RequestAcceptedClient() {
avatars={advisorAvatars} avatars={advisorAvatars}
extraCount={7} extraCount={7}
getAdvisorLabel={t["Get Advisor"]} getAdvisorLabel={t["Get Advisor"]}
getAdvisorHref="/marriage-advisors"
onGetAdvisor={openAdvisors}
/> />
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> <section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]">
@ -752,6 +757,11 @@ export default function RequestAcceptedClient() {
</div> </div>
</div> </div>
</main> </main>
<MarriageAdvisorsOverlay
open={isAdvisorOpen}
onClose={closeAdvisors}
/>
</> </>
); );
} }

12
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 { 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 MarriageAdvisorsOverlay, {
useMarriageAdvisorsOverlay,
} from "@/components/Componentes/marriage-advisors-overlay";
import PageHeader from "@/components/Componentes/page-header"; import PageHeader from "@/components/Componentes/page-header";
import { PageBackground } from "@/components/Componentes/page-background"; import { PageBackground } from "@/components/Componentes/page-background";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
@ -23,6 +26,8 @@ const advisorAvatars = [
export default function RequestSentClient() { export default function RequestSentClient() {
const router = useRouter(); const router = useRouter();
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const { isAdvisorOpen, openAdvisors, closeAdvisors } =
useMarriageAdvisorsOverlay();
const { data: profile, isLoading } = useMarriageProfileQuery({ const { data: profile, isLoading } = useMarriageProfileQuery({
refetchInterval: 3000, refetchInterval: 3000,
}); });
@ -115,7 +120,7 @@ export default function RequestSentClient() {
avatars={advisorAvatars} avatars={advisorAvatars}
extraCount={7} extraCount={7}
getAdvisorLabel={copy.getAdvisor} getAdvisorLabel={copy.getAdvisor}
getAdvisorHref="/marriage-advisors"
onGetAdvisor={openAdvisors}
/> />
<section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]"> <section className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]">
@ -139,6 +144,11 @@ export default function RequestSentClient() {
</div> </div>
</div> </div>
</main> </main>
<MarriageAdvisorsOverlay
open={isAdvisorOpen}
onClose={closeAdvisors}
/>
</> </>
); );
} }

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

@ -18,7 +18,8 @@ type AdvisorActionsCardProps = {
avatars: AdvisorAvatar[]; avatars: AdvisorAvatar[];
extraCount: number; extraCount: number;
getAdvisorLabel: string; getAdvisorLabel: string;
getAdvisorHref: string;
getAdvisorHref?: string;
onGetAdvisor?: () => void;
className?: string; className?: string;
}; };
@ -29,6 +30,7 @@ export function AdvisorActionsCard({
extraCount: fallbackExtraCount, extraCount: fallbackExtraCount,
getAdvisorLabel, getAdvisorLabel,
getAdvisorHref, getAdvisorHref,
onGetAdvisor,
className, className,
}: AdvisorActionsCardProps) { }: AdvisorActionsCardProps) {
const { data, isLoading } = useMarriageAdvisorsQuery(); const { data, isLoading } = useMarriageAdvisorsQuery();
@ -96,7 +98,8 @@ export function AdvisorActionsCard({
<Button <Button
className="w-auto rounded-[9px] border-none bg-[#EBEDF0] bg-none px-5 py-[13px] text-[#111111]! shadow-none" className="w-auto rounded-[9px] border-none bg-[#EBEDF0] bg-none px-5 py-[13px] text-[#111111]! shadow-none"
href={getAdvisorHref}
href={onGetAdvisor ? undefined : getAdvisorHref}
onClick={onGetAdvisor}
> >
{getAdvisorLabel} {getAdvisorLabel}
</Button> </Button>

134
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 (
<QueryClientProvider client={queryClient}>
<I18nProvider locale="fa">{children}</I18nProvider>
</QueryClientProvider>
);
};
}
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(
<MarriageAdvisorsOverlay open={true} onClose={handleClose} />,
{ 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(
<MarriageAdvisorsOverlay open={true} onClose={handleClose} />,
{ wrapper },
);
act(() => {
vi.advanceTimersByTime(16);
});
expect(screen.getByRole("dialog")).toHaveAttribute("data-state", "open");
expect(document.body.classList.contains("section-overlay-open")).toBe(true);
rerender(<MarriageAdvisorsOverlay open={false} onClose={handleClose} />);
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();
});
});

77
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 (
<SectionOverlayHost open={open} onClose={onClose}>
<MarriageAdvisorsPage onClose={onClose} />
</SectionOverlayHost>
);
}
export default MarriageAdvisorsOverlay;
Loading…
Cancel
Save