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