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 ( |
|||
<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